From e09fd41adc37caef38e6802a04825fd054cbce66 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 20 Apr 2026 19:33:39 +0700 Subject: [PATCH] fix(release): patch existing release notes by id --- scripts/sync-release-notes-from-changelog.mjs | 54 ++++++++------- ...sync-release-notes-from-changelog.test.mjs | 65 +++++++++++++++++++ 2 files changed, 96 insertions(+), 23 deletions(-) create mode 100644 scripts/sync-release-notes-from-changelog.test.mjs diff --git a/scripts/sync-release-notes-from-changelog.mjs b/scripts/sync-release-notes-from-changelog.mjs index 6a63ba5ff..c83d77b5a 100644 --- a/scripts/sync-release-notes-from-changelog.mjs +++ b/scripts/sync-release-notes-from-changelog.mjs @@ -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 { tmpdir } from "node:os"; import path from "node:path"; @@ -117,21 +117,28 @@ function parseChangelog(changelogText) { }); } -function hasRelease(tag, repo) { +function getRelease(tag, repo, execFileSync = nodeExecFileSync) { try { - execFileSync("gh", ["release", "view", tag, "--repo", repo], { - stdio: "ignore", + const output = execFileSync("gh", ["api", `repos/${repo}/releases/tags/${tag}`], { + encoding: "utf8", }); - return true; + return JSON.parse(output); } catch { - return false; + return null; } } -function runGh(args) { +function runGh(args, execFileSync = nodeExecFileSync) { 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 }) { const today = new Date().toISOString().slice(0, 10); const lines = [ @@ -150,8 +157,9 @@ function buildPrereleaseNotes({ releaseTag, version }) { return `${lines.join("\n")}\n`; } -function main() { - const args = parseArgs(process.argv.slice(2)); +export function syncReleaseNotes(argv = process.argv.slice(2), deps = {}) { + const execFileSync = deps.execFileSync ?? nodeExecFileSync; + const args = parseArgs(argv); const changelogPath = path.resolve("CHANGELOG.md"); const changelogText = readFileSync(changelogPath, "utf8"); const entries = parseChangelog(changelogText); @@ -178,7 +186,6 @@ function main() { const notesPath = path.join(tempDir, `${targetTag}-notes.md`); writeFileSync(notesPath, notes); - const editArgs = ["release", "edit", targetTag, "--repo", args.repo, "--notes-file", notesPath]; const createArgs = [ "release", "create", @@ -194,16 +201,11 @@ function main() { ]; try { - if (hasRelease(targetTag, args.repo)) { - try { - runGh(editArgs); - console.log(`Updated release notes for ${targetTag}.`); - return; - } catch { - console.warn( - `Edit failed for ${targetTag} (release may have been recreated by another workflow); falling through to create.`, - ); - } + const release = getRelease(targetTag, args.repo, execFileSync); + if (release) { + updateReleaseNotes({ releaseId: release.id, repo: args.repo, notesPath }, execFileSync); + console.log(`Updated release notes for ${targetTag}.`); + return; } if (!args.createIfMissing) { @@ -214,13 +216,17 @@ function main() { } try { - runGh(createArgs); + 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.`, ); - 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.`); if (createError instanceof Error) { @@ -232,4 +238,6 @@ function main() { } } -main(); +if (import.meta.url === `file://${process.argv[1]}`) { + syncReleaseNotes(); +} diff --git a/scripts/sync-release-notes-from-changelog.test.mjs b/scripts/sync-release-notes-from-changelog.test.mjs new file mode 100644 index 000000000..6c7720649 --- /dev/null +++ b/scripts/sync-release-notes-from-changelog.test.mjs @@ -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", + ); + }); +});