diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 289bca2c4..b53258601 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -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: + - Real Hopper/Ghidra, browser, or OS coverage: - Package or release metadata impact: - Security, privacy, process, or containment review: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b1f0f0033..04e385404 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/docs/releasing.md b/docs/releasing.md index b42189f45..922208caf 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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 diff --git a/scripts/lib/release-checkpoint.mjs b/scripts/lib/release-checkpoint.mjs new file mode 100644 index 000000000..af42178c3 --- /dev/null +++ b/scripts/lib/release-checkpoint.mjs @@ -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, + }; +} diff --git a/scripts/verify-release-checkpoint.mjs b/scripts/verify-release-checkpoint.mjs new file mode 100644 index 000000000..e8ce6329e --- /dev/null +++ b/scripts/verify-release-checkpoint.mjs @@ -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; +}); diff --git a/tests/boundary/filesystem/releaseCheckpoint.test.ts b/tests/boundary/filesystem/releaseCheckpoint.test.ts new file mode 100644 index 000000000..107b30b00 --- /dev/null +++ b/tests/boundary/filesystem/releaseCheckpoint.test.ts @@ -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", + "user.email=fixture@example.invalid", + "-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>; + +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, + 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" }); + } + }, +); diff --git a/tests/boundary/filesystem/releaseWorkflow.test.ts b/tests/boundary/filesystem/releaseWorkflow.test.ts index dcacdde57..7385c3e4f 100644 --- a/tests/boundary/filesystem/releaseWorkflow.test.ts +++ b/tests/boundary/filesystem/releaseWorkflow.test.ts @@ -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 ||