fix(release): validate frozen checkpoint versions and breaking notes

Audit actual first-parent ancestry instead of trusting the release bot
chronological cutoff, and block mismatched versions before tag creation.
Sync the published 6.0.0 baseline without dropping newer main changes.

Fixes #1063

Assisted-by: Sakuya (gensokyo-skills:sakuya-checkpointed-execution)
This commit is contained in:
N0zoM1z0
2026-10-08 10:13:05 +00:00
parent 5dfd9905ed
commit 60825fb447
7 changed files with 962 additions and 0 deletions
+4
View File
@@ -59,6 +59,10 @@ relevant real-provider or platform workflow, or explain why it was unavailable.
limits, security/privacy/containment implications, and meaningful proof gaps. -->
- Breaking changes or migration steps:
<!-- For ! / BREAKING CHANGE: show a previously valid call/configuration,
its new behavior and migration, and why compatibility/deprecation cannot
preserve it. Compatible fixes/additions should use patch/minor semantics.
See docs/releasing.md. -->
- Real Hopper/Ghidra, browser, or OS coverage:
- Package or release metadata impact:
- Security, privacy, process, or containment review:
+37
View File
@@ -84,6 +84,32 @@ jobs:
exit 1
fi
- name: Check out release controller and Git history
uses: actions/checkout@v6
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js for checkpoint validation
uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
package-manager-cache: false
- name: Install checkpoint validation dependencies
run: npm ci
- name: Validate checkpoint version and ancestry before release creation
env:
RELEASE_PHASE: ${{ inputs.phase }}
RELEASE_BRANCH: ${{ inputs.release_branch }}
SOURCE_SHA: ${{ steps.branch.outputs.sha }}
run: >-
node scripts/verify-release-checkpoint.mjs
--phase "$RELEASE_PHASE" --release-branch "$RELEASE_BRANCH"
--source-sha "$SOURCE_SHA"
- uses: googleapis/release-please-action@v4
id: release
with:
@@ -116,6 +142,7 @@ jobs:
uses: actions/checkout@v6
with:
ref: ${{ fromJSON(steps.release.outputs.pr).headBranchName }}
fetch-depth: 0
persist-credentials: false
- name: Set up Node.js for generated documentation
@@ -129,6 +156,16 @@ jobs:
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
run: npm ci
- name: Validate prepared release version and migration notes
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
env:
RELEASE_BRANCH: ${{ inputs.release_branch }}
SOURCE_SHA: ${{ steps.branch.outputs.sha }}
run: >-
node scripts/verify-release-checkpoint.mjs
--phase prepare --stage candidate --release-branch "$RELEASE_BRANCH"
--source-sha "$SOURCE_SHA"
- name: Regenerate release documentation
if: inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'
run: npm run docs:generate
+42
View File
@@ -11,6 +11,32 @@ breaking changes. Record the full source SHA and create `release/VERSION` at
that commit. The version is a maintainer choice; Release Please's proposed
version must agree before publication.
`release/VERSION` also sets the expected version for the workflow. Use an exact
SemVer such as `release/6.1.0` or `release/6.1.0-rc.1`, without build metadata.
The checkpoint validator rejects a candidate whose package, lockfile, manifest,
registry metadata or changelog disagrees with that version before creating a tag.
### Compatibility and version selection
REA uses the default SemVer strategy: compatible fixes increment patch;
compatible capabilities increment minor; incompatible public behavior increments
major. Adding tools, internal refactoring and correcting behavior outside the
supported contract do not by themselves require a major release.
The supported public surface includes documented CLI commands/options, MCP tool
names and input/result contracts, saved evidence formats and supported runtime
requirements. Before adding `!` or a `BREAKING CHANGE` footer, a PR must identify
a previously valid call or configuration that will fail or change meaning,
explain why compatibility cannot be preserved, and give its migration. Prefer
optional additions, compatibility adapters and a documented deprecation period;
group deliberate removals into a planned major release. Maintainers review this
impact before merging; the bot parses markers and cannot infer compatibility.
For example, adding an optional inspection tool is a minor change. Rejecting a
relative MCP path previously accepted by the public contract is breaking;
rejecting an input that the existing contract already prohibited is a fix.
Changing the version number alone does not restore compatibility.
For example, after selecting a reviewed commit for 5.0.0:
```bash
@@ -48,6 +74,22 @@ normalization, then approve that head's blocked runs from the PR page. Updates
to the bot branch can create new approval-required runs; approval of an older
head does not verify the normalized candidate. Wait for the final head's CI.
The validator reads the actual ancestry range from the published baseline tag
to the selected checkpoint, including visible first-parent Conventional Commits.
This matters after merging a side-branch release back into a newer main:
Release Please's chronological history cutoff can omit unreleased mainline
commits. Unreleased breaking markers require a major increment and references
in the new release's breaking-change migration section. Other omitted entries
are reported for review. Historical notes from an older release cannot satisfy
the new release's migration check. This audits declared markers and references;
it does not prove API compatibility or the quality of a migration explanation.
If preparation reports a mismatch or omitted breaking changes, inspect the
recorded ancestry report, correct the release PR's version artifacts and notes,
regenerate documentation, and review/test its final head. Do not repeatedly
prepare over manual corrections. Publication independently checks the merged
candidate again before Release Please can create a tag.
Review the candidate's version, notes, generated metadata, and package
contents. Wait for the candidate's CI and relevant real-provider checks.
Routine local iterations need focused checks; CI owns full deterministic
+320
View File
@@ -0,0 +1,320 @@
import { gt, major, valid } from "semver";
import { z } from "zod";
const shaSchema = z.string().regex(/^[a-f0-9]{40}$/u);
const versionSchema = z.object({ version: z.string() });
const manifestSchema = z.object({ ".": z.string() });
const sectionsSchema = z.object({
"changelog-sections": z.array(
z.object({ type: z.string(), hidden: z.boolean().optional() }),
),
});
function releaseVersion(value, location) {
if (valid(value) !== value || value.includes("+")) {
throw new Error(
`${location} must contain an exact SemVer without build metadata: ${value}`,
);
}
return value;
}
async function readJson(git, sha, path, schema) {
const text = await git(["show", `${sha}:${path}`]);
let value;
try {
value = JSON.parse(text);
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error(`${path}@${sha} contains invalid JSON: ${error.message}`);
}
throw error;
}
const parsed = schema.safeParse(value);
if (!parsed.success) {
throw new Error(`${path}@${sha} is malformed: ${parsed.error.message}`);
}
return parsed.data;
}
function readCommits(text) {
if (text.length === 0) return [];
const fields = text.split("\0");
if (fields.pop() !== "" || fields.length % 3 !== 0) {
throw new Error("Git returned malformed checkpoint history records.");
}
const commits = [];
for (let index = 0; index < fields.length; index += 3) {
const sha = shaSchema.parse(fields[index]);
const subject = z.string().parse(fields[index + 1]);
const body = z.string().parse(fields[index + 2]);
const type = /^([a-z]+)(?:\([^)]*\))?!?: /u.exec(subject)?.[1];
commits.push({
sha,
subject,
type,
breaking:
/^[a-z]+(?:\([^)]*\))?!: /u.test(subject) ||
/^BREAKING(?: CHANGE|-CHANGE):\s*\S/mu.test(body),
});
}
return commits;
}
function referencesCommit(text, commit) {
if (text.includes(commit.sha.slice(0, 7))) return true;
const pullRequest = /\(#(\d+)\)$/u.exec(commit.subject)?.[1];
return (
pullRequest !== undefined &&
new RegExp(`#${pullRequest}(?!\\d)`, "u").test(text)
);
}
function releaseNotes(text, expectedVersion) {
const headings = Array.from(text.matchAll(/^## \[([^\]]+)\].*$/gmu));
const first = headings[0];
if (first?.[1] !== expectedVersion || first.index === undefined) {
throw new Error(`CHANGELOG.md must start with release ${expectedVersion}.`);
}
return text.slice(first.index, headings[1]?.index ?? text.length);
}
function breakingSection(notes) {
const heading = /^### [^\n]*BREAKING CHANGES[^\n]*\n/imu.exec(notes);
if (heading === null) return "";
const body = notes.slice(heading.index + heading[0].length);
const end = body.search(/^### /mu);
return end < 0 ? body : body.slice(0, end);
}
async function requireAncestor(git, ancestor, descendant, description) {
try {
await git(["merge-base", "--is-ancestor", ancestor, descendant]);
} catch (error) {
const failure = z.object({ code: z.literal(1) }).safeParse(error);
if (!failure.success) throw error;
throw new Error(
`${description}: ${ancestor} is not an ancestor of ${descendant}. Sync the released tag into main before cutting a checkpoint, and prepare candidates from that frozen source.`,
);
}
}
async function resolveBaseline(git, tag) {
try {
return shaSchema.parse(
(await git(["rev-parse", "--verify", `${tag}^{commit}`])).trim(),
);
} catch (error) {
const failure = z.object({ code: z.literal(128) }).safeParse(error);
if (!failure.success) throw error;
throw new Error(
`Published baseline ${tag} is unavailable. Fetch release tags and finish the previous release sync before preparing this checkpoint. ${error instanceof Error ? error.message : String(error)}`,
);
}
}
async function validateCandidate(git, sha, version, commits) {
const versions = [
[
"package.json",
(await readJson(git, sha, "package.json", versionSchema)).version,
],
[
".release-please-manifest.json",
(
await readJson(
git,
sha,
".release-please-manifest.json",
manifestSchema,
)
)["."],
],
];
const lock = await readJson(
git,
sha,
"package-lock.json",
versionSchema.extend({
packages: z.object({ "": versionSchema }),
}),
);
versions.push(
["package-lock.json", lock.version],
["package-lock.json packages.root", lock.packages[""].version],
);
const server = await readJson(
git,
sha,
"server.json",
versionSchema.extend({
packages: z.array(
z.object({ identifier: z.string(), version: z.string() }),
),
}),
);
const npmPackage = server.packages.find(
(entry) => entry.identifier === "rea-agents",
);
if (npmPackage === undefined)
throw new Error("server.json is missing the rea-agents package.");
versions.push(
["server.json", server.version],
["server.json rea-agents package", npmPackage.version],
);
const catalog = await readJson(
git,
sha,
"docs/product-catalog.json",
z.object({ package: versionSchema }),
);
versions.push(["docs/product-catalog.json", catalog.package.version]);
const generated = await git([
"show",
`${sha}:src/generatedPackageMetadata.ts`,
]);
const generatedVersions = Array.from(
generated.matchAll(/^\s+version: "([^"]+)"/gmu),
);
if (generatedVersions.length !== 1)
throw new Error(
"Generated package metadata must declare one literal version.",
);
versions.push(["src/generatedPackageMetadata.ts", generatedVersions[0]?.[1]]);
for (const [location, actual] of versions) {
if (actual !== version) {
throw new Error(
`${location}@${sha} declares ${actual}; release branch requires ${version}.`,
);
}
}
const notes = releaseNotes(
await git(["show", `${sha}:CHANGELOG.md`]),
version,
);
const breakingNotes = breakingSection(notes);
const missingBreaking = commits.filter(
(commit) => commit.breaking && !referencesCommit(breakingNotes, commit),
);
if (missingBreaking.length > 0) {
throw new Error(
`Release ${version} migration notes omit unreleased breaking changes: ${missingBreaking.map((commit) => `${commit.sha} ${commit.subject}`).join("; ")}`,
);
}
return commits.filter((commit) => !referencesCommit(notes, commit));
}
/** Validate a recorded checkpoint and optional candidate using exact Git ancestry. */
export async function inspectReleaseCheckpoint(git, options) {
const { phase, stage, releaseBranch, sourceSha, candidateSha } = z
.object({
phase: z.enum(["prepare", "publish"]),
stage: z.enum(["source", "candidate"]),
releaseBranch: z.string().startsWith("release/"),
sourceSha: shaSchema,
candidateSha: shaSchema.optional(),
})
.parse(options);
const expectedVersion = releaseVersion(
releaseBranch.slice("release/".length),
"release/VERSION",
);
if (phase === "publish" && stage === "candidate") {
throw new Error(
"Publish validates its merged source SHA; candidate stage applies only to prepare.",
);
}
const applicationSha =
phase === "publish"
? shaSchema.parse((await git(["rev-parse", `${sourceSha}^1`])).trim())
: sourceSha;
const manifest = await readJson(
git,
applicationSha,
".release-please-manifest.json",
manifestSchema,
);
const baselineVersion = releaseVersion(
manifest["."],
"published baseline manifest",
);
const sourcePackage = await readJson(
git,
applicationSha,
"package.json",
versionSchema,
);
if (sourcePackage.version !== baselineVersion) {
throw new Error(
`Source package ${sourcePackage.version} and release baseline ${baselineVersion} disagree.`,
);
}
if (!gt(expectedVersion, baselineVersion)) {
throw new Error(
`Expected release ${expectedVersion} must advance published baseline ${baselineVersion}.`,
);
}
const baselineTag = `refs/tags/rea-agents-${baselineVersion}`;
const baselineSha = await resolveBaseline(git, baselineTag);
await requireAncestor(
git,
baselineSha,
applicationSha,
"Published baseline is outside the checkpoint ancestry",
);
const configuration = await readJson(
git,
applicationSha,
"release-please-config.json",
sectionsSchema,
);
const visibleTypes = new Set(
configuration["changelog-sections"]
.filter((section) => !section.hidden)
.map((section) => section.type),
);
const commits = readCommits(
await git([
"log",
"--first-parent",
"-z",
"--format=%H%x00%s%x00%B",
`${baselineSha}..${applicationSha}`,
]),
).filter((commit) => commit.breaking || visibleTypes.has(commit.type));
const breaking = commits.filter((commit) => commit.breaking);
if (
breaking.length > 0 &&
major(baselineVersion) > 0 &&
major(expectedVersion) <= major(baselineVersion)
) {
throw new Error(
`Expected release ${expectedVersion} contains unreleased breaking markers since ${baselineVersion}: ${breaking.map((commit) => `${commit.sha} ${commit.subject}`).join("; ")}. Review compatibility or select a major version.`,
);
}
let snapshot = phase === "publish" ? sourceSha : candidateSha;
if (stage === "candidate" && snapshot === undefined) {
snapshot = shaSchema.parse((await git(["rev-parse", "HEAD"])).trim());
}
if (phase === "publish" || stage === "candidate") {
await requireAncestor(
git,
applicationSha,
snapshot,
"Candidate is outside the selected checkpoint ancestry",
);
}
const missingNotes =
phase === "publish" || stage === "candidate"
? await validateCandidate(git, snapshot, expectedVersion, commits)
: [];
return {
expectedVersion,
baselineVersion,
baselineSha,
sourceSha,
applicationSha,
commits,
missingNotes,
};
}
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env node
import { execFile } from "node:child_process";
import { parseArgs, promisify } from "node:util";
import { inspectReleaseCheckpoint } from "./lib/release-checkpoint.mjs";
const execFileAsync = promisify(execFile);
async function git(args) {
const result = await execFileAsync("git", args, {
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
});
return result.stdout;
}
async function main() {
const { values } = parseArgs({
options: {
phase: { type: "string" },
stage: { type: "string", default: "source" },
"release-branch": { type: "string" },
"source-sha": { type: "string" },
},
});
const report = await inspectReleaseCheckpoint(git, {
phase: values.phase,
stage: values.stage,
releaseBranch: values["release-branch"],
sourceSha: values["source-sha"],
});
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
if (report.missingNotes.length > 0) {
process.stderr.write(
`Review ${report.missingNotes.length} additional unreleased mainline entries missing from these release notes; see the ancestry report above.\n`,
);
}
}
await main().catch((error) => {
process.stderr.write(
`Release checkpoint validation failed: ${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
});
@@ -0,0 +1,471 @@
import { execFile } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { expect, it } from "vitest";
import { z } from "zod";
import { parse } from "yaml";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const execFileAsync = promisify(execFile);
const verifier = fileURLToPath(
new URL("../../../scripts/verify-release-checkpoint.mjs", import.meta.url),
);
async function git(
directory: string,
args: string[],
date = "2026-01-04T00:00:00Z",
) {
const result = await execFileAsync(
"git",
[
"-c",
"user.name=REA fixture",
"-c",
"[email protected]",
"-c",
"commit.gpgsign=false",
"-c",
`core.hooksPath=${join(directory, "no-hooks")}`,
...args,
],
{
cwd: directory,
env: { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date },
},
);
return result.stdout.trim();
}
async function metadata(directory: string, version: string, notes: string) {
await mkdir(join(directory, "docs"), { recursive: true });
await mkdir(join(directory, "src"), { recursive: true });
const files = {
"package.json": { name: "rea-agents", version },
"package-lock.json": { version, packages: { "": { version } } },
".release-please-manifest.json": { ".": version },
"server.json": {
version,
packages: [{ identifier: "rea-agents", version }],
},
"docs/product-catalog.json": { package: { version } },
"release-please-config.json": {
"changelog-sections": [
{ type: "feat", hidden: false },
{ type: "fix", hidden: false },
{ type: "chore", hidden: true },
],
},
};
for (const [name, value] of Object.entries(files)) {
await writeFile(join(directory, name), JSON.stringify(value));
}
await writeFile(
join(directory, "src/generatedPackageMetadata.ts"),
`export const METADATA = {\n version: "${version}",\n};\n`,
);
await writeFile(
join(directory, "CHANGELOG.md"),
`# Changelog\n\n## [${version}]\n\n${notes}\n`,
);
}
async function fixture(
subject = "fix(contracts)!: require absolute paths (#948)",
footer = "",
) {
const directory = await createTestTempDirectory("rea-release-ancestry-");
await git(directory, ["init", "--initial-branch=main"]);
await metadata(directory, "4.0.0", "Initial release");
await git(directory, ["add", "."]);
await git(directory, ["commit", "-m", "chore: seed"], "2026-01-01T00:00:00Z");
await git(directory, ["switch", "-c", "release/5.0.0"]);
await metadata(directory, "5.0.0", "Previous release");
await git(directory, ["add", "."]);
await git(
directory,
["commit", "-m", "chore: release 5.0.0"],
"2026-01-03T00:00:00Z",
);
const releasedSha = await git(directory, ["rev-parse", "HEAD"]);
await git(directory, ["tag", "rea-agents-5.0.0"]);
await git(directory, ["switch", "main"]);
await writeFile(
join(directory, "behavior.txt"),
"Unreleased mainline behavior\n",
);
await git(directory, ["add", "."]);
await git(
directory,
["commit", "-m", subject, "-m", footer],
"2026-01-02T00:00:00Z",
);
const changedSha = await git(directory, ["rev-parse", "HEAD"]);
await git(directory, [
"merge",
"--no-ff",
"release/5.0.0",
"-m",
"chore: sync published baseline",
]);
const sourceSha = await git(directory, ["rev-parse", "HEAD"]);
return { directory, sourceSha, changedSha, releasedSha };
}
type Fixture = Awaited<ReturnType<typeof fixture>>;
async function candidate(
f: Fixture,
version: string,
notes = "### ⚠ BREAKING CHANGES\n\n* MCP callers must use absolute paths; CLI paths remain compatible. See #948.",
) {
await metadata(f.directory, version, notes);
await git(f.directory, ["add", "."]);
await git(
f.directory,
["commit", "-m", `chore: release ${version}`],
"2026-01-05T00:00:00Z",
);
return git(f.directory, ["rev-parse", "HEAD"]);
}
function verify(
f: Pick<Fixture, "directory" | "sourceSha">,
version = "6.0.0",
phase = "prepare",
stage = "candidate",
) {
return execFileAsync(
process.execPath,
[
verifier,
"--phase",
phase,
"--stage",
stage,
"--release-branch",
`release/${version}`,
"--source-sha",
f.sourceSha,
],
{ cwd: f.directory },
);
}
const reportSchema = z.object({
expectedVersion: z.string(),
baselineVersion: z.string(),
applicationSha: z.string(),
commits: z.array(
z.object({ sha: z.string(), subject: z.string(), breaking: z.boolean() }),
),
missingNotes: z.array(
z.object({
sha: z.string(),
subject: z.string(),
type: z.string().optional(),
breaking: z.boolean(),
}),
),
});
it("retains an unreleased breaking commit hidden by the side-branch chronological cutoff", async () => {
const f = await fixture();
const walk = (
await git(f.directory, ["log", "--format=%H", f.sourceSha])
).split("\n");
expect(walk.slice(0, walk.indexOf(f.releasedSha))).not.toContain(
f.changedSha,
);
await candidate(f, "6.0.0");
const result = await verify(f);
const report = reportSchema.parse(JSON.parse(result.stdout));
expect(report).toMatchObject({
expectedVersion: "6.0.0",
baselineVersion: "5.0.0",
missingNotes: [],
});
expect(report.commits).toContainEqual({
sha: f.changedSha,
subject: "fix(contracts)!: require absolute paths (#948)",
breaking: true,
});
});
it("rejects a wrong bot version before publication", async () => {
const f = await fixture();
await candidate(f, "5.1.0");
await expect(verify(f)).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining(
"declares 5.1.0; release branch requires 6.0.0",
),
});
});
it("rejects a requested minor that omits the breaking ancestry", async () => {
const f = await fixture();
await expect(verify(f, "5.1.0", "prepare", "source")).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("contains unreleased breaking markers"),
});
});
it("cannot satisfy migration references with notes retained from an older release", async () => {
const f = await fixture();
await candidate(
f,
"6.0.0",
"### Features\n\nNew tool\n\n## [5.0.0]\n\n### ⚠ BREAKING CHANGES\n\nSee #948.",
);
await expect(verify(f)).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining(
"migration notes omit unreleased breaking changes",
),
});
});
it("recognizes a BREAKING CHANGE footer without a bang in the subject", async () => {
const f = await fixture(
"fix(contracts): change path inputs (#948)",
"BREAKING CHANGE: relative MCP paths are rejected",
);
await expect(verify(f, "5.1.0", "prepare", "source")).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("contains unreleased breaking markers"),
});
});
it.each([
{ subject: "fix: correct supported output", version: "5.0.1" },
{ subject: "feat: add an optional inspection", version: "5.1.0" },
])(
"accepts compatible $version without a major increment",
async ({ subject, version }) => {
const f = await fixture(subject);
await candidate(f, version, `### Changes\n\n${f.changedSha} ${subject}`);
const report = reportSchema.parse(
JSON.parse((await verify(f, version)).stdout),
);
expect(report.expectedVersion).toBe(version);
expect(report.commits.every((entry) => !entry.breaking)).toBe(true);
},
);
it("accepts an exact prerelease version", async () => {
const f = await fixture();
await candidate(f, "6.0.0-rc.1");
await expect(verify(f, "6.0.0-rc.1")).resolves.toMatchObject({ stderr: "" });
});
it.each(["6.01.0", "6.0", "not-a-version", "6.0.0+build"])(
"rejects invalid release version %s",
async (version) => {
const directory = await createTestTempDirectory(
"rea-release-invalid-version-",
);
await expect(
verify(
{ directory, sourceSha: "1".repeat(40) },
version,
"prepare",
"source",
),
).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("must contain an exact SemVer"),
});
},
);
it("validates candidate options before inspecting the working directory", async () => {
const directory = await createTestTempDirectory(
"rea-release-invalid-candidate-",
);
await git(directory, ["init", "--initial-branch=main"]);
await expect(
verify({ directory, sourceSha: "1".repeat(40) }, "not-a-version"),
).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("must contain an exact SemVer"),
});
});
it.each([
"package-lock.json",
"server.json",
"docs/product-catalog.json",
"src/generatedPackageMetadata.ts",
])("rejects inconsistent version metadata in %s", async (path) => {
const f = await fixture();
await candidate(f, "6.0.0");
await writeFile(
join(f.directory, path),
path.endsWith(".ts")
? 'export const METADATA = {\n version: "5.1.0",\n};\n'
: JSON.stringify(
path === "package-lock.json"
? { version: "6.0.0", packages: { "": { version: "5.1.0" } } }
: path === "server.json"
? {
version: "6.0.0",
packages: [{ identifier: "rea-agents", version: "5.1.0" }],
}
: { package: { version: "5.1.0" } },
),
);
await git(f.directory, ["add", "."]);
await git(f.directory, ["commit", "-m", "chore: inconsistent metadata"]);
await expect(verify(f)).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining(
"declares 5.1.0; release branch requires 6.0.0",
),
});
});
it("checks a publish merge using its pre-release first parent baseline", async () => {
const f = await fixture();
const sourceSha = await candidate(f, "6.0.0");
const report = reportSchema.parse(
JSON.parse(
(await verify({ ...f, sourceSha }, "6.0.0", "publish", "source")).stdout,
),
);
expect(report.applicationSha).toBe(f.sourceSha);
expect(report.baselineVersion).toBe("5.0.0");
});
it("reports other omitted entries for review without inventing a breaking change", async () => {
const f = await fixture("fix: improve diagnostics");
await candidate(f, "5.0.1", "### Bug Fixes\n\nUpdated diagnostics");
const result = await verify(f, "5.0.1");
expect(reportSchema.parse(JSON.parse(result.stdout)).missingNotes).toEqual([
{
sha: f.changedSha,
subject: "fix: improve diagnostics",
type: "fix",
breaking: false,
},
]);
expect(result.stderr).toContain("additional unreleased mainline entries");
});
it("reports a missing published tag with recovery advice", async () => {
const f = await fixture();
await git(f.directory, ["tag", "-d", "rea-agents-5.0.0"]);
await expect(verify(f, "6.0.0", "prepare", "source")).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("Fetch release tags"),
});
});
it("rejects a published baseline outside the selected ancestry", async () => {
const f = await fixture();
const next = await candidate(f, "6.0.0");
await git(f.directory, ["tag", "-f", "rea-agents-5.0.0", next]);
await expect(verify(f, "6.0.0", "prepare", "source")).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining(
"Published baseline is outside the checkpoint ancestry",
),
});
});
it("reports malformed candidate JSON with the exact metadata path", async () => {
const f = await fixture();
await candidate(f, "6.0.0");
await writeFile(join(f.directory, "server.json"), "{");
await git(f.directory, ["add", "."]);
await git(f.directory, ["commit", "-m", "chore: invalid metadata"]);
await expect(verify(f)).rejects.toMatchObject({
code: 1,
stderr: expect.stringMatching(/server\.json@.*contains invalid JSON/u),
});
});
it("rejects a candidate from a different source even when versions and notes match", async () => {
const f = await fixture();
await git(f.directory, ["switch", "release/5.0.0"]);
await candidate(f, "6.0.0");
await expect(verify(f)).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining(
"Candidate is outside the selected checkpoint ancestry",
),
});
});
it.skipIf(process.platform === "win32").each([true, false])(
"the actual publish preflight admits the next action only for valid metadata (%s)",
async (valid) => {
const f = await fixture();
const sourceSha = await candidate(f, valid ? "6.0.0" : "5.1.0");
const workflow = z
.object({
jobs: z.object({
"release-please": z.object({
steps: z.array(
z.object({
name: z.string().optional(),
run: z.string().optional(),
}),
),
}),
}),
})
.parse(
parse(
await readFile(
new URL("../../../.github/workflows/release.yml", import.meta.url),
"utf8",
),
),
);
const command = z
.string()
.parse(
workflow.jobs["release-please"].steps.find(
(step) =>
step.name ===
"Validate checkpoint version and ancestry before release creation",
)?.run,
);
const marker = join(f.directory, "next-release-action");
const execution = execFileAsync(
"bash",
[
"-e",
"-o",
"pipefail",
"-c",
`${command}\ntouch "$NEXT_RELEASE_ACTION_MARKER"`,
],
{
cwd: fileURLToPath(new URL("../../../", import.meta.url)),
env: {
...process.env,
GIT_DIR: join(f.directory, ".git"),
GIT_WORK_TREE: f.directory,
RELEASE_PHASE: "publish",
RELEASE_BRANCH: "release/6.0.0",
SOURCE_SHA: sourceSha,
NEXT_RELEASE_ACTION_MARKER: marker,
},
},
);
if (valid) {
await expect(execution).resolves.toMatchObject({ stderr: "" });
expect(await readFile(marker, "utf8")).toBe("");
} else {
await expect(execution).rejects.toMatchObject({
code: 1,
stderr: expect.stringContaining("release branch requires 6.0.0"),
});
await expect(readFile(marker)).rejects.toMatchObject({ code: "ENOENT" });
}
},
);
@@ -43,6 +43,43 @@ async function readReleaseWorkflow() {
);
}
it("validates the source before release creation and the prepared candidate before generation", async () => {
const steps = (await readReleaseWorkflow()).jobs["release-please"].steps;
const source = steps.findIndex(
(step) =>
step.name ===
"Validate checkpoint version and ancestry before release creation",
);
const action = steps.findIndex((step) =>
step.uses?.startsWith("googleapis/release-please-action@"),
);
const candidate = steps.findIndex(
(step) =>
step.name === "Validate prepared release version and migration notes",
);
const generation = steps.findIndex(
(step) => step.name === "Regenerate release documentation",
);
expect(source).toBeGreaterThanOrEqual(0);
expect(source).toBeLessThan(action);
expect(steps[source]?.if).toBeUndefined();
expect(steps[source]?.run).toContain("--source-sha");
expect(candidate).toBeGreaterThan(action);
expect(candidate).toBeLessThan(generation);
expect(steps[candidate]?.if).toBe(
"inputs.phase == 'prepare' && steps.release.outputs.prs_created == 'true'",
);
expect(steps[candidate]?.run).toContain("--stage candidate");
for (const name of [
"Check out release controller and Git history",
"Check out release pull request",
]) {
expect(
steps.find((step) => step.name === name)?.with?.["fetch-depth"],
).toBe(0);
}
});
it("requires explicit release preparation or publication instead of main pushes", async () => {
const workflow = await readReleaseWorkflow();
expect(Object.keys(workflow.on)).toEqual(["workflow_dispatch"]);
@@ -90,6 +127,13 @@ it.skipIf(process.platform === "win32").each([
expect(actionIndex).toBeGreaterThanOrEqual(0);
const commands = steps
.slice(0, actionIndex)
.filter((step) =>
[
"Validate release selection",
"Resolve release branch tip",
"Require the publish dispatch to match the branch tip",
].includes(step.name ?? ""),
)
.filter(
(step) =>
step.if === undefined ||