fix(release): keep releases draft until manifests are ready (#4201)

Create tag-triggered GitHub releases as drafts and publish them only after the desktop updater manifests are complete, stamped, and uploaded. Preserve beta prerelease semantics and safe retry behavior.\n\nCloses #4183
This commit is contained in:
Mohamed Boudra
2026-09-02 11:29:41 +02:00
committed by GitHub
parent 78f7796eb4
commit d67a6f5f6c
7 changed files with 404 additions and 60 deletions
+108
View File
@@ -0,0 +1,108 @@
import { execFileSync as nodeExecFileSync } from "node:child_process";
function parseJson(output) {
return JSON.parse(output);
}
function isNotFoundError(error) {
return String(error?.stderr ?? "").includes("HTTP 404");
}
export function getGitHubRelease(repo, tag, execFileSync = nodeExecFileSync) {
try {
return parseJson(
execFileSync("gh", ["api", `repos/${repo}/releases/tags/${tag}`], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}),
);
} catch (error) {
if (!isNotFoundError(error)) {
throw error;
}
const expectedName = `Paseo ${tag}`;
const output = execFileSync(
"gh",
[
"api",
`repos/${repo}/releases?per_page=100`,
"--jq",
`[.[] | select(.draft == true and .name == ${JSON.stringify(expectedName)})] | sort_by(.id)`,
],
{
encoding: "utf8",
},
);
return parseJson(output)[0] ?? null;
}
}
export function getReleaseLookupTag(release) {
if (release.draft === true && release.html_url) {
return new URL(release.html_url).pathname.split("/").at(-1);
}
return release.tag_name;
}
function parseArgs(argv) {
let repo = "";
let tag = "";
let cleanupDuplicates = false;
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === "--repo") {
repo = argv[index + 1] ?? "";
index += 1;
} else if (argv[index] === "--tag") {
tag = argv[index + 1] ?? "";
index += 1;
} else if (argv[index] === "--cleanup-duplicates") {
cleanupDuplicates = true;
} else {
throw new Error(`Unknown argument: ${argv[index]}`);
}
}
if (!repo || !tag) {
throw new Error("Usage: node scripts/github-release.mjs --repo <owner/repo> --tag <tag>");
}
return { cleanupDuplicates, repo, tag };
}
if (import.meta.url === `file://${process.argv[1]}`) {
const { cleanupDuplicates, repo, tag } = parseArgs(process.argv.slice(2));
let release = null;
for (let attempt = 0; attempt < 5 && !release; attempt += 1) {
release = getGitHubRelease(repo, tag);
if (!release && attempt < 4) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1_000);
}
}
if (!release) {
process.exitCode = 1;
} else {
if (cleanupDuplicates && release.draft === true) {
const expectedName = `Paseo ${tag}`;
const duplicateIds = nodeExecFileSync(
"gh",
[
"api",
`repos/${repo}/releases?per_page=100`,
"--jq",
`.[] | select(.draft == true and .name == ${JSON.stringify(expectedName)} and .id != ${release.id}) | .id`,
],
{ encoding: "utf8" },
)
.trim()
.split("\n")
.filter(Boolean);
for (const duplicateId of duplicateIds) {
nodeExecFileSync("gh", [
"api",
"--method",
"DELETE",
`repos/${repo}/releases/${duplicateId}`,
]);
}
}
process.stdout.write(`${getReleaseLookupTag(release)}\n`);
}
}
+25 -15
View File
@@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { parseChangelogEntries } from "./changelog-utils.mjs";
import { getGitHubRelease } from "./github-release.mjs";
import {
getReleaseInfoFromSourceTag,
normalizeReleaseTag,
@@ -16,7 +17,7 @@ Usage: node scripts/sync-release-notes-from-changelog.mjs [options]
Options:
--repo <owner/repo> Repository slug. Defaults to $GITHUB_REPOSITORY.
--tag <tag> Release tag (e.g. v0.1.14). Defaults to latest changelog entry.
--create-if-missing Create release if it does not already exist.
--create-if-missing Create a draft release if it does not already exist.
`;
process.stderr.write(usage.trimStart());
process.stderr.write("\n");
@@ -80,17 +81,6 @@ function parseChangelog(changelogText) {
});
}
function getRelease(tag, repo, execFileSync = nodeExecFileSync) {
try {
const output = execFileSync("gh", ["api", `repos/${repo}/releases/tags/${tag}`], {
encoding: "utf8",
});
return JSON.parse(output);
} catch {
return null;
}
}
function runGh(args, execFileSync = nodeExecFileSync) {
execFileSync("gh", args, { stdio: "inherit" });
}
@@ -117,6 +107,11 @@ function exposeGitHubContributorMentions(notes) {
export function syncReleaseNotes(argv = process.argv.slice(2), deps = {}) {
const execFileSync = deps.execFileSync ?? nodeExecFileSync;
const sleepSync =
deps.sleepSync ??
((milliseconds) => {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
});
const args = parseArgs(argv);
const changelogPath = path.resolve("CHANGELOG.md");
const changelogText = readFileSync(changelogPath, "utf8");
@@ -150,11 +145,12 @@ export function syncReleaseNotes(argv = process.argv.slice(2), deps = {}) {
"--notes-file",
notesPath,
"--verify-tag",
"--draft",
...(parseReleaseVersion(releaseInfo.version).isPrerelease ? ["--prerelease"] : []),
];
try {
const release = getRelease(targetTag, args.repo, execFileSync);
const release = getGitHubRelease(args.repo, targetTag, execFileSync);
if (release) {
updateReleaseNotes({ releaseId: release.id, repo: args.repo, notesPath }, execFileSync);
console.log(`Updated release notes for ${targetTag}.`);
@@ -170,12 +166,11 @@ export function syncReleaseNotes(argv = process.argv.slice(2), deps = {}) {
try {
runGh(createArgs, execFileSync);
console.log(`Created release ${targetTag} with changelog notes.`);
} catch (createError) {
console.warn(
`Release creation failed for ${targetTag}; attempting edit in case another workflow created it concurrently.`,
);
const raceRelease = getRelease(targetTag, args.repo, execFileSync);
const raceRelease = getGitHubRelease(args.repo, targetTag, execFileSync);
if (!raceRelease) {
throw createError;
}
@@ -185,7 +180,22 @@ export function syncReleaseNotes(argv = process.argv.slice(2), deps = {}) {
if (createError instanceof Error) {
console.warn(createError.message);
}
return;
}
for (let attempt = 0; attempt < 5; attempt += 1) {
const createdRelease = getGitHubRelease(args.repo, targetTag, execFileSync);
if (createdRelease) {
updateReleaseNotes(
{ releaseId: createdRelease.id, repo: args.repo, notesPath },
execFileSync,
);
console.log(`Created release ${targetTag} with changelog notes.`);
return;
}
sleepSync(1_000);
}
throw new Error(`Created release ${targetTag} could not be resolved.`);
} finally {
rmSync(tempDir, { force: true, recursive: true });
}
@@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { getGitHubRelease, getReleaseLookupTag } from "./github-release.mjs";
import { syncReleaseNotes } from "./sync-release-notes-from-changelog.mjs";
function withTempChangelog(fn, changelogText = "## 0.1.60-beta.1 - 2026-04-20\n\n- Beta notes.\n") {
@@ -19,6 +20,37 @@ function withTempChangelog(fn, changelogText = "## 0.1.60-beta.1 - 2026-04-20\n\
}
}
function notFoundError() {
return Object.assign(new Error("release not found"), { stderr: "gh: Not Found (HTTP 404)" });
}
test("uses the untagged URL slug for draft release CLI operations", () => {
assert.equal(
getReleaseLookupTag({
draft: true,
html_url: "https://github.com/getpaseo/paseo/releases/tag/untagged-draft",
tag_name: "v0.1.60-beta.1",
}),
"untagged-draft",
);
});
test("does not treat GitHub authentication failures as missing releases", () => {
const calls = [];
const authError = Object.assign(new Error("authentication failed"), {
stderr: "gh: HTTP 401: Bad credentials",
});
assert.throws(
() =>
getGitHubRelease("getpaseo/paseo", "v0.1.60-beta.1", (command, args) => {
calls.push({ args, command });
throw authError;
}),
authError,
);
assert.equal(calls.length, 1, "authentication failures must not fall back to draft lookup");
});
test("updates an existing release body through the release id API", () => {
withTempChangelog(() => {
const calls = [];
@@ -27,7 +59,7 @@ test("updates an existing release body through the release id API", () => {
calls.push({ args, command, options });
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases/tags/v0.1.60-beta.1") {
return JSON.stringify({ id: 311163621 });
return JSON.stringify({ id: 311163621, draft: false });
}
if (args[0] === "api" && args[1] === "-X" && args[2] === "PATCH") {
@@ -64,6 +96,103 @@ test("updates an existing release body through the release id API", () => {
});
});
test("updates a draft release body without publishing it", () => {
withTempChangelog(() => {
const calls = [];
const execFileSync = (command, args, options) => {
calls.push({ args, command, options });
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases/tags/v0.1.60-beta.1") {
throw notFoundError();
}
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases?per_page=100") {
return JSON.stringify([
{
id: 311163621,
draft: true,
name: "Paseo v0.1.60-beta.1",
tag_name: "untagged-draft",
html_url: "https://github.com/getpaseo/paseo/releases/tag/untagged-draft",
},
]);
}
if (args[0] === "api" && args[1] === "-X" && args[2] === "PATCH") {
return "[]";
}
throw new Error(`Unexpected gh call: ${command} ${args.join(" ")}`);
};
syncReleaseNotes(["--repo", "getpaseo/paseo", "--tag", "v0.1.60-beta.1"], {
execFileSync,
});
const updateCall = calls.find(
(call) => call.args[0] === "api" && call.args[1] === "-X" && call.args[2] === "PATCH",
);
assert.ok(updateCall, "the draft release should be updated");
assert.deepEqual(
updateCall.args.filter((arg) => arg.startsWith("draft=")),
[],
"updating notes must not change draft visibility",
);
});
});
test("creates missing beta releases as drafts", () => {
withTempChangelog(() => {
const calls = [];
let created = false;
const execFileSync = (command, args, options) => {
calls.push({ args, command, options });
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases/tags/v0.1.60-beta.1") {
throw notFoundError();
}
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases?per_page=100") {
return created
? JSON.stringify([
{
id: 311163621,
draft: true,
name: "Paseo v0.1.60-beta.1",
tag_name: "v0.1.60-beta.1",
},
])
: "[]";
}
if (args[0] === "release" && args[1] === "create") {
created = true;
return "";
}
if (args[0] === "api" && args[1] === "-X" && args[2] === "PATCH") {
return "";
}
throw new Error(`Unexpected gh call: ${command} ${args.join(" ")}`);
};
syncReleaseNotes(
["--repo", "getpaseo/paseo", "--tag", "v0.1.60-beta.1", "--create-if-missing"],
{ execFileSync },
);
const createCall = calls.find(
(call) => call.args[0] === "release" && call.args[1] === "create",
);
assert.ok(createCall, "the missing release should be created");
assert.equal(createCall.args.includes("--draft"), true);
assert.equal(createCall.args.includes("--prerelease"), true);
});
});
test("converts contributor profile links to mentions in synced release notes", () => {
const changelogText = [
"## 0.1.60-beta.1 - 2026-04-20",
@@ -77,7 +206,7 @@ test("converts contributor profile links to mentions in synced release notes", (
const execFileSync = (command, args) => {
if (args[0] === "api" && args[1] === "repos/getpaseo/paseo/releases/tags/v0.1.60-beta.1") {
return JSON.stringify({ id: 311163621 });
return JSON.stringify({ id: 311163621, draft: false });
}
if (args[0] === "api" && args[1] === "-X" && args[2] === "PATCH") {