mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user