Fixes #540 Add support for immutable releases (#544)

This commit is contained in:
Nick Cipollo
2025-08-28 13:15:38 -04:00
committed by GitHub
parent 05013d58ed
commit defcf131e4
12 changed files with 337 additions and 83 deletions

View File

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

View File

@@ -529,6 +529,145 @@ describe("Action", () => {
})
})
it("does not publish immutable release when immutableCreate is false", async () => {
const action = createAction(false, true, false, true, false, createBody, false, false)
await action.perform()
expect(createMock).toHaveBeenCalledWith(
tag,
generatedReleaseBody,
commit,
discussionCategory,
false, // draft should be false when createdDraft is false
makeLatest,
createName,
createPrerelease
)
// Should only call update once for regular create, not for publishImmutableRelease
expect(updateMock).not.toHaveBeenCalled()
assertOutputApplied()
})
it("does not publish immutable release when createdDraft is true", async () => {
const action = createAction(false, true, false, true, false, createBody, true, true)
await action.perform()
expect(createMock).toHaveBeenCalledWith(
tag,
generatedReleaseBody,
commit,
discussionCategory,
true, // draft should be true when createdDraft is true or immutableCreate is true
makeLatest,
createName,
createPrerelease
)
// Should only call update once for regular create, not for publishImmutableRelease
expect(updateMock).not.toHaveBeenCalled()
assertOutputApplied()
})
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 immutableReleaseResponse = {
data: {
id: 999,
upload_url: "http://immutable.example.com",
html_url: "https://github.com/owner/repo/releases/tag/v1.0.0-immutable",
tarball_url: "https://api.github.com/repos/owner/repo/tarball/v1.0.0-immutable",
zipball_url: "https://api.github.com/repos/owner/repo/zipball/v1.0.0-immutable",
},
}
updateMock.mockResolvedValueOnce(immutableReleaseResponse)
await action.perform()
expect(createMock).toHaveBeenCalledWith(
tag,
generatedReleaseBody,
commit,
discussionCategory,
true, // draft should be true when immutableCreate is true
makeLatest,
createName,
createPrerelease
)
// Should call update for publishImmutableRelease
expect(updateMock).toHaveBeenCalledWith(
releaseId,
tag,
undefined, // body is omitted
undefined, // commit is omitted
discussionCategory,
false, // draft is set to false to publish the release
makeLatest,
createName,
createPrerelease
)
// Should apply the immutable release data instead of the original
expect(applyReleaseDataMock).toHaveBeenCalledWith(immutableReleaseResponse.data)
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("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 error = { status: 404 }
getMock.mockRejectedValue(error)
listMock.mockResolvedValue({ data: [] }) // No draft releases found
const immutableReleaseResponse = {
data: {
id: 888,
upload_url: "http://immutable-update.example.com",
html_url: "https://github.com/owner/repo/releases/tag/v1.0.0-immutable-update",
tarball_url: "https://api.github.com/repos/owner/repo/tarball/v1.0.0-immutable-update",
zipball_url: "https://api.github.com/repos/owner/repo/zipball/v1.0.0-immutable-update",
},
}
updateMock.mockResolvedValueOnce(immutableReleaseResponse)
await action.perform()
// Should try to get the release first (allowUpdates=true)
expect(getMock).toHaveBeenCalledWith(tag)
// Should check for draft releases when get fails with 404
expect(listMock).toHaveBeenCalled()
// Should create a new release when no drafts found
expect(createMock).toHaveBeenCalledWith(
tag,
generatedReleaseBody,
commit,
discussionCategory,
true, // draft should be true when immutableCreate is true
makeLatest,
createName,
createPrerelease
)
// Should call update for publishImmutableRelease
expect(updateMock).toHaveBeenCalledWith(
releaseId,
tag,
undefined, // body is omitted
undefined, // commit is omitted
discussionCategory,
false, // draft is set to false to publish the release
makeLatest,
createName,
createPrerelease
)
// Should apply the immutable release data instead of the original
expect(applyReleaseDataMock).toHaveBeenCalledWith(immutableReleaseResponse.data)
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",
})
})
function assertOutputApplied() {
expect(applyReleaseDataMock).toHaveBeenCalledWith({
id: releaseId,
@@ -549,7 +688,9 @@ describe("Action", () => {
removeArtifacts = false,
generateReleaseNotes = true,
omitBodyDuringUpdate = false,
createdReleaseBody = createBody
createdReleaseBody = createBody,
immutableCreate = true,
createdDraft = createDraft
): Action {
let inputArtifact: Artifact[]
@@ -615,12 +756,13 @@ describe("Action", () => {
allowUpdates,
artifactErrorsFailBuild: true,
artifacts: inputArtifact,
createdDraft: createDraft,
createdDraft: createdDraft,
createdReleaseBody: createdReleaseBody,
createdReleaseName: createName,
commit,
discussionCategory,
generateReleaseNotes,
immutableCreate: immutableCreate,
makeLatest: makeLatest,
owner: "owner",
createdPrerelease: createPrerelease,

View File

@@ -200,6 +200,23 @@ describe("Inputs", () => {
})
})
describe("immutableCreate", () => {
it("returns true by default", function () {
mockGetInput.mockReturnValue("")
expect(inputs.immutableCreate).toBe(true)
})
it("returns true when explicitly set", function () {
mockGetInput.mockReturnValue("true")
expect(inputs.immutableCreate).toBe(true)
})
it("returns false when explicitly disabled", function () {
mockGetInput.mockReturnValue("false")
expect(inputs.immutableCreate).toBe(false)
})
})
describe("makeLatest", () => {
it("returns legacy", () => {
mockGetInput.mockReturnValueOnce("legacy")

View File

@@ -45,6 +45,7 @@ describe.skip("Integration Test", () => {
commit: undefined,
discussionCategory: "Release",
generateReleaseNotes: true,
immutableCreate: true,
omitBodyDuringUpdate: false,
owner: "ncipollo",
createdPrerelease: false,

View File

@@ -47,6 +47,10 @@ inputs:
description: 'Indicates if release notes should be automatically generated.'
required: false
default: 'false'
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'
makeLatest:
description: 'Indicates if the release should be the "latest" release or not.'
required: false

89
dist/index.js vendored
View File

@@ -66,20 +66,7 @@ class Action {
core.notice("Skipping action, release already exists and skipIfReleaseExists is enabled.");
return;
}
const releaseResponse = await this.createOrUpdateRelease();
const releaseData = releaseResponse.data;
const releaseId = releaseData.id;
const uploadUrl = releaseData.upload_url;
if (this.inputs.removeArtifacts) {
await this.artifactDestroyer.destroyArtifacts(releaseId);
}
const artifacts = this.inputs.artifacts;
let assetUrls = {};
if (artifacts.length > 0) {
assetUrls = await this.uploader.uploadArtifacts(artifacts, releaseId, uploadUrl);
}
this.outputs.applyReleaseData(releaseData);
this.outputs.applyAssetUrls(assetUrls);
await this.createOrUpdateRelease();
}
async createOrUpdateRelease() {
if (this.inputs.allowUpdates) {
@@ -88,32 +75,25 @@ class Action {
getResponse = await this.releases.getByTag(this.inputs.tag);
}
catch (error) {
return await this.checkForMissingReleaseError(error);
await this.checkForMissingReleaseError(error);
return;
}
// Fail if this isn't an unreleased release & updateOnlyUnreleased is enabled.
this.releaseValidator.validateReleaseUpdate(getResponse.data);
return await this.updateRelease(getResponse.data.id);
await this.updateRelease(getResponse.data.id);
}
else {
return await this.createRelease();
await this.createRelease();
}
}
async checkForMissingReleaseError(error) {
if (Action.noPublishedRelease(error)) {
return await this.updateDraftOrCreateRelease();
await this.updateDraftOrCreateRelease();
}
else {
throw error;
}
}
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;
}
return 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);
}
static noPublishedRelease(error) {
const githubError = new GithubError_1.GithubError(error);
return githubError.status == 404;
@@ -121,10 +101,10 @@ class Action {
async updateDraftOrCreateRelease() {
const draftReleaseId = await this.findMatchingDraftReleaseId();
if (draftReleaseId) {
return await this.updateRelease(draftReleaseId);
await this.updateRelease(draftReleaseId);
}
else {
return await this.createRelease();
await this.createRelease();
}
}
async findMatchingDraftReleaseId() {
@@ -137,13 +117,60 @@ class Action {
const draftRelease = releases.find((release) => release.draft && release.tag_name == tag);
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 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;
}
return await this.releases.create(this.inputs.tag, releaseBody, this.inputs.commit, this.inputs.discussionCategory, this.inputs.createdDraft, this.inputs.makeLatest, this.inputs.createdReleaseName, this.inputs.createdPrerelease);
// 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);
await this.processReleaseArtifactsAndOutputs(releaseResponse, true);
}
async processReleaseArtifactsAndOutputs(releaseResponse, wasCreated) {
const releaseData = releaseResponse.data;
const releaseId = releaseData.id;
const uploadUrl = releaseData.upload_url;
if (this.inputs.removeArtifacts) {
await this.artifactDestroyer.destroyArtifacts(releaseId);
}
const artifacts = this.inputs.artifacts;
let assetUrls = {};
if (artifacts.length > 0) {
assetUrls = await this.uploader.uploadArtifacts(artifacts, releaseId, uploadUrl);
}
if (wasCreated) {
const immutableRelease = await this.publishImmutableRelease(releaseId);
if (immutableRelease) {
this.setOutputs(immutableRelease.data, assetUrls);
return;
}
}
this.setOutputs(releaseData, assetUrls);
}
async publishImmutableRelease(releaseId) {
// Check if immutableCreate is on and createdDraft is off
if (!this.inputs.immutableCreate || this.inputs.createdDraft) {
return undefined;
}
return await this.releases.update(releaseId, this.inputs.tag, undefined, // body is omitted
undefined, // commit is omitted
this.inputs.discussionCategory, false, // We want to publish the release, set draft to false
this.inputs.makeLatest, this.inputs.createdReleaseName, this.inputs.createdPrerelease);
}
setOutputs(releaseData, assetUrls) {
this.outputs.applyReleaseData(releaseData);
this.outputs.applyAssetUrls(assetUrls);
}
}
exports.Action = Action;
@@ -833,6 +860,10 @@ class CoreInputs {
const generate = core.getInput("generateReleaseNotes");
return generate == "true";
}
get immutableCreate() {
const immutable = core.getInput("immutableCreate");
return immutable != "false";
}
get makeLatest() {
let latest = core.getInput("makeLatest");
if (latest == "true" || latest == "false" || latest == "legacy") {

2
dist/index.js.map vendored

File diff suppressed because one or more lines are too long

View File

@@ -41,7 +41,9 @@
"testMatch": ["**/*.test.ts"],
"testRunner": "jest-circus/runner",
"transform": {
"^.+\\.ts$": "ts-jest"
"^.+\\.ts$": ["ts-jest", {
"tsconfig": "tsconfig.test.json"
}]
},
"verbose": true
},

View File

@@ -47,83 +47,47 @@ export class Action {
return
}
const releaseResponse = await this.createOrUpdateRelease()
const releaseData = releaseResponse.data
const releaseId = releaseData.id
const uploadUrl = releaseData.upload_url
if (this.inputs.removeArtifacts) {
await this.artifactDestroyer.destroyArtifacts(releaseId)
}
const artifacts = this.inputs.artifacts
let assetUrls: Record<string, string> = {}
if (artifacts.length > 0) {
assetUrls = await this.uploader.uploadArtifacts(artifacts, releaseId, uploadUrl)
}
this.outputs.applyReleaseData(releaseData)
this.outputs.applyAssetUrls(assetUrls)
await this.createOrUpdateRelease()
}
private async createOrUpdateRelease(): Promise<CreateOrUpdateReleaseResponse> {
private async createOrUpdateRelease() {
if (this.inputs.allowUpdates) {
let getResponse: ReleaseByTagResponse
try {
getResponse = await this.releases.getByTag(this.inputs.tag)
} catch (error: any) {
return await this.checkForMissingReleaseError(error)
await this.checkForMissingReleaseError(error)
return
}
// Fail if this isn't an unreleased release & updateOnlyUnreleased is enabled.
this.releaseValidator.validateReleaseUpdate(getResponse.data)
return await this.updateRelease(getResponse.data.id)
await this.updateRelease(getResponse.data.id)
} else {
return await this.createRelease()
await this.createRelease()
}
}
private async checkForMissingReleaseError(error: Error): Promise<CreateOrUpdateReleaseResponse> {
private async checkForMissingReleaseError(error: Error): Promise<void> {
if (Action.noPublishedRelease(error)) {
return await this.updateDraftOrCreateRelease()
await this.updateDraftOrCreateRelease()
} else {
throw error
}
}
private async updateRelease(id: number): Promise<UpdateReleaseResponse> {
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
}
return 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
)
}
private static noPublishedRelease(error: any): boolean {
const githubError = new GithubError(error)
return githubError.status == 404
}
private async updateDraftOrCreateRelease(): Promise<CreateReleaseResponse | UpdateReleaseResponse> {
private async updateDraftOrCreateRelease(): Promise<void> {
const draftReleaseId = await this.findMatchingDraftReleaseId()
if (draftReleaseId) {
return await this.updateRelease(draftReleaseId)
await this.updateRelease(draftReleaseId)
} else {
return await this.createRelease()
await this.createRelease()
}
}
@@ -140,7 +104,30 @@ export class Action {
return draftRelease?.id
}
private async createRelease(): Promise<CreateReleaseResponse> {
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 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)
}
private async createRelease() {
let releaseBody = this.inputs.createdReleaseBody
if (this.inputs.generateReleaseNotes) {
@@ -148,15 +135,70 @@ export class Action {
releaseBody = response.data.body
}
return await this.releases.create(
// 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,
this.inputs.createdDraft,
draft,
this.inputs.makeLatest,
this.inputs.createdReleaseName,
this.inputs.createdPrerelease
)
await this.processReleaseArtifactsAndOutputs(releaseResponse, true)
}
private async processReleaseArtifactsAndOutputs(releaseResponse: CreateOrUpdateReleaseResponse, wasCreated: boolean) {
const releaseData = releaseResponse.data
const releaseId = releaseData.id
const uploadUrl = releaseData.upload_url
if (this.inputs.removeArtifacts) {
await this.artifactDestroyer.destroyArtifacts(releaseId)
}
const artifacts = this.inputs.artifacts
let assetUrls: Record<string, string> = {}
if (artifacts.length > 0) {
assetUrls = await this.uploader.uploadArtifacts(artifacts, releaseId, uploadUrl)
}
if (wasCreated) {
const immutableRelease = await this.publishImmutableRelease(releaseId)
if (immutableRelease) {
this.setOutputs(immutableRelease.data, assetUrls)
return
}
}
this.setOutputs(releaseData, assetUrls)
}
private async publishImmutableRelease(releaseId: number): Promise<CreateOrUpdateReleaseResponse | undefined> {
// Check if immutableCreate is on and createdDraft is off
if (!this.inputs.immutableCreate || this.inputs.createdDraft) {
return undefined
}
return await this.releases.update(
releaseId,
this.inputs.tag,
undefined, // body is omitted
undefined, // commit is omitted
this.inputs.discussionCategory,
false, // We want to publish the release, set draft to false
this.inputs.makeLatest,
this.inputs.createdReleaseName,
this.inputs.createdPrerelease
)
}
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 immutableCreate: boolean
readonly makeLatest?: "legacy" | "true" | "false" | undefined
readonly omitBodyDuringUpdate: boolean
readonly owner: string
@@ -137,6 +138,11 @@ export class CoreInputs implements Inputs {
return generate == "true"
}
get immutableCreate(): boolean {
const immutable = core.getInput("immutableCreate")
return immutable != "false"
}
get makeLatest(): "legacy" | "true" | "false" | undefined {
let latest = core.getInput("makeLatest")
if (latest == "true" || latest == "false" || latest == "legacy") {

7
tsconfig.test.json Normal file
View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"isolatedModules": true
},
"include": ["src/**/*", "__tests__/**/*"]
}