7 Commits

Author SHA1 Message Date
Nick Cipollo
b7eabc95ff preparing release 1.20.0
Some checks failed
Test / check_pr (push) Has been cancelled
Release / Create Release (push) Has been cancelled
2025-09-02 16:01:27 -04:00
Nick Cipollo
e87de4c2e4 Fixes #542 Add previous tag option (#550) 2025-09-02 15:56:40 -04:00
Nick Cipollo
98d25d4189 preparing release 1.19.2
Some checks failed
Release / Create Release (push) Has been cancelled
Test / check_pr (push) Has been cancelled
2025-09-02 12:53:03 -04:00
Nick Cipollo
d3601261f8 Fixes #548 Add support body + generated release notes (#549) 2025-09-02 12:27:56 -04:00
Benoît Blanchon
571fe81601 Update immutableCreate's default in the documentation (#547)
The default of `immutableCreate` changed from `true` to `false` in version 1.9.1
2025-09-02 08:11:01 -04:00
Nick Cipollo
1c89adf398 preparing release 1.19.1
Some checks failed
Release / Create Release (push) Has been cancelled
Test / check_pr (push) Has been cancelled
2025-09-01 12:57:13 -04:00
Nick Cipollo
36bf8dd70f References #545 Default immutable builds to false 2025-09-01 12:47:09 -04:00
10 changed files with 206 additions and 64 deletions

View File

@@ -25,7 +25,7 @@ This action will create a GitHub release and optionally upload an artifact to it
| discussionCategory | When provided this will generate a discussion of the specified category. The category must exist otherwise this will cause the action to fail. This isn't used with draft releases. The default "Announcements" category is not supported via the API and will cause an error if used here. | false | "" |
| draft | Optionally marks this release as a draft release. Set to true to enable. | false | "" |
| generateReleaseNotes | Indicates if release notes should be automatically generated. | false | false |
| immutableCreate | Indicates if immutable release creation should be used. When enabled, the action will first create a draft, upload artifacts, then publish the release. | false | true |
| immutableCreate | Indicates if immutable release creation should be used. When enabled, the action will first create a draft, upload artifacts, then publish the release. | false | false |
| makeLatest | Indicates if the release should be the "latest" release or not. legacy specifies that the latest release should be determined based on the release creation date and higher semantic version. | false | "legacy" |
| name | An optional name for the release. If this is omitted the tag will be used. | false | "" |
| omitBody | Indicates if the release body should be omitted. | false | false |

View File

@@ -26,7 +26,7 @@
| discussionCategory | 当提供该选项时,将生成指定类别的 discussion。指定的类别必须存在否则将导致 Action 失败。这对草案状态的发布不生效。API 不支持默认的 Announcement 分类,使用这个分类会引发错误。 | false | "" |
| draft | 可选择将此版本标记为草稿版本。 设置为 true 以启用。 | false | "" |
| generateReleaseNotes | 指示是否应自动生成发行说明。 | false | false |
| immutableCreate | 指示是否应使用不可变发布创建。启用时,操作将首先创建草稿,上传产出文件,然后发布版本。 | false | true |
| immutableCreate | 指示是否应使用不可变发布创建。启用时,操作将首先创建草稿,上传产出文件,然后发布版本。 | false | false |
| makeLatest | 指示是否将这个发布设置为 latest。使用"lagecy"值则会根据发布时间和语义化版本号自动决定。 | false | "legacy" |
| name | 版本的可选名称。 如果省略,将使用标签。 | false | "" |
| omitBody | 指示是否应省略发布主体。 | false | false |

View File

@@ -49,6 +49,7 @@ const updateOnlyUnreleased = false
const url = TEST_URLS.UPLOAD_URL
const makeLatest = "legacy"
const generatedReleaseBody = "test release notes"
const previousTag = "v1.0.0"
describe("Action", () => {
beforeEach(() => {
@@ -68,7 +69,7 @@ describe("Action", () => {
await action.perform()
expect(genReleaseNotesMock).toHaveBeenCalledWith(tag)
expect(genReleaseNotesMock).toHaveBeenCalledWith(tag, undefined)
expect(createMock).toHaveBeenCalledWith(
tag,
generatedReleaseBody,
@@ -85,11 +86,11 @@ describe("Action", () => {
})
it("creates release with generated release notes that override existing body", async () => {
const action = createAction(false, false, false, true, false, "existing body")
const action = createAction(false, false, false, true, false, "")
await action.perform()
expect(genReleaseNotesMock).toHaveBeenCalledWith(tag)
expect(genReleaseNotesMock).toHaveBeenCalledWith(tag, undefined)
expect(createMock).toHaveBeenCalledWith(
tag,
generatedReleaseBody,
@@ -126,12 +127,52 @@ describe("Action", () => {
assertAssetUrlsApplied({})
})
it("creates release but does not upload if no artifact", async () => {
const action = createAction(false, false)
it("creates release with combined body and generated release notes", async () => {
const action = createAction(false, false, false, true, false, createBody)
await action.perform()
expect(genReleaseNotesMock).toHaveBeenCalledWith(tag)
expect(genReleaseNotesMock).toHaveBeenCalledWith(tag, undefined)
expect(createMock).toHaveBeenCalledWith(
tag,
`${createBody}\n${generatedReleaseBody}`,
commit,
discussionCategory,
createDraft,
makeLatest,
createName,
createPrerelease
)
expect(uploadMock).not.toHaveBeenCalled()
assertOutputApplied()
assertAssetUrlsApplied({})
})
it("creates release with combined body and generated release notes using previous tag", async () => {
const action = createAction(false, false, false, true, false, createBody, true, createDraft, updateBody, previousTag)
await action.perform()
expect(genReleaseNotesMock).toHaveBeenCalledWith(tag, previousTag)
expect(createMock).toHaveBeenCalledWith(
tag,
`${createBody}\n${generatedReleaseBody}`,
commit,
discussionCategory,
createDraft,
makeLatest,
createName,
createPrerelease
)
expect(uploadMock).not.toHaveBeenCalled()
})
it("creates release but does not upload if no artifact", async () => {
const action = createAction(false, false, false, true, false, "")
await action.perform()
expect(genReleaseNotesMock).toHaveBeenCalledWith(tag, undefined)
expect(createMock).toHaveBeenCalledWith(
tag,
generatedReleaseBody,
@@ -148,13 +189,13 @@ describe("Action", () => {
})
it("creates release if no release exists to update", async () => {
const action = createAction(true, true)
const action = createAction(true, true, false, true, false, "")
const error = { status: 404 }
getMock.mockRejectedValue(error)
await action.perform()
expect(genReleaseNotesMock).toHaveBeenCalledWith(tag)
expect(genReleaseNotesMock).toHaveBeenCalledWith(tag, undefined)
expect(createMock).toHaveBeenCalledWith(
tag,
generatedReleaseBody,
@@ -174,7 +215,7 @@ describe("Action", () => {
})
it("creates release if no draft releases", async () => {
const action = createAction(true, true)
const action = createAction(true, true, false, true, false, "")
const error = { status: 404 }
getMock.mockRejectedValue(error)
listMock.mockResolvedValue({
@@ -183,7 +224,7 @@ describe("Action", () => {
await action.perform()
expect(genReleaseNotesMock).toHaveBeenCalledWith(tag)
expect(genReleaseNotesMock).toHaveBeenCalledWith(tag, undefined)
expect(createMock).toHaveBeenCalledWith(
tag,
generatedReleaseBody,
@@ -203,11 +244,11 @@ describe("Action", () => {
})
it("creates release then uploads artifact", async () => {
const action = createAction(false, true)
const action = createAction(false, true, false, true, false, "")
await action.perform()
expect(genReleaseNotesMock).toHaveBeenCalledWith(tag)
expect(genReleaseNotesMock).toHaveBeenCalledWith(tag, undefined)
expect(createMock).toHaveBeenCalledWith(
tag,
generatedReleaseBody,
@@ -263,7 +304,7 @@ describe("Action", () => {
})
it("throws error when create fails", async () => {
const action = createAction(false, true)
const action = createAction(false, true, false, true, false, "")
createMock.mockRejectedValue("error")
expect.hasAssertions()
@@ -273,7 +314,7 @@ describe("Action", () => {
expect(error).toEqual("error")
}
expect(genReleaseNotesMock).toHaveBeenCalledWith(tag)
expect(genReleaseNotesMock).toHaveBeenCalledWith(tag, undefined)
expect(createMock).toHaveBeenCalledWith(
tag,
generatedReleaseBody,
@@ -337,7 +378,7 @@ describe("Action", () => {
})
it("throws error when update fails", async () => {
const action = createAction(true, true)
const action = createAction(true, true, false, true, false, "", true, createDraft, "")
updateMock.mockRejectedValue("error")
@@ -363,7 +404,7 @@ describe("Action", () => {
})
it("throws error when upload fails", async () => {
const action = createAction(false, true)
const action = createAction(false, true, false, true, false, "")
const expectedError = { status: 404 }
uploadMock.mockRejectedValue(expectedError)
@@ -374,7 +415,7 @@ describe("Action", () => {
expect(error).toEqual(expectedError)
}
expect(genReleaseNotesMock).toHaveBeenCalledWith(tag)
expect(genReleaseNotesMock).toHaveBeenCalledWith(tag, undefined)
expect(createMock).toHaveBeenCalledWith(
tag,
generatedReleaseBody,
@@ -389,7 +430,7 @@ describe("Action", () => {
})
it("updates draft release", async () => {
const action = createAction(true, true)
const action = createAction(true, true, false, true, false, "", true, createDraft, "")
const error = { status: 404 }
getMock.mockRejectedValue(error)
listMock.mockResolvedValue({
@@ -452,8 +493,53 @@ describe("Action", () => {
})
})
it("updates release with combined body and generated release notes", async () => {
const action = createAction(true, true, false, true, false, "", true, createDraft, updateBody)
await action.perform()
expect(genReleaseNotesMock).toHaveBeenCalledWith(tag, undefined)
expect(updateMock).toHaveBeenCalledWith(
id,
tag,
`${updateBody}\n${generatedReleaseBody}`,
commit,
discussionCategory,
updateDraft,
makeLatest,
updateName,
updatePrerelease
)
expect(uploadMock).toHaveBeenCalledWith(artifacts, releaseId, url)
assertOutputApplied()
assertAssetUrlsApplied({
"art1": "https://github.com/owner/repo/releases/download/v1.0.0/art1",
"art2": "https://github.com/owner/repo/releases/download/v1.0.0/art2",
})
})
it("updates release with combined body and generated release notes using previous tag", async () => {
const action = createAction(true, true, false, true, false, "", true, createDraft, updateBody, previousTag)
await action.perform()
expect(genReleaseNotesMock).toHaveBeenCalledWith(tag, previousTag)
expect(updateMock).toHaveBeenCalledWith(
id,
tag,
`${updateBody}\n${generatedReleaseBody}`,
commit,
discussionCategory,
updateDraft,
makeLatest,
updateName,
updatePrerelease
)
expect(uploadMock).toHaveBeenCalledWith(artifacts, releaseId, url)
})
it("updates release but does not upload if no artifact", async () => {
const action = createAction(true, false)
const action = createAction(true, false, false, true, false, "", true, createDraft, "")
await action.perform()
@@ -474,7 +560,7 @@ describe("Action", () => {
})
it("updates release then uploads artifact", async () => {
const action = createAction(true, true)
const action = createAction(true, true, false, true, false, "", true, createDraft, "")
await action.perform()
@@ -530,7 +616,7 @@ describe("Action", () => {
})
it("does not publish immutable release when immutableCreate is false", async () => {
const action = createAction(false, true, false, true, false, createBody, false, false)
const action = createAction(false, true, false, true, false, "", false, false)
await action.perform()
@@ -550,7 +636,7 @@ describe("Action", () => {
})
it("does not publish immutable release when createdDraft is true", async () => {
const action = createAction(false, true, false, true, false, createBody, true, true)
const action = createAction(false, true, false, true, false, "", true, true)
await action.perform()
@@ -570,7 +656,7 @@ describe("Action", () => {
})
it("publishes immutable release when immutableCreate is true and createdDraft is false", async () => {
const action = createAction(false, true, false, true, false, createBody, true, false)
const action = createAction(false, true, false, true, false, "", true, false)
const immutableReleaseResponse = {
data: {
id: 999,
@@ -615,7 +701,7 @@ describe("Action", () => {
})
it("publishes immutable release when allowUpdates is true but release does not exist", async () => {
const action = createAction(true, true, false, true, false, createBody, true, false)
const action = createAction(true, true, false, true, false, "", true, false)
const error = { status: 404 }
getMock.mockRejectedValue(error)
listMock.mockResolvedValue({ data: [] }) // No draft releases found
@@ -690,7 +776,9 @@ describe("Action", () => {
omitBodyDuringUpdate = false,
createdReleaseBody = createBody,
immutableCreate = true,
createdDraft = createDraft
createdDraft = createDraft,
updatedReleaseBody = updateBody,
generateReleaseNotesPreviousTag: string | undefined = undefined
): Action {
let inputArtifact: Artifact[]
@@ -762,6 +850,7 @@ describe("Action", () => {
commit,
discussionCategory,
generateReleaseNotes,
generateReleaseNotesPreviousTag: generateReleaseNotesPreviousTag,
immutableCreate: immutableCreate,
makeLatest: makeLatest,
owner: "owner",
@@ -773,7 +862,7 @@ describe("Action", () => {
tag,
token,
updatedDraft: updateDraft,
updatedReleaseBody: updateBody,
updatedReleaseBody: updatedReleaseBody,
updatedReleaseName: updateName,
updatedPrerelease: updatePrerelease,
updateOnlyUnreleased: updateOnlyUnreleased,

View File

@@ -200,10 +200,22 @@ describe("Inputs", () => {
})
})
describe("immutableCreate", () => {
it("returns true by default", function () {
describe("generateReleaseNotesPreviousTag", () => {
it("returns the previous tag when provided", function () {
mockGetInput.mockReturnValue("v1.0.0")
expect(inputs.generateReleaseNotesPreviousTag).toBe("v1.0.0")
})
it("returns undefined when omitted", function () {
mockGetInput.mockReturnValue("")
expect(inputs.immutableCreate).toBe(true)
expect(inputs.generateReleaseNotesPreviousTag).toBeUndefined()
})
})
describe("immutableCreate", () => {
it("returns false by default", function () {
mockGetInput.mockReturnValue("")
expect(inputs.immutableCreate).toBe(false)
})
it("returns true when explicitly set", function () {

View File

@@ -47,10 +47,14 @@ inputs:
description: 'Indicates if release notes should be automatically generated.'
required: false
default: 'false'
generateReleaseNotesPreviousTag:
description: 'An optional previous tag to use when generating release notes. This will limit the release notes to changes between the two tags.'
required: false
default: ''
immutableCreate:
description: 'Indicates if immutable release creation should be used. When enabled, the action will first create a draft, upload artifacts, then publish the release.'
required: false
default: 'true'
default: 'false'
makeLatest:
description: 'Indicates if the release should be the "latest" release or not.'
required: false

43
dist/index.js vendored
View File

@@ -118,20 +118,12 @@ class Action {
return draftRelease?.id;
}
async updateRelease(id) {
let releaseBody = this.inputs.updatedReleaseBody;
if (this.inputs.generateReleaseNotes && !this.inputs.omitBodyDuringUpdate) {
const response = await this.releases.generateReleaseNotes(this.inputs.tag);
releaseBody = response.data.body;
}
const releaseBody = await this.combineBodyWithReleaseNotes(this.inputs.updatedReleaseBody, true);
const releaseResponse = await this.releases.update(id, this.inputs.tag, releaseBody, this.inputs.commit, this.inputs.discussionCategory, this.inputs.updatedDraft, this.inputs.makeLatest, this.inputs.updatedReleaseName, this.inputs.updatedPrerelease);
await this.processReleaseArtifactsAndOutputs(releaseResponse, false);
}
async createRelease() {
let releaseBody = this.inputs.createdReleaseBody;
if (this.inputs.generateReleaseNotes) {
const response = await this.releases.generateReleaseNotes(this.inputs.tag);
releaseBody = response.data.body;
}
const releaseBody = await this.combineBodyWithReleaseNotes(this.inputs.createdReleaseBody, false);
// If immutableCreate is enabled we need to start with a draft release
const draft = this.inputs.createdDraft || this.inputs.immutableCreate;
const releaseResponse = await this.releases.create(this.inputs.tag, releaseBody, this.inputs.commit, this.inputs.discussionCategory, draft, this.inputs.makeLatest, this.inputs.createdReleaseName, this.inputs.createdPrerelease);
@@ -168,6 +160,21 @@ class Action {
this.inputs.discussionCategory, false, // We want to publish the release, set draft to false
this.inputs.makeLatest, this.inputs.createdReleaseName, this.inputs.createdPrerelease);
}
async combineBodyWithReleaseNotes(body, isUpdate) {
// Determine if we should generate release notes based on operation type
const shouldGenerateReleaseNotes = isUpdate
? this.inputs.generateReleaseNotes && !this.inputs.omitBodyDuringUpdate
: this.inputs.generateReleaseNotes;
if (!shouldGenerateReleaseNotes) {
return body;
}
const response = await this.releases.generateReleaseNotes(this.inputs.tag, this.inputs.generateReleaseNotesPreviousTag);
const releaseNotes = response.data.body;
if (!body || body.trim() === "") {
return releaseNotes;
}
return `${body}\n${releaseNotes}`;
}
setOutputs(releaseData, assetUrls) {
this.outputs.applyReleaseData(releaseData);
this.outputs.applyAssetUrls(assetUrls);
@@ -860,9 +867,13 @@ class CoreInputs {
const generate = core.getInput("generateReleaseNotes");
return generate == "true";
}
get generateReleaseNotesPreviousTag() {
const previousTag = core.getInput("generateReleaseNotesPreviousTag");
return previousTag || undefined;
}
get immutableCreate() {
const immutable = core.getInput("immutableCreate");
return immutable != "false";
return immutable == "true";
}
get makeLatest() {
let latest = core.getInput("makeLatest");
@@ -1100,12 +1111,16 @@ class GithubReleases {
repo: this.inputs.repo,
});
}
async generateReleaseNotes(tag) {
return this.git.rest.repos.generateReleaseNotes({
async generateReleaseNotes(tag, previousTag) {
const params = {
owner: this.inputs.owner,
repo: this.inputs.repo,
tag_name: tag,
});
};
if (previousTag) {
params.previous_tag_name = previousTag;
}
return this.git.rest.repos.generateReleaseNotes(params);
}
async getByTag(tag) {
return this.git.rest.repos.getReleaseByTag({

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

View File

@@ -105,12 +105,7 @@ export class Action {
}
private async updateRelease(id: number) {
let releaseBody = this.inputs.updatedReleaseBody
if (this.inputs.generateReleaseNotes && !this.inputs.omitBodyDuringUpdate) {
const response = await this.releases.generateReleaseNotes(this.inputs.tag)
releaseBody = response.data.body
}
const releaseBody = await this.combineBodyWithReleaseNotes(this.inputs.updatedReleaseBody, true)
const releaseResponse = await this.releases.update(
id,
@@ -128,12 +123,7 @@ export class Action {
}
private async createRelease() {
let releaseBody = this.inputs.createdReleaseBody
if (this.inputs.generateReleaseNotes) {
const response = await this.releases.generateReleaseNotes(this.inputs.tag)
releaseBody = response.data.body
}
const releaseBody = await this.combineBodyWithReleaseNotes(this.inputs.createdReleaseBody, false)
// If immutableCreate is enabled we need to start with a draft release
const draft = this.inputs.createdDraft || this.inputs.immutableCreate
@@ -197,6 +187,26 @@ export class Action {
)
}
private async combineBodyWithReleaseNotes(body: string | undefined, isUpdate: boolean): Promise<string | undefined> {
// Determine if we should generate release notes based on operation type
const shouldGenerateReleaseNotes = isUpdate
? this.inputs.generateReleaseNotes && !this.inputs.omitBodyDuringUpdate
: this.inputs.generateReleaseNotes
if (!shouldGenerateReleaseNotes) {
return body
}
const response = await this.releases.generateReleaseNotes(this.inputs.tag, this.inputs.generateReleaseNotesPreviousTag)
const releaseNotes = response.data.body
if (!body || body.trim() === "") {
return releaseNotes
}
return `${body}\n${releaseNotes}`
}
private setOutputs(releaseData: any, assetUrls: Record<string, string>): void {
this.outputs.applyReleaseData(releaseData)
this.outputs.applyAssetUrls(assetUrls)

View File

@@ -15,6 +15,7 @@ export interface Inputs {
readonly createdReleaseName?: string
readonly discussionCategory?: string
readonly generateReleaseNotes: boolean
readonly generateReleaseNotesPreviousTag?: string
readonly immutableCreate: boolean
readonly makeLatest?: "legacy" | "true" | "false" | undefined
readonly omitBodyDuringUpdate: boolean
@@ -138,9 +139,14 @@ export class CoreInputs implements Inputs {
return generate == "true"
}
get generateReleaseNotesPreviousTag(): string | undefined {
const previousTag = core.getInput("generateReleaseNotesPreviousTag")
return previousTag || undefined
}
get immutableCreate(): boolean {
const immutable = core.getInput("immutableCreate")
return immutable != "false"
return immutable == "true"
}
get makeLatest(): "legacy" | "true" | "false" | undefined {

View File

@@ -36,7 +36,7 @@ export interface Releases {
getByTag(tag: string): Promise<ReleaseByTagResponse>
generateReleaseNotes(tag: string): Promise<GenerateReleaseNotesResponse>
generateReleaseNotes(tag: string, previousTag?: string): Promise<GenerateReleaseNotesResponse>
listArtifactsForRelease(releaseId: number): Promise<ListReleaseAssetsResponseData>
@@ -106,12 +106,18 @@ export class GithubReleases implements Releases {
})
}
async generateReleaseNotes(tag: string): Promise<GenerateReleaseNotesResponse> {
return this.git.rest.repos.generateReleaseNotes({
async generateReleaseNotes(tag: string, previousTag?: string): Promise<GenerateReleaseNotesResponse> {
const params: any = {
owner: this.inputs.owner,
repo: this.inputs.repo,
tag_name: tag,
})
}
if (previousTag) {
params.previous_tag_name = previousTag
}
return this.git.rest.repos.generateReleaseNotes(params)
}
async getByTag(tag: string): Promise<ReleaseByTagResponse> {