Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/release-prepare.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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. <details>) 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('<', '&lt;').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.`);
Expand Down Expand Up @@ -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.',
'',
Expand Down
11 changes: 8 additions & 3 deletions .github/workflows/release-validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<!-- ${plugin}-release-pr schema=1 version=${version.replaceAll('.', '\\.')}` +
' base=([0-9a-f]{40}) -->',
'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,
Expand Down
11 changes: 8 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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(
`<!-- ${plugin}-release-pr schema=1 version=${version.replaceAll('.', '\\.')}` +
' base=([0-9a-f]{40}) -->',
'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,
Expand Down
5 changes: 5 additions & 0 deletions release-pipeline-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<details>`.
- **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.
Expand Down
74 changes: 71 additions & 3 deletions scripts/release-prepare-pr.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `<!-- ${PLUGIN}-release-pr schema=1 version=0.7.0 base=${"b".repeat(40)} -->`;
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 <details><summary>changelog</summary>",
"",
].join("\n");

const originalCwd = process.cwd();
const tempRoots = [];
Expand Down Expand Up @@ -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");
Expand All @@ -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;
}

Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -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(`<!-- ${PLUGIN}-release-pr `, "g"));
assert.equal(markers?.length, 1, "the body must contain exactly one marker-shaped comment");
assert.ok(
body.endsWith(`<!-- ${PLUGIN}-release-pr schema=1 version=0.7.0 base=${MASTER_SHA} -->`),
"the provenance marker must close the body",
);
assert.ok(
body.includes(`&lt;!-- ${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("&lt;details>&lt;summary>changelog&lt;/summary>"),
"commit-derived HTML tags must be escaped",
);
const rawHtmlStart = body.indexOf("<", body.indexOf("\n"));
assert.ok(
body.slice(rawHtmlStart).startsWith(`<!-- ${PLUGIN}-release-pr `),
"the provenance marker must be the only raw HTML after the intro line",
);
});

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(`<!-- ${PLUGIN}-release-pr schema=1 version=0.7.0 base=${MASTER_SHA} -->`),
"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({
Expand Down