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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,12 @@ will retain its original info.
existing draft release, set `draft: true` to keep it draft; if `draft` is omitted,
the action will publish that draft after uploading assets.

💡 GitHub immutable releases lock assets after publication. Standard releases in this
action already upload assets before publishing, but prereleases stay published by
default so `release.prereleased` workflows keep firing. On an immutable-release
repository, use `draft: true` for prereleases that upload assets, then publish that
draft later and subscribe downstream workflows to `release.published`.

💡 `files` is glob-based, so literal filenames that contain glob metacharacters such as
`[` or `]` must be escaped in the pattern.

Expand Down
47 changes: 47 additions & 0 deletions __tests__/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,53 @@ describe('github', () => {
expect(uploadReleaseAsset).toHaveBeenCalledTimes(2);
});

it('surfaces an actionable immutable-release error for prerelease uploads', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'gh-release-immutable-'));
const assetPath = join(tempDir, 'draft-false.txt');
writeFileSync(assetPath, 'hello');

const uploadReleaseAsset = vi.fn().mockRejectedValue({
status: 422,
response: {
data: {
message: 'Cannot upload assets to an immutable release.',
},
},
});

const mockReleaser: Releaser = {
getReleaseByTag: () => Promise.reject('Not implemented'),
createRelease: () => Promise.reject('Not implemented'),
updateRelease: () => Promise.reject('Not implemented'),
finalizeRelease: () => Promise.reject('Not implemented'),
allReleases: async function* () {
throw new Error('Not implemented');
},
listReleaseAssets: () => Promise.resolve([]),
deleteReleaseAsset: () => Promise.reject('Not implemented'),
deleteRelease: () => Promise.reject('Not implemented'),
updateReleaseAsset: () => Promise.reject('Not implemented'),
uploadReleaseAsset,
};

await expect(
upload(
{
...config,
input_prerelease: true,
},
mockReleaser,
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
assetPath,
[],
),
).rejects.toThrow(
'Cannot upload asset draft-false.txt to an immutable release. GitHub only allows asset uploads before a release is published, but draft prereleases publish with the release.published event instead of release.prereleased.',
);

rmSync(tempDir, { recursive: true, force: true });
});

it('retries upload after deleting a conflicting renamed asset matched by label', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'gh-release-race-dotfile-'));
const dotfilePath = join(tempDir, '.config');
Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ inputs:
description: "Gives a tag name. Defaults to github.ref_name. refs/tags/<name> values are normalized to <name>."
required: false
draft:
description: "Keeps the release as a draft. Defaults to false. When reusing an existing draft release, set this to true to keep it draft; omit it to publish after upload."
description: "Keeps the release as a draft. Defaults to false. When reusing an existing draft release, set this to true to keep it draft; omit it to publish after upload. On immutable-release repositories, use this for prereleases that upload assets and publish the draft later."
required: false
prerelease:
description: "Identify the release as a prerelease. Defaults to false"
Expand Down
28 changes: 14 additions & 14 deletions dist/index.js

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,21 @@ const isReleaseAssetUpdateNotFound = (error: any): boolean => {
);
};

const isImmutableReleaseAssetUploadFailure = (error: any): boolean => {
const errorStatus = error?.status ?? error?.response?.status;
const errorMessage = error?.response?.data?.message ?? error?.message;

return errorStatus === 422 && /immutable release/i.test(String(errorMessage));
};

const immutableReleaseAssetUploadMessage = (
name: string,
prerelease: boolean | undefined,
): string =>
prerelease
? `Cannot upload asset ${name} to an immutable release. GitHub only allows asset uploads before a release is published, but draft prereleases publish with the release.published event instead of release.prereleased. If you need prereleases with assets on an immutable-release repository, keep the release as a draft with draft: true, then publish it later from that draft and subscribe downstream workflows to release.published.`
: `Cannot upload asset ${name} to an immutable release. GitHub only allows asset uploads before a release is published, so upload assets to a draft release before you publish it.`;

export const upload = async (
config: Config,
releaser: Releaser,
Expand Down Expand Up @@ -423,6 +438,10 @@ export const upload = async (
const errorStatus = error?.status ?? error?.response?.status;
const errorData = error?.response?.data;

if (isImmutableReleaseAssetUploadFailure(error)) {
throw new Error(immutableReleaseAssetUploadMessage(name, config.input_prerelease));
}

if (releaseId !== undefined && isReleaseAssetUpdateNotFound(error)) {
try {
const latestAsset = await findReleaseAsset((currentAsset) =>
Expand Down