Skip to content

Commit b959f31

Browse files
authored
fix: clarify immutable prerelease uploads (#763)
* fix: draft prereleases before uploading assets Signed-off-by: Rui Chen <rui@chenrui.dev> * fix: clarify immutable prerelease uploads Signed-off-by: Rui Chen <rui@chenrui.dev> --------- Signed-off-by: Rui Chen <rui@chenrui.dev>
1 parent 8a8510e commit b959f31

File tree

5 files changed

+87
-15
lines changed

5 files changed

+87
-15
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,12 @@ will retain its original info.
240240
existing draft release, set `draft: true` to keep it draft; if `draft` is omitted,
241241
the action will publish that draft after uploading assets.
242242

243+
💡 GitHub immutable releases lock assets after publication. Standard releases in this
244+
action already upload assets before publishing, but prereleases stay published by
245+
default so `release.prereleased` workflows keep firing. On an immutable-release
246+
repository, use `draft: true` for prereleases that upload assets, then publish that
247+
draft later and subscribe downstream workflows to `release.published`.
248+
243249
💡 `files` is glob-based, so literal filenames that contain glob metacharacters such as
244250
`[` or `]` must be escaped in the pattern.
245251

__tests__/github.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,53 @@ describe('github', () => {
614614
expect(uploadReleaseAsset).toHaveBeenCalledTimes(2);
615615
});
616616

617+
it('surfaces an actionable immutable-release error for prerelease uploads', async () => {
618+
const tempDir = mkdtempSync(join(tmpdir(), 'gh-release-immutable-'));
619+
const assetPath = join(tempDir, 'draft-false.txt');
620+
writeFileSync(assetPath, 'hello');
621+
622+
const uploadReleaseAsset = vi.fn().mockRejectedValue({
623+
status: 422,
624+
response: {
625+
data: {
626+
message: 'Cannot upload assets to an immutable release.',
627+
},
628+
},
629+
});
630+
631+
const mockReleaser: Releaser = {
632+
getReleaseByTag: () => Promise.reject('Not implemented'),
633+
createRelease: () => Promise.reject('Not implemented'),
634+
updateRelease: () => Promise.reject('Not implemented'),
635+
finalizeRelease: () => Promise.reject('Not implemented'),
636+
allReleases: async function* () {
637+
throw new Error('Not implemented');
638+
},
639+
listReleaseAssets: () => Promise.resolve([]),
640+
deleteReleaseAsset: () => Promise.reject('Not implemented'),
641+
deleteRelease: () => Promise.reject('Not implemented'),
642+
updateReleaseAsset: () => Promise.reject('Not implemented'),
643+
uploadReleaseAsset,
644+
};
645+
646+
await expect(
647+
upload(
648+
{
649+
...config,
650+
input_prerelease: true,
651+
},
652+
mockReleaser,
653+
'https://uploads.github.com/repos/owner/repo/releases/1/assets',
654+
assetPath,
655+
[],
656+
),
657+
).rejects.toThrow(
658+
'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.',
659+
);
660+
661+
rmSync(tempDir, { recursive: true, force: true });
662+
});
663+
617664
it('retries upload after deleting a conflicting renamed asset matched by label', async () => {
618665
const tempDir = mkdtempSync(join(tmpdir(), 'gh-release-race-dotfile-'));
619666
const dotfilePath = join(tempDir, '.config');

action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ inputs:
1616
description: "Gives a tag name. Defaults to github.ref_name. refs/tags/<name> values are normalized to <name>."
1717
required: false
1818
draft:
19-
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."
19+
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."
2020
required: false
2121
prerelease:
2222
description: "Identify the release as a prerelease. Defaults to false"

dist/index.js

Lines changed: 14 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/github.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,21 @@ const isReleaseAssetUpdateNotFound = (error: any): boolean => {
283283
);
284284
};
285285

286+
const isImmutableReleaseAssetUploadFailure = (error: any): boolean => {
287+
const errorStatus = error?.status ?? error?.response?.status;
288+
const errorMessage = error?.response?.data?.message ?? error?.message;
289+
290+
return errorStatus === 422 && /immutable release/i.test(String(errorMessage));
291+
};
292+
293+
const immutableReleaseAssetUploadMessage = (
294+
name: string,
295+
prerelease: boolean | undefined,
296+
): string =>
297+
prerelease
298+
? `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.`
299+
: `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.`;
300+
286301
export const upload = async (
287302
config: Config,
288303
releaser: Releaser,
@@ -423,6 +438,10 @@ export const upload = async (
423438
const errorStatus = error?.status ?? error?.response?.status;
424439
const errorData = error?.response?.data;
425440

441+
if (isImmutableReleaseAssetUploadFailure(error)) {
442+
throw new Error(immutableReleaseAssetUploadMessage(name, config.input_prerelease));
443+
}
444+
426445
if (releaseId !== undefined && isReleaseAssetUpdateNotFound(error)) {
427446
try {
428447
const latestAsset = await findReleaseAsset((currentAsset) =>

0 commit comments

Comments
 (0)