feat: mark release as draft until all artifacts are uploaded

Previously, the releases were created and then artifacts (if any)
were added to them. This broke when GitHub released "immutable"
releases, which disallow changes after the release is published.

Make it so that releases are always marked as "draft" when being
worked on by the action, and unmarked as draft (if desired) once
the action is completed.

Fixes #653
This commit is contained in:
jj 2025-11-18 15:26:08 +00:00
parent 5be0e66d93
commit f0aaee5ca0
No known key found for this signature in database
4 changed files with 67 additions and 8 deletions

View file

@ -49,6 +49,7 @@ describe('github', () => {
getReleaseByTag: () => Promise.reject('Not implemented'), getReleaseByTag: () => Promise.reject('Not implemented'),
createRelease: () => Promise.reject('Not implemented'), createRelease: () => Promise.reject('Not implemented'),
updateRelease: () => Promise.reject('Not implemented'), updateRelease: () => Promise.reject('Not implemented'),
finalizeRelease: () => Promise.reject('Not implemented'),
allReleases: async function* () { allReleases: async function* () {
yield { data: [mockRelease] }; yield { data: [mockRelease] };
}, },
@ -254,11 +255,12 @@ describe('github', () => {
name: 'test', name: 'test',
body: 'test', body: 'test',
target_commitish: 'main', target_commitish: 'main',
draft: false, draft: true,
prerelease: false, prerelease: false,
assets: [], assets: [],
}, },
}), }),
finalizeRelease: async () => {},
allReleases: async function* () { allReleases: async function* () {
yield { yield {
data: [ data: [

2
dist/index.js vendored

File diff suppressed because one or more lines are too long

View file

@ -58,6 +58,12 @@ export interface Releaser {
make_latest: 'true' | 'false' | 'legacy' | undefined; make_latest: 'true' | 'false' | 'legacy' | undefined;
}): Promise<{ data: Release }>; }): Promise<{ data: Release }>;
finalizeRelease(params: {
owner: string;
repo: string;
release_id: number;
}): Promise<{ data: Release }>;
allReleases(params: { owner: string; repo: string }): AsyncIterableIterator<{ data: Release[] }>; allReleases(params: { owner: string; repo: string }): AsyncIterableIterator<{ data: Release[] }>;
} }
@ -160,6 +166,15 @@ export class GitHubReleaser implements Releaser {
return this.github.rest.repos.updateRelease(params); return this.github.rest.repos.updateRelease(params);
} }
async finalizeRelease(params: { owner: string; repo: string; release_id: number }) {
return await this.github.rest.repos.updateRelease({
owner: params.owner,
repo: params.repo,
release_id: params.release_id,
draft: false,
});
}
allReleases(params: { owner: string; repo: string }): AsyncIterableIterator<{ data: Release[] }> { allReleases(params: { owner: string; repo: string }): AsyncIterableIterator<{ data: Release[] }> {
const updatedParams = { per_page: 100, ...params }; const updatedParams = { per_page: 100, ...params };
return this.github.paginate.iterator( return this.github.paginate.iterator(
@ -303,7 +318,6 @@ export const release = async (
body = workflowBody || existingReleaseBody; body = workflowBody || existingReleaseBody;
} }
const draft = config.input_draft !== undefined ? config.input_draft : existingRelease.draft;
const prerelease = const prerelease =
config.input_prerelease !== undefined ? config.input_prerelease : existingRelease.prerelease; config.input_prerelease !== undefined ? config.input_prerelease : existingRelease.prerelease;
@ -317,7 +331,7 @@ export const release = async (
target_commitish, target_commitish,
name, name,
body, body,
draft, draft: existingRelease.draft,
prerelease, prerelease,
discussion_category_name, discussion_category_name,
generate_release_notes, generate_release_notes,
@ -345,6 +359,45 @@ export const release = async (
} }
}; };
/**
* Finalizes a release by unmarking it as "draft" (if relevant)
* after all artifacts have been uploaded.
*
* @param config - Release configuration as specified by user
* @param releaser - The GitHub API wrapper for release operations
* @param release - The existing release to be finalized
* @param maxRetries - The maximum number of attempts to finalize the release
*/
export const finalizeRelease = async (
config: Config,
releaser: Releaser,
release: Release,
maxRetries: number = 3,
): Promise<Release> => {
if (config.input_draft === true) {
return release;
}
if (maxRetries <= 0) {
console.log(`❌ Too many retries. Aborting...`);
throw new Error('Too many retries.');
}
const [owner, repo] = config.github_repository.split('/');
try {
const { data } = await releaser.finalizeRelease({
owner,
repo,
release_id: release.id,
});
return data;
} catch {
console.log(`retrying... (${maxRetries - 1} retries remaining)`);
return finalizeRelease(config, releaser, release, maxRetries - 1);
}
};
/** /**
* Finds a release by tag name from all a repository's releases. * Finds a release by tag name from all a repository's releases.
* *
@ -385,7 +438,6 @@ async function createRelease(
const tag_name = tag; const tag_name = tag;
const name = config.input_name || tag; const name = config.input_name || tag;
const body = releaseBody(config); const body = releaseBody(config);
const draft = config.input_draft;
const prerelease = config.input_prerelease; const prerelease = config.input_prerelease;
const target_commitish = config.input_target_commitish; const target_commitish = config.input_target_commitish;
const make_latest = config.input_make_latest; const make_latest = config.input_make_latest;
@ -401,7 +453,7 @@ async function createRelease(
tag_name, tag_name,
name, name,
body, body,
draft, draft: true,
prerelease, prerelease,
target_commitish, target_commitish,
discussion_category_name, discussion_category_name,

View file

@ -1,6 +1,6 @@
import { setFailed, setOutput } from '@actions/core'; import { setFailed, setOutput } from '@actions/core';
import { getOctokit } from '@actions/github'; import { getOctokit } from '@actions/github';
import { GitHubReleaser, release, upload } from './github'; import { GitHubReleaser, release, finalizeRelease, upload } from './github';
import { isTag, parseConfig, paths, unmatchedPatterns, uploadUrl } from './util'; import { isTag, parseConfig, paths, unmatchedPatterns, uploadUrl } from './util';
import { env } from 'process'; import { env } from 'process';
@ -48,7 +48,8 @@ async function run() {
}, },
}); });
//); //);
const rel = await release(config, new GitHubReleaser(gh)); const releaser = new GitHubReleaser(gh);
let rel = await release(config, releaser);
if (config.input_files && config.input_files.length > 0) { if (config.input_files && config.input_files.length > 0) {
const files = paths(config.input_files, config.input_working_directory); const files = paths(config.input_files, config.input_working_directory);
if (files.length == 0) { if (files.length == 0) {
@ -81,6 +82,10 @@ async function run() {
const assets = results.filter(Boolean); const assets = results.filter(Boolean);
setOutput('assets', assets); setOutput('assets', assets);
} }
console.log('Finalizing release...');
rel = await finalizeRelease(config, releaser, rel);
console.log(`🎉 Release ready at ${rel.html_url}`); console.log(`🎉 Release ready at ${rel.html_url}`);
setOutput('url', rel.html_url); setOutput('url', rel.html_url);
setOutput('id', rel.id.toString()); setOutput('id', rel.id.toString());