Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,20 @@ jobs:
token: ${{ secrets.CUSTOM_GITHUB_TOKEN }}
```

When you use GitHub's built-in `generate_release_notes` support, you can optionally
pin the comparison base explicitly with `previous_tag`. This is useful when the default
comparison range does not match the release series you want to publish.

```yaml
- name: Release
uses: softprops/action-gh-release@v2
with:
tag_name: stage-2026-03-15
target_commitish: ${{ github.sha }}
previous_tag: prod-2026-03-01
generate_release_notes: true
```

### 💅 Customizing

#### inputs
Expand All @@ -196,6 +210,7 @@ The following are optional as `step.with` keys
| `token` | String | Authorized GitHub token or PAT. Defaults to `${{ github.token }}` when omitted. A non-empty explicit token overrides `GITHUB_TOKEN`. Passing `""` treats the token as explicitly unset, so omit the input entirely or use an expression such as `${{ inputs.token || github.token }}` when wrapping this action in a composite action. |
| `discussion_category_name` | String | If specified, a discussion of the specified category is created and linked to the release. The value must be a category that already exists in the repository. For more information, see ["Managing categories for discussions in your repository."](https://docs.github.com/en/discussions/managing-discussions-for-your-community/managing-categories-for-discussions-in-your-repository) |
| `generate_release_notes` | Boolean | Whether to automatically generate the name and body for this release. If name is specified, the specified name will be used; otherwise, a name will be automatically generated. If body is specified, the body will be pre-pended to the automatically generated notes. See the [GitHub docs for this feature](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes) for more information |
| `previous_tag` | String | Optional. When `generate_release_notes` is enabled, use this tag as GitHub's `previous_tag_name` comparison base. If omitted, GitHub chooses the comparison base automatically. |
| `append_body` | Boolean | Append to existing body instead of overwriting it |
| `make_latest` | String | Specifies whether this release should be set as the latest release for the repository. Drafts and prereleases cannot be set as latest. Can be `true`, `false`, or `legacy`. Uses GitHub api defaults if not provided |

Expand Down
177 changes: 177 additions & 0 deletions __tests__/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
asset,
findTagFromReleases,
finalizeRelease,
GitHubReleaser,
mimeOrDefault,
release,
Release,
Expand Down Expand Up @@ -32,6 +33,7 @@ describe('github', () => {
input_target_commitish: undefined,
input_discussion_category_name: undefined,
input_generate_release_notes: false,
input_previous_tag: undefined,
input_append_body: false,
input_make_latest: undefined,
};
Expand Down Expand Up @@ -146,6 +148,86 @@ describe('github', () => {
});
});

describe('GitHubReleaser', () => {
it('passes previous_tag_name to generateReleaseNotes and strips it from createRelease', async () => {
const generateReleaseNotes = vi.fn(async () => ({
data: {
name: 'Generated release',
body: "## What's Changed\n* Added support for previous_tag",
},
}));
const createRelease = vi.fn(async (params) => ({
data: {
id: 1,
upload_url: 'test',
html_url: 'test',
tag_name: params.tag_name,
name: params.name,
body: params.body,
target_commitish: params.target_commitish || 'main',
draft: params.draft ?? false,
prerelease: params.prerelease ?? false,
assets: [],
},
}));

const releaser = new GitHubReleaser({
rest: {
repos: {
generateReleaseNotes,
createRelease,
updateRelease: vi.fn(),
getReleaseByTag: vi.fn(),
listReleaseAssets: vi.fn(),
deleteReleaseAsset: vi.fn(),
deleteRelease: vi.fn(),
updateReleaseAsset: vi.fn(),
listReleases: {
endpoint: {
merge: vi.fn(),
},
},
},
},
paginate: {
iterator: vi.fn(),
},
request: vi.fn(),
} as any);

await releaser.createRelease({
owner: 'owner',
repo: 'repo',
tag_name: 'v1.0.0',
name: 'v1.0.0',
body: 'Intro',
draft: false,
prerelease: false,
target_commitish: 'abc123',
discussion_category_name: undefined,
generate_release_notes: true,
make_latest: undefined,
previous_tag_name: 'v0.9.0',
});

expect(generateReleaseNotes).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repo',
tag_name: 'v1.0.0',
target_commitish: 'abc123',
previous_tag_name: 'v0.9.0',
});
expect(createRelease).toHaveBeenCalledWith(
expect.objectContaining({
tag_name: 'v1.0.0',
body: "Intro\n\n## What's Changed\n* Added support for previous_tag",
generate_release_notes: false,
}),
);
expect(createRelease.mock.calls[0][0]).not.toHaveProperty('previous_tag_name');
});
});

describe('finalizeRelease input_draft behavior', () => {
const draftRelease: Release = {
id: 1,
Expand Down Expand Up @@ -340,6 +422,101 @@ describe('github', () => {
});

describe('error handling', () => {
it('passes previous_tag_name through when creating a release with generated notes', async () => {
const createReleaseSpy = vi.fn(async () => ({
data: {
id: 1,
upload_url: 'test',
html_url: 'test',
tag_name: 'v1.0.0',
name: 'test',
body: 'generated notes',
target_commitish: 'main',
draft: true,
prerelease: false,
assets: [],
},
}));

await release(
{
...config,
input_generate_release_notes: true,
input_previous_tag: 'v0.9.0',
},
{
getReleaseByTag: () => Promise.reject({ status: 404 }),
createRelease: createReleaseSpy,
updateRelease: () => Promise.reject('Not implemented'),
finalizeRelease: () => Promise.reject('Not implemented'),
allReleases: async function* () {
yield { data: [] };
},
listReleaseAssets: () => Promise.reject('Not implemented'),
deleteReleaseAsset: () => Promise.reject('Not implemented'),
deleteRelease: () => Promise.reject('Not implemented'),
updateReleaseAsset: () => Promise.reject('Not implemented'),
uploadReleaseAsset: () => Promise.reject('Not implemented'),
},
1,
);

expect(createReleaseSpy).toHaveBeenCalledWith(
expect.objectContaining({
tag_name: 'v1.0.0',
generate_release_notes: true,
previous_tag_name: 'v0.9.0',
}),
);
});

it('passes previous_tag_name through when updating a release with generated notes', async () => {
const existingRelease: Release = {
id: 1,
upload_url: 'test',
html_url: 'test',
tag_name: 'v1.0.0',
name: 'test',
body: 'existing body',
target_commitish: 'main',
draft: false,
prerelease: false,
assets: [],
};
const updateReleaseSpy = vi.fn(async () => ({ data: existingRelease }));

await release(
{
...config,
input_generate_release_notes: true,
input_previous_tag: 'v0.9.0',
},
{
getReleaseByTag: () => Promise.resolve({ data: existingRelease }),
createRelease: () => Promise.reject('Not implemented'),
updateRelease: updateReleaseSpy,
finalizeRelease: () => Promise.reject('Not implemented'),
allReleases: async function* () {
yield { data: [existingRelease] };
},
listReleaseAssets: () => Promise.reject('Not implemented'),
deleteReleaseAsset: () => Promise.reject('Not implemented'),
deleteRelease: () => Promise.reject('Not implemented'),
updateReleaseAsset: () => Promise.reject('Not implemented'),
uploadReleaseAsset: () => Promise.reject('Not implemented'),
},
1,
);

expect(updateReleaseSpy).toHaveBeenCalledWith(
expect.objectContaining({
release_id: existingRelease.id,
generate_release_notes: true,
previous_tag_name: 'v0.9.0',
}),
);
});

it('creates published prereleases without the forced draft-first path', async () => {
const prereleaseConfig = {
...config,
Expand Down
Loading