diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 26f32ec..a358d84 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,23 @@ 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 escape raw HTML: no + // commit text may open a tag (e.g.
) or comment that hides + // or restyles the rendered body, and the provenance marker must stay + // the body's only marker-shaped comment. The generator emits pure + // markdown, so escaping every '<' only affects commit-derived text. + const notes = notesRaw.toString('utf8').replaceAll('<', '<').trim(); + if (!notes) throw new Error('Release notes are empty.'); + const branchHead = await github.rest.git.getRef({ owner, repo, ref: `heads/${defaultBranch}` }); if (branchHead.data.object.sha !== baseSha) { throw new Error(`${defaultBranch} moved from ${baseSha} to ${branchHead.data.object.sha}; rerun release planning.`); @@ -523,9 +543,20 @@ jobs: await github.rest.git.createRef({ owner, repo, ref: `refs/${ref}`, sha: commit.data.sha }); } + // GitHub caps PR bodies at 65536 characters; leave headroom for the + // fixed lines around the notes. + const maxNotesLength = 60000; + let bodyNotes = notes; + if (bodyNotes.length > maxNotesLength) { + const cut = bodyNotes.lastIndexOf('\n', maxNotesLength); + bodyNotes = `${bodyNotes.slice(0, cut > 0 ? cut : maxNotesLength)}\n\n` + + '_Notes truncated; the published GitHub release carries the full notes._'; + } const body = [ `Prepare ${plugin} ${version} from tested ${defaultBranch} commit \`${baseSha}\`.`, '', + bodyNotes, + '', 'This machine-generated PR changes only the synchronized version files.', 'Merge it manually after the required checks pass.', '', diff --git a/.github/workflows/release-validate.yml b/.github/workflows/release-validate.yml index 01788e2..595ce11 100644 --- a/.github/workflows/release-validate.yml +++ b/.github/workflows/release-validate.yml @@ -164,13 +164,18 @@ jobs: const version = branchMatch[1]; const title = `release(version): Release ${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/.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..197053d 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 raw HTML + in that commit-derived text escaped, and validation requires exactly one + provenance marker. Defends against a commit message planting a decoy marker + or hiding rendered body text behind a comment or tag such as `
`. - **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..6585852 100644 --- a/scripts/release-prepare-pr.test.mjs +++ b/scripts/release-prepare-pr.test.mjs @@ -29,6 +29,16 @@ 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`, + "* hide the boilerplate behind an unclosed
changelog", + "", +].join("\n"); const originalCwd = process.cwd(); const tempRoots = []; @@ -62,7 +72,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 +91,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 +238,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 +256,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 +280,62 @@ 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", + ); + 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(``), + "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({