fix(release): patch existing release notes by id

This commit is contained in:
Mohamed Boudra
2026-04-20 19:33:39 +07:00
parent 8d32167826
commit e09fd41adc
2 changed files with 96 additions and 23 deletions

View File

@@ -1,4 +1,4 @@
import { execFileSync } from "node:child_process"; import { execFileSync as nodeExecFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
@@ -117,21 +117,28 @@ function parseChangelog(changelogText) {
}); });
} }
function hasRelease(tag, repo) { function getRelease(tag, repo, execFileSync = nodeExecFileSync) {
try { try {
execFileSync("gh", ["release", "view", tag, "--repo", repo], { const output = execFileSync("gh", ["api", `repos/${repo}/releases/tags/${tag}`], {
stdio: "ignore", encoding: "utf8",
}); });
return true; return JSON.parse(output);
} catch { } catch {
return false; return null;
} }
} }
function runGh(args) { function runGh(args, execFileSync = nodeExecFileSync) {
execFileSync("gh", args, { stdio: "inherit" }); execFileSync("gh", args, { stdio: "inherit" });
} }
function updateReleaseNotes({ releaseId, repo, notesPath }, execFileSync = nodeExecFileSync) {
runGh(
["api", "-X", "PATCH", `repos/${repo}/releases/${releaseId}`, "-F", `body=@${notesPath}`],
execFileSync,
);
}
function buildPrereleaseNotes({ releaseTag, version }) { function buildPrereleaseNotes({ releaseTag, version }) {
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
const lines = [ const lines = [
@@ -150,8 +157,9 @@ function buildPrereleaseNotes({ releaseTag, version }) {
return `${lines.join("\n")}\n`; return `${lines.join("\n")}\n`;
} }
function main() { export function syncReleaseNotes(argv = process.argv.slice(2), deps = {}) {
const args = parseArgs(process.argv.slice(2)); const execFileSync = deps.execFileSync ?? nodeExecFileSync;
const args = parseArgs(argv);
const changelogPath = path.resolve("CHANGELOG.md"); const changelogPath = path.resolve("CHANGELOG.md");
const changelogText = readFileSync(changelogPath, "utf8"); const changelogText = readFileSync(changelogPath, "utf8");
const entries = parseChangelog(changelogText); const entries = parseChangelog(changelogText);
@@ -178,7 +186,6 @@ function main() {
const notesPath = path.join(tempDir, `${targetTag}-notes.md`); const notesPath = path.join(tempDir, `${targetTag}-notes.md`);
writeFileSync(notesPath, notes); writeFileSync(notesPath, notes);
const editArgs = ["release", "edit", targetTag, "--repo", args.repo, "--notes-file", notesPath];
const createArgs = [ const createArgs = [
"release", "release",
"create", "create",
@@ -194,16 +201,11 @@ function main() {
]; ];
try { try {
if (hasRelease(targetTag, args.repo)) { const release = getRelease(targetTag, args.repo, execFileSync);
try { if (release) {
runGh(editArgs); updateReleaseNotes({ releaseId: release.id, repo: args.repo, notesPath }, execFileSync);
console.log(`Updated release notes for ${targetTag}.`); console.log(`Updated release notes for ${targetTag}.`);
return; return;
} catch {
console.warn(
`Edit failed for ${targetTag} (release may have been recreated by another workflow); falling through to create.`,
);
}
} }
if (!args.createIfMissing) { if (!args.createIfMissing) {
@@ -214,13 +216,17 @@ function main() {
} }
try { try {
runGh(createArgs); runGh(createArgs, execFileSync);
console.log(`Created release ${targetTag} with changelog notes.`); console.log(`Created release ${targetTag} with changelog notes.`);
} catch (createError) { } catch (createError) {
console.warn( console.warn(
`Release creation failed for ${targetTag}; attempting edit in case another workflow created it concurrently.`, `Release creation failed for ${targetTag}; attempting edit in case another workflow created it concurrently.`,
); );
runGh(editArgs); const raceRelease = getRelease(targetTag, args.repo, execFileSync);
if (!raceRelease) {
throw createError;
}
updateReleaseNotes({ releaseId: raceRelease.id, repo: args.repo, notesPath }, execFileSync);
console.log(`Updated release notes for ${targetTag} after create race.`); console.log(`Updated release notes for ${targetTag} after create race.`);
if (createError instanceof Error) { if (createError instanceof Error) {
@@ -232,4 +238,6 @@ function main() {
} }
} }
main(); if (import.meta.url === `file://${process.argv[1]}`) {
syncReleaseNotes();
}

View File

@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { syncReleaseNotes } from "./sync-release-notes-from-changelog.mjs";
function withTempChangelog(fn) {
const previousCwd = process.cwd();
const tempDir = mkdtempSync(path.join(tmpdir(), "paseo-release-notes-test-"));
process.chdir(tempDir);
writeFileSync("CHANGELOG.md", "## 0.1.60 - 2026-04-20\n\n- Stable notes.\n");
try {
fn();
} finally {
process.chdir(previousCwd);
rmSync(tempDir, { force: true, recursive: true });
}
}
test("updates an existing release body through the release id API", () => {
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-rc.3") {
return JSON.stringify({ id: 311163621 });
}
if (args[0] === "api" && args[1] === "-X" && args[2] === "PATCH") {
const notesArg = args.find((arg) => arg.startsWith("body=@"));
assert.ok(notesArg, "PATCH should send the notes body from a file");
const notesPath = notesArg.slice("body=@".length);
assert.match(notesPath, /v0\.1\.60-rc\.3-notes\.md$/);
return "";
}
throw new Error(`Unexpected gh call: ${command} ${args.join(" ")}`);
};
syncReleaseNotes(["--repo", "getpaseo/paseo", "--tag", "v0.1.60-rc.3"], {
execFileSync,
});
assert.equal(
calls.some((call) => call.args[0] === "release" && call.args[1] === "edit"),
false,
"retagged releases should not use gh release edit because it can resend tag_name",
);
assert.equal(
calls.some(
(call) =>
call.args[0] === "api" &&
call.args[1] === "-X" &&
call.args[2] === "PATCH" &&
call.args[3] === "repos/getpaseo/paseo/releases/311163621",
),
true,
"existing releases should be patched by release id",
);
});
});