From e793af98d486409ba34b9ad8f8dd82b62e548ae8 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 13 Jul 2026 17:01:12 +0200 Subject: [PATCH 1/2] feat(release): embed digest-verified release notes in the release PR body The standing release PR body now carries the semantic-release notes the plan job already generates, so the maintainer sees what ships before merging. The notes travel through the existing artifact and are verified against a new notes-sha256 plan output, commit-derived HTML comment openers are neutralized so the provenance marker stays the body's only marker-shaped comment, and oversized notes truncate below GitHub's body limit. Marker extraction in release-validate and release now requires exactly one match instead of first-match-anywhere, so a decoy marker in a commit message fails loudly instead of confusingly. --- .github/workflows/release-prepare.yml | 29 ++++++++++++ .github/workflows/release-validate.yml | 11 +++-- .github/workflows/release.yml | 11 +++-- release-pipeline-plan.md | 5 ++ scripts/release-prepare-pr.test.mjs | 64 ++++++++++++++++++++++++-- 5 files changed, 111 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 26f32ec..d8f770c 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -86,6 +86,7 @@ jobs: outputs: artifact-name: ${{ steps.plan.outputs.artifact-name }} base-sha: ${{ steps.plan.outputs.base-sha }} + notes-sha256: ${{ steps.plan.outputs.notes-sha256 }} release: ${{ steps.plan.outputs.release || 'false' }} release-files: ${{ steps.files.outputs.list }} version: ${{ steps.plan.outputs.version }} @@ -273,6 +274,7 @@ jobs: --base-sha "$TARGET_SHA" \ --package-manager "$PACKAGE_MANAGER" cp "$NOTES" "$OUTPUT/release-notes.md" + echo "notes-sha256=$(sha256sum "$NOTES" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" ARTIFACT_NAME="release-version-files-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" echo "artifact-name=$ARTIFACT_NAME" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" @@ -324,6 +326,7 @@ jobs: env: BASE_SHA: ${{ needs.plan.outputs.base-sha }} VERSION: ${{ needs.plan.outputs.version }} + NOTES_SHA256: ${{ needs.plan.outputs.notes-sha256 }} RELEASE_FILES: ${{ needs.plan.outputs.release-files }} PLUGIN_NAME: ${{ inputs.plugin-name }} DEFAULT_BRANCH: ${{ inputs.default-branch }} @@ -388,6 +391,21 @@ jobs: throw new Error('Release artifact metadata contains unexpected paths.'); } + const notesPath = path.join(root, 'release-notes.md'); + const notesStat = fs.lstatSync(notesPath); + if (!notesStat.isFile() || notesStat.isSymbolicLink()) { + throw new Error('Invalid release artifact: release-notes.md'); + } + const notesRaw = fs.readFileSync(notesPath); + if (crypto.createHash('sha256').update(notesRaw).digest('hex') !== process.env.NOTES_SHA256) { + throw new Error('Release artifact digest mismatch: release-notes.md'); + } + // The notes derive from commit messages, so neutralize HTML comment + // openers: the provenance marker must stay the body's only + // marker-shaped comment, and no note may hide rendered body text. + const notes = notesRaw.toString('utf8').replaceAll('', + 'g', ); - const marker = markerPattern.exec(pull.body || ''); - if (!marker) throw new Error(`PR #${pullNumber} is missing valid release provenance.`); - const baseSha = marker[1]; + const markers = [...(pull.body || '').matchAll(markerPattern)]; + if (markers.length !== 1) { + throw new Error(`PR #${pullNumber} must carry exactly one valid release provenance marker.`); + } + const baseSha = markers[0][1]; const files = await retryRead(() => github.paginate(github.rest.pulls.listFiles, { owner, diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e28ff5b..e87353e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -196,13 +196,18 @@ jobs: const title = `release(version): Release ${version}`; if (pull.title !== title) throw new Error(`PR #${pullNumber} title does not match ${version}.`); + // The body also carries commit-derived release notes, so provenance + // binds to exactly one marker: ambiguity fails loudly. const markerPattern = new RegExp( `', + 'g', ); - const marker = markerPattern.exec(pull.body || ''); - if (!marker) throw new Error(`PR #${pullNumber} is missing valid release provenance.`); - const baseSha = marker[1]; + const markers = [...(pull.body || '').matchAll(markerPattern)]; + if (markers.length !== 1) { + throw new Error(`PR #${pullNumber} must carry exactly one valid release provenance marker.`); + } + const baseSha = markers[0][1]; const files = await retryRead(() => github.paginate(github.rest.pulls.listFiles, { owner, diff --git a/release-pipeline-plan.md b/release-pipeline-plan.md index af5526a..bb4d82c 100644 --- a/release-pipeline-plan.md +++ b/release-pipeline-plan.md @@ -162,6 +162,11 @@ otherwise. Layered checks: - **Commit-message contract + parent** (validate): head commit message is the exact marker and its single parent is the recorded base. Defends against a rewritten or reparented release commit. +- **Unambiguous body marker** (prepare + validate + resolve): the PR body embeds + the digest-verified release notes for the human merge decision, with HTML + comment openers in that commit-derived text neutralized, and validation + requires exactly one provenance marker. Defends against a commit message + planting a decoy marker or hiding rendered body text. - **Squash-parent + tree-sha** (validate): the squash merge commit has one parent (the base) and its tree equals the validated head tree. Defends against a merge that changed content relative to what was reviewed. diff --git a/scripts/release-prepare-pr.test.mjs b/scripts/release-prepare-pr.test.mjs index f52b77b..528b216 100644 --- a/scripts/release-prepare-pr.test.mjs +++ b/scripts/release-prepare-pr.test.mjs @@ -29,6 +29,15 @@ const RELEASE_FILES = ["package.json", "package-lock.json", "manifest.json", "ve const MASTER_SHA = "a".repeat(40); const STALE_BASE_SHA = "d".repeat(40); const STALE_HEAD_SHA = "c".repeat(40); +const INJECTED_MARKER = ``; +const NOTES = [ + "## [0.7.0](https://example.test/compare/0.6.7...0.7.0) (2026-07-13)", + "", + "### Features", + "", + `* sneak ${INJECTED_MARKER} into a commit subject`, + "", +].join("\n"); const originalCwd = process.cwd(); const tempRoots = []; @@ -62,7 +71,7 @@ async function loadOpenPrScript() { return body.join("\n"); } -async function writeArtifact(version, baseSha) { +async function writeArtifact(version, baseSha, notes) { const root = await fs.mkdtemp(path.join(os.tmpdir(), "opw-open-pr-")); tempRoots.push(root); const dir = path.join(root, "release-version-files"); @@ -81,6 +90,7 @@ async function writeArtifact(version, baseSha) { path.join(dir, "release-version-files.json"), JSON.stringify({ schemaVersion: 1, version, baseSha, files: entries }), ); + await fs.writeFile(path.join(dir, "release-notes.md"), notes); return root; } @@ -227,9 +237,9 @@ function createGithub(state) { }; } -async function runOpenPrScript(state, { version, baseSha = MASTER_SHA } = {}) { +async function runOpenPrScript(state, { version, baseSha = MASTER_SHA, notes = NOTES, notesSha256 } = {}) { const script = await loadOpenPrScript(); - const root = await writeArtifact(version, baseSha); + const root = await writeArtifact(version, baseSha, notes); process.chdir(root); const run = new AsyncFunction("github", "context", "core", "require", "process", script); await run( @@ -245,6 +255,7 @@ async function runOpenPrScript(state, { version, baseSha = MASTER_SHA } = {}) { env: { BASE_SHA: baseSha, VERSION: version, + NOTES_SHA256: notesSha256 ?? crypto.createHash("sha256").update(notes).digest("hex"), RELEASE_FILES: JSON.stringify(RELEASE_FILES), PLUGIN_NAME: PLUGIN, DEFAULT_BRANCH, @@ -268,6 +279,53 @@ describe("release-prepare open-pr script", () => { assert.deepEqual(state.comments, []); }); + it("embeds the sanitized release notes above a single provenance marker", async () => { + const state = createState({ branches: { [DEFAULT_BRANCH]: MASTER_SHA } }); + + await runOpenPrScript(state, { version: "0.7.0" }); + + const body = state.pulls[0].body; + assert.match(body, /^Prepare notetweet 0\.7\.0 from tested master commit `a{40}`\./); + assert.ok(body.includes("### Features"), "release notes are missing from the body"); + const markers = body.match(new RegExp(``), + "the provenance marker must close the body", + ); + assert.ok( + body.includes(`<!-- ${PLUGIN}-release-pr schema=1 version=0.7.0 base=${"b".repeat(40)} -->`), + "commit-derived HTML comment openers must be neutralized", + ); + }); + + it("truncates oversized notes below the GitHub body limit, keeping the marker", async () => { + const state = createState({ branches: { [DEFAULT_BRANCH]: MASTER_SHA } }); + const longNotes = `## 0.7.0\n\n${"* a fix line padded out to keep each entry realistic\n".repeat(1500)}`; + + await runOpenPrScript(state, { version: "0.7.0", notes: longNotes }); + + const body = state.pulls[0].body; + assert.ok(body.length <= 65536, `body length ${body.length} exceeds the GitHub limit`); + assert.ok(body.includes("_Notes truncated;"), "truncated bodies must say so"); + assert.ok( + body.endsWith(``), + "the provenance marker must survive truncation", + ); + }); + + it("rejects release notes whose digest does not match the plan output", async () => { + const state = createState({ branches: { [DEFAULT_BRANCH]: MASTER_SHA } }); + + await assert.rejects( + runOpenPrScript(state, { version: "0.7.0", notesSha256: "0".repeat(64) }), + /digest mismatch: release-notes\.md/, + ); + + assert.equal(state.pulls.length, 0); + assert.ok(!state.branches.has("release/0.7.0"), "no branch may be created from tampered notes"); + }); + it("refreshes the standing PR in place when the version is unchanged", async () => { const headSha = "f".repeat(40); const state = createState({ From c06e3767dff7a71baeadd3fba98999686c069f3a Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 13 Jul 2026 17:09:21 +0200 Subject: [PATCH 2/2] fix(release): escape all raw HTML in commit-derived release notes Neutralizing only comment openers left tags like an unclosed
active, which could visually hide the generated boilerplate from the human reviewer. The notes generator emits pure markdown, so escaping every '<' only affects commit-derived text. Addresses Codex review feedback on #21. --- .github/workflows/release-prepare.yml | 10 ++++++---- release-pipeline-plan.md | 8 ++++---- scripts/release-prepare-pr.test.mjs | 10 ++++++++++ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index d8f770c..a358d84 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -400,10 +400,12 @@ jobs: if (crypto.createHash('sha256').update(notesRaw).digest('hex') !== process.env.NOTES_SHA256) { throw new Error('Release artifact digest mismatch: release-notes.md'); } - // The notes derive from commit messages, so neutralize HTML comment - // openers: the provenance marker must stay the body's only - // marker-shaped comment, and no note may hide rendered body text. - const notes = notesRaw.toString('utf8').replaceAll('`), "commit-derived HTML comment openers must be neutralized", ); + assert.ok( + body.includes("<details><summary>changelog</summary>"), + "commit-derived HTML tags must be escaped", + ); + const rawHtmlStart = body.indexOf("<", body.indexOf("\n")); + assert.ok( + body.slice(rawHtmlStart).startsWith(`