diff --git a/.atomic/workflows/lib/publish-release.ts b/.atomic/workflows/lib/publish-release.ts new file mode 100644 index 000000000..a58dd71c4 --- /dev/null +++ b/.atomic/workflows/lib/publish-release.ts @@ -0,0 +1,526 @@ +import { execFileSync } from "node:child_process"; +import { createGitEnvironment } from "@bastani/atomic"; + +export type ReleaseKind = "release" | "prerelease"; +export type ReleaseStatus = "completed" | "blocked" | "failed"; + +export type ValidatedRelease = { + readonly kind: ReleaseKind; + readonly version: string; + readonly branch: string; +}; + +export type PublishReleaseOutput = { + readonly status: ReleaseStatus; + readonly target_version: string; + readonly release_kind: ReleaseKind; + readonly branch: string; + readonly pr_url?: string; + readonly tag?: string; + readonly summary: string; +}; + +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue }; + +export type CommandResult = { + readonly command: string; + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +}; + +export type PullRequestReferenceVerification = + | { + readonly ok: true; + readonly summary: string; + readonly prUrl: string; + readonly prNumber: number; + readonly headRefOid?: string; + readonly state?: string; + } + | { + readonly ok: false; + readonly summary: string; + readonly prUrl?: string; + readonly prNumber?: number; + }; + +export type PullRequestMergeVerification = + | { + readonly ok: true; + readonly summary: string; + readonly mergeCommitOid: string; + readonly prUrl?: string; + } + | { + readonly ok: false; + readonly summary: string; + readonly prUrl?: string; + }; + +export type PullRequestChecksVerification = + | { + readonly ok: true; + readonly summary: string; + readonly checkCount: number; + } + | { + readonly ok: false; + readonly summary: string; + }; + +export type PublishWorkflowRunVerification = + | { + readonly ok: true; + readonly summary: string; + readonly runId: number; + readonly runUrl?: string; + readonly status: string; + readonly conclusion: string; + readonly headSha?: string; + } + | { + readonly ok: false; + readonly summary: string; + readonly runId?: number; + readonly runUrl?: string; + }; + +export type PublishWorkflowRunReference = + | { + readonly ok: true; + readonly summary: string; + readonly runId: number; + readonly runUrl?: string; + readonly status: string; + readonly conclusion?: string; + readonly headSha?: string; + } + | { + readonly ok: false; + readonly summary: string; + }; + +export const releaseVersionPattern = /^\d+\.\d+\.\d+$/; +export const prereleaseVersionPattern = /^\d+\.\d+\.\d+-alpha\.[1-9]\d*$/; + +export function validateReleaseRequest(kind: ReleaseKind, version: string): ValidatedRelease { + if (version.startsWith("v")) { + throw new Error(`target_version must not include a leading "v"; received ${version}`); + } + + const matches = kind === "release" ? releaseVersionPattern.test(version) : prereleaseVersionPattern.test(version); + + if (!matches) { + const expected = kind === "release" ? "MAJOR.MINOR.PATCH" : "MAJOR.MINOR.PATCH-alpha.REVISION"; + throw new Error(`target_version ${JSON.stringify(version)} is not valid for ${kind}; expected ${expected}`); + } + + return { + kind, + version, + branch: `${kind}/${version}`, + }; +} + +// Sanitize repository-local Git environment variables so release subprocesses +// always target this checkout rather than an inherited hook/worktree context. +export function runCommand(args: readonly string[]): CommandResult { + const [command, ...commandArgs] = args; + if (command === undefined) { + return { + command: "", + exitCode: 1, + stdout: "", + stderr: "Cannot run an empty command.", + }; + } + + try { + const stdout = execFileSync(command, commandArgs, { + encoding: "utf8", + env: createGitEnvironment(), + maxBuffer: 1024 * 1024 * 20, + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + + return { + command: args.join(" "), + exitCode: 0, + stdout, + stderr: "", + }; + } catch (error) { + const failure = error as { + readonly status?: number; + readonly stdout?: Buffer | string; + readonly stderr?: Buffer | string; + readonly message?: string; + }; + const stdout = String(failure.stdout ?? "").trim(); + const stderr = String(failure.stderr ?? failure.message ?? "").trim(); + + return { + command: args.join(" "), + exitCode: failure.status ?? 1, + stdout, + stderr, + }; + } +} + +export function commandSummary(result: CommandResult): string { + return [ + `$ ${result.command}`, + `exitCode: ${result.exitCode}`, + result.stdout.length === 0 ? undefined : `stdout:\n${result.stdout}`, + result.stderr.length === 0 ? undefined : `stderr:\n${result.stderr}`, + ].filter((line): line is string => line !== undefined).join("\n"); +} + +export function parseJsonCommand( + result: CommandResult, + failurePrefix: string, +): { readonly ok: true; readonly value: JsonValue } | { readonly ok: false; readonly summary: string } { + try { + return { ok: true, value: JSON.parse(result.stdout) as JsonValue }; + } catch { + return { ok: false, summary: [failurePrefix, commandSummary(result)].join("\n\n") }; + } +} + +function isJsonObject(value: JsonValue): value is { readonly [key: string]: JsonValue } { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringField(object: { readonly [key: string]: JsonValue }, key: string): string | undefined { + const value = object[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function positiveIntegerField(object: { readonly [key: string]: JsonValue }, key: string): number | undefined { + const value = object[key]; + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; +} + +function nullableStringField(object: { readonly [key: string]: JsonValue }, key: string): string | undefined { + const value = object[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function verifyReleasePullRequestReferenceJson( + value: JsonValue, + expectedHeadRefName: string, + expectedBaseRefName = "main", + expectedHeadRefOid?: string, + expectedState?: string, +): PullRequestReferenceVerification { + if (!isJsonObject(value)) { + return { ok: false, summary: "GitHub PR reference response was not a JSON object." }; + } + + const baseRefName = stringField(value, "baseRefName"); + const headRefName = stringField(value, "headRefName"); + const headRefOid = stringField(value, "headRefOid"); + const prUrl = stringField(value, "url"); + const prNumber = positiveIntegerField(value, "number"); + const state = stringField(value, "state"); + const failures: string[] = []; + + if (prUrl === undefined) failures.push("url was missing"); + if (prNumber === undefined) failures.push("number was missing or invalid"); + if (baseRefName !== expectedBaseRefName) { + failures.push(`baseRefName was ${baseRefName ?? "missing"}, expected ${expectedBaseRefName}`); + } + if (headRefName !== expectedHeadRefName) { + failures.push(`headRefName was ${headRefName ?? "missing"}, expected ${expectedHeadRefName}`); + } + if (expectedHeadRefOid !== undefined && headRefOid !== expectedHeadRefOid) { + failures.push(`headRefOid was ${headRefOid ?? "missing"}, expected ${expectedHeadRefOid}`); + } + if (expectedState !== undefined && state !== expectedState) { + failures.push(`state was ${state ?? "missing"}, expected ${expectedState}`); + } + + if (failures.length > 0 || prUrl === undefined || prNumber === undefined) { + return { + ok: false, + summary: ["GitHub PR reference is not verified.", ...failures.map((failure) => `- ${failure}`)].join("\n"), + prUrl, + prNumber, + }; + } + + return { + ok: true, + summary: [ + "GitHub PR reference is verified.", + `number: ${prNumber}`, + `url: ${prUrl}`, + `baseRefName: ${baseRefName}`, + `headRefName: ${headRefName}`, + headRefOid === undefined ? undefined : `headRefOid: ${headRefOid}`, + state === undefined ? undefined : `state: ${state}`, + ].filter((line): line is string => line !== undefined).join("\n"), + prUrl, + prNumber, + headRefOid, + state, + }; +} + +export function verifyPullRequestMergedJson( + value: JsonValue, + expectedHeadRefName: string, + expectedBaseRefName = "main", + expectedHeadRefOid?: string, +): PullRequestMergeVerification { + if (!isJsonObject(value)) { + return { ok: false, summary: "GitHub PR response was not a JSON object." }; + } + + const state = stringField(value, "state"); + const mergedAt = stringField(value, "mergedAt"); + const baseRefName = stringField(value, "baseRefName"); + const headRefName = stringField(value, "headRefName"); + const headRefOid = stringField(value, "headRefOid"); + const prUrl = stringField(value, "url"); + const mergeCommit = value.mergeCommit; + const mergeCommitOid = isJsonObject(mergeCommit) ? stringField(mergeCommit, "oid") : undefined; + const failures: string[] = []; + + if (state !== "MERGED") failures.push(`state was ${state ?? "missing"}, expected MERGED`); + if (mergedAt === undefined) failures.push("mergedAt was missing"); + if (mergeCommitOid === undefined) failures.push("mergeCommit.oid was missing"); + if (baseRefName !== expectedBaseRefName) { + failures.push(`baseRefName was ${baseRefName ?? "missing"}, expected ${expectedBaseRefName}`); + } + if (headRefName !== expectedHeadRefName) { + failures.push(`headRefName was ${headRefName ?? "missing"}, expected ${expectedHeadRefName}`); + } + if (expectedHeadRefOid !== undefined && headRefOid !== expectedHeadRefOid) { + failures.push(`headRefOid was ${headRefOid ?? "missing"}, expected ${expectedHeadRefOid}`); + } + + if (failures.length > 0 || mergeCommitOid === undefined) { + return { + ok: false, + summary: ["GitHub PR is not verified as merged.", ...failures.map((failure) => `- ${failure}`)].join("\n"), + prUrl, + }; + } + + return { + ok: true, + summary: [ + "GitHub PR is verified as merged.", + `state: ${state}`, + `mergedAt: ${mergedAt}`, + `mergeCommit.oid: ${mergeCommitOid}`, + `baseRefName: ${baseRefName}`, + `headRefName: ${headRefName}`, + headRefOid === undefined ? undefined : `headRefOid: ${headRefOid}`, + prUrl === undefined ? undefined : `url: ${prUrl}`, + ].filter((line): line is string => line !== undefined).join("\n"), + mergeCommitOid, + prUrl, + }; +} + +function checkName(value: JsonValue, index: number): string { + if (!isJsonObject(value)) return `check[${index}]`; + return stringField(value, "name") ?? stringField(value, "workflow") ?? `check[${index}]`; +} + +function checkPassed(value: { readonly [key: string]: JsonValue }): boolean { + const bucket = stringField(value, "bucket")?.toLowerCase(); + if (bucket !== undefined) return bucket === "pass"; + + const state = stringField(value, "state")?.toUpperCase(); + return state === "SUCCESS" || state === "PASSING" || state === "PASSED"; +} + +export function verifyPullRequestChecksJson(value: JsonValue): PullRequestChecksVerification { + if (!Array.isArray(value)) { + return { ok: false, summary: "GitHub PR checks response was not a JSON array." }; + } + + if (value.length === 0) { + return { ok: false, summary: "GitHub PR checks response contained no required checks." }; + } + + const failures: string[] = []; + for (const [index, check] of value.entries()) { + if (!isJsonObject(check)) { + failures.push(`check[${index}] was not a JSON object`); + continue; + } + + if (!checkPassed(check)) { + const name = checkName(check, index); + const bucket = stringField(check, "bucket") ?? "missing"; + const state = stringField(check, "state") ?? "missing"; + const link = stringField(check, "link"); + failures.push(`${name} bucket=${bucket} state=${state}${link === undefined ? "" : ` link=${link}`}`); + } + } + + if (failures.length > 0) { + return { + ok: false, + summary: [ + "GitHub PR required checks are not verified as passing.", + ...failures.map((failure) => `- ${failure}`), + ].join("\n"), + }; + } + + return { + ok: true, + summary: [ + "GitHub PR required checks are verified as passing.", + `checkCount: ${value.length}`, + ].join("\n"), + checkCount: value.length, + }; +} + +export function selectPublishWorkflowRunJson( + value: JsonValue, + expectedHeadBranch: string, +): PublishWorkflowRunReference { + if (!Array.isArray(value)) { + return { ok: false, summary: "GitHub Actions run list response was not a JSON array." }; + } + + const mismatches: string[] = []; + + for (const [index, candidate] of value.entries()) { + if (!isJsonObject(candidate)) { + mismatches.push(`run[${index}] was not a JSON object`); + continue; + } + + const headBranch = stringField(candidate, "headBranch"); + const event = stringField(candidate, "event"); + const runId = positiveIntegerField(candidate, "databaseId"); + const status = stringField(candidate, "status"); + const conclusion = nullableStringField(candidate, "conclusion"); + const runUrl = stringField(candidate, "url"); + const headSha = stringField(candidate, "headSha"); + + if (headBranch !== expectedHeadBranch || event !== "push") { + mismatches.push( + `run[${index}] headBranch=${headBranch ?? "missing"} event=${event ?? "missing"}`, + ); + continue; + } + + const failures: string[] = []; + if (runId === undefined) failures.push("databaseId was missing or invalid"); + if (status === undefined) failures.push("status was missing"); + + if (failures.length > 0 || runId === undefined || status === undefined) { + return { + ok: false, + summary: [ + "GitHub Actions publish run is not selectable.", + ...failures.map((failure) => `- ${failure}`), + ].join("\n"), + }; + } + + return { + ok: true, + summary: [ + "GitHub Actions publish run is selected.", + `databaseId: ${runId}`, + `headBranch: ${headBranch}`, + `event: ${event}`, + `status: ${status}`, + conclusion === undefined ? undefined : `conclusion: ${conclusion}`, + headSha === undefined ? undefined : `headSha: ${headSha}`, + runUrl === undefined ? undefined : `url: ${runUrl}`, + ].filter((line): line is string => line !== undefined).join("\n"), + runId, + runUrl, + status, + conclusion, + headSha, + }; + } + + return { + ok: false, + summary: [ + "GitHub Actions publish run was not found for the release tag.", + `expected headBranch: ${expectedHeadBranch}`, + `examined runs: ${value.length}`, + ...mismatches.slice(0, 10).map((mismatch) => `- ${mismatch}`), + ].join("\n"), + }; +} + +export function verifyPublishWorkflowRunJson( + value: JsonValue, + expectedHeadBranch: string, + expectedHeadSha?: string, +): PublishWorkflowRunVerification { + if (!isJsonObject(value)) { + return { ok: false, summary: "GitHub Actions run response was not a JSON object." }; + } + + const headBranch = stringField(value, "headBranch"); + const event = stringField(value, "event"); + const runId = positiveIntegerField(value, "databaseId"); + const status = stringField(value, "status"); + const conclusion = nullableStringField(value, "conclusion"); + const runUrl = stringField(value, "url"); + const workflowName = stringField(value, "workflowName"); + const headSha = stringField(value, "headSha"); + const failures: string[] = []; + + if (runId === undefined) failures.push("databaseId was missing or invalid"); + if (headBranch !== expectedHeadBranch) { + failures.push(`headBranch was ${headBranch ?? "missing"}, expected ${expectedHeadBranch}`); + } + if (event !== "push") failures.push(`event was ${event ?? "missing"}, expected push`); + if (status !== "completed") failures.push(`status was ${status ?? "missing"}, expected completed`); + if (conclusion !== "success") failures.push(`conclusion was ${conclusion ?? "missing"}, expected success`); + if (expectedHeadSha !== undefined && headSha !== expectedHeadSha) { + failures.push(`headSha was ${headSha ?? "missing"}, expected ${expectedHeadSha}`); + } + + if (failures.length > 0 || runId === undefined || status === undefined || conclusion === undefined) { + return { + ok: false, + summary: [ + "GitHub Actions publish run is not verified as successful.", + ...failures.map((failure) => `- ${failure}`), + ].join("\n"), + runId, + runUrl, + }; + } + + return { + ok: true, + summary: [ + "GitHub Actions publish run is verified as successful.", + `databaseId: ${runId}`, + workflowName === undefined ? undefined : `workflowName: ${workflowName}`, + `headBranch: ${headBranch}`, + `event: ${event}`, + `status: ${status}`, + `conclusion: ${conclusion}`, + headSha === undefined ? undefined : `headSha: ${headSha}`, + runUrl === undefined ? undefined : `url: ${runUrl}`, + ].filter((line): line is string => line !== undefined).join("\n"), + runId, + runUrl, + status, + conclusion, + headSha, + }; +} diff --git a/.atomic/workflows/publish-release.ts b/.atomic/workflows/publish-release.ts new file mode 100644 index 000000000..930527556 --- /dev/null +++ b/.atomic/workflows/publish-release.ts @@ -0,0 +1,926 @@ +import { existsSync, readdirSync } from "node:fs"; +import { defineWorkflow, Type } from "@bastani/workflows"; +import { + commandSummary, + parseJsonCommand, + runCommand, + selectPublishWorkflowRunJson, + validateReleaseRequest, + verifyPublishWorkflowRunJson, + verifyPullRequestChecksJson, + verifyPullRequestMergedJson, + verifyReleasePullRequestReferenceJson, + type CommandResult, + type JsonValue, + type PublishReleaseOutput, + type PublishWorkflowRunVerification, + type PullRequestMergeVerification, + type PullRequestReferenceVerification, + type ReleaseStatus, + type ValidatedRelease, +} from "./lib/publish-release.js"; + +const releaseKindSchema = Type.Union([Type.Literal("release"), Type.Literal("prerelease")]); +const statusSchema = Type.Union([Type.Literal("completed"), Type.Literal("blocked"), Type.Literal("failed")]); + +function excerpt(text: string, limit = 1_200): string { + if (text.length <= limit) return text; + return `${text.slice(0, limit)}\n…[truncated ${text.length - limit} chars]`; +} + +function blockedOutput( + release: ValidatedRelease, + stage: string, + expectedResult: string, + text: string, + status: ReleaseStatus = "blocked", +): PublishReleaseOutput { + return { + status, + target_version: release.version, + release_kind: release.kind, + branch: release.branch, + summary: [ + `publish-release stopped during ${stage} for ${release.kind} ${release.version}.`, + `Expected result: ${expectedResult}`, + "", + "Stage output:", + excerpt(text, 2_000), + ].join("\n"), + }; +} + +type GateVerification = + | { + readonly ok: true; + readonly summary: string; + } + | { + readonly ok: false; + readonly summary: string; + }; + +type PreparationVerification = + | { + readonly ok: true; + readonly summary: string; + readonly releaseCommitOid: string; + } + | { + readonly ok: false; + readonly summary: string; + }; + +type MainReadyVerification = + | { + readonly ok: true; + readonly summary: string; + readonly mainOid: string; + } + | { + readonly ok: false; + readonly summary: string; + }; + +type TagPublicationVerification = + | { + readonly ok: true; + readonly summary: string; + readonly tagTargetOid: string; + } + | { + readonly ok: false; + readonly summary: string; + }; + +type PackageManifest = { + readonly name?: JsonValue; + readonly version?: JsonValue; + readonly private?: JsonValue; +}; + +function isJsonObject(value: JsonValue): value is { readonly [key: string]: JsonValue } { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function readPackageManifest(path: string): Promise { + const value = await Bun.file(path).json() as JsonValue; + if (!isJsonObject(value)) { + throw new Error(`${path} did not contain a JSON object`); + } + return value; +} + +function packageManifestPaths(): readonly string[] { + const paths = existsSync("package.json") ? ["package.json"] : []; + if (!existsSync("packages")) return paths; + + paths.push( + ...readdirSync("packages", { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => `packages/${entry.name}/package.json`) + .filter((path) => existsSync(path)) + .sort(), + ); + + return paths; +} + +function releaseChangedFileAllowed(path: string): boolean { + return path === "package.json" + || path === "bun.lock" + || /^packages\/[^/]+\/(?:package\.json|README\.md|CHANGELOG\.md)$/u.test(path); +} + +async function verifyReleasePreparation( + release: ValidatedRelease, + sourceHeadOid: string, +): Promise { + const branch = runCommand(["git", "branch", "--show-current"]); + const head = runCommand(["git", "rev-parse", "HEAD"]); + const status = runCommand(["git", "status", "--short"]); + const changedFiles = runCommand(["git", "diff", "--name-only", `${sourceHeadOid}..HEAD`]); + const failures: string[] = []; + + if (branch.exitCode !== 0 || branch.stdout !== release.branch) { + failures.push(`current branch was ${branch.stdout || "missing"}, expected ${release.branch}`); + } + if (head.exitCode !== 0 || head.stdout.length === 0) failures.push("release commit HEAD could not be resolved"); + if (status.exitCode !== 0 || status.stdout.length > 0) { + failures.push("worktree is not clean after release preparation"); + } + + const files = changedFiles.stdout.length === 0 ? [] : changedFiles.stdout.split(/\r?\n/u); + const disallowed = files.filter((file) => !releaseChangedFileAllowed(file)); + if (changedFiles.exitCode !== 0) { + failures.push("changed files could not be compared against the recorded source HEAD"); + } + if (disallowed.length > 0) { + failures.push(`release branch changed files outside the release allowlist: ${disallowed.join(", ")}`); + } + + for (const manifestPath of packageManifestPaths()) { + let manifest: PackageManifest; + try { + manifest = await readPackageManifest(manifestPath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + failures.push(message); + continue; + } + + if (typeof manifest.version === "string" && manifest.version !== release.version) { + failures.push(`${manifestPath} version was ${manifest.version}, expected ${release.version}`); + } + + if (manifestPath === "packages/coding-agent/package.json" && manifest.name !== "@bastani/atomic") { + failures.push(`${manifestPath} name was ${String(manifest.name)}, expected @bastani/atomic`); + } + + if (manifestPath !== "packages/coding-agent/package.json" + && manifestPath.startsWith("packages/") + && manifest.private !== true) { + failures.push(`${manifestPath} must remain private because it is bundled into @bastani/atomic`); + } + } + + const summary = [ + failures.length === 0 ? "Release preparation is deterministically verified." : "Release preparation is not verified.", + `sourceHeadOid: ${sourceHeadOid}`, + head.stdout.length === 0 ? undefined : `releaseCommitOid: ${head.stdout}`, + files.length === 0 ? "changedFiles: none" : `changedFiles:\n${files.map((file) => `- ${file}`).join("\n")}`, + failures.length === 0 ? undefined : failures.map((failure) => `- ${failure}`).join("\n"), + commandSummary(branch), + commandSummary(head), + commandSummary(status), + commandSummary(changedFiles), + ].filter((line): line is string => line !== undefined).join("\n\n"); + + if (failures.length > 0 || head.stdout.length === 0) return { ok: false, summary }; + return { ok: true, summary, releaseCommitOid: head.stdout }; +} + +function runLocalReleaseChecks(release: ValidatedRelease): GateVerification { + const branch = runCommand(["git", "branch", "--show-current"]); + const head = runCommand(["git", "rev-parse", "HEAD"]); + const statusBefore = runCommand(["git", "status", "--short"]); + const typecheck = runCommand(["bun", "run", "typecheck"]); + const unitTests = typecheck.exitCode === 0 ? runCommand(["bun", "run", "test:unit"]) : undefined; + const statusAfter = runCommand(["git", "status", "--short"]); + const failures: string[] = []; + + if (branch.exitCode !== 0 || branch.stdout !== release.branch) { + failures.push(`current branch was ${branch.stdout || "missing"}, expected ${release.branch}`); + } + if (head.exitCode !== 0 || head.stdout.length === 0) failures.push("release commit HEAD could not be resolved"); + if (statusBefore.exitCode !== 0 || statusBefore.stdout.length > 0) failures.push("worktree was not clean before local checks"); + if (typecheck.exitCode !== 0) failures.push("bun run typecheck failed"); + if (unitTests === undefined) failures.push("bun run test:unit was skipped because typecheck failed"); + if (unitTests !== undefined && unitTests.exitCode !== 0) failures.push("bun run test:unit failed"); + if (statusAfter.exitCode !== 0 || statusAfter.stdout.length > 0) failures.push("worktree was not clean after local checks"); + + return { + ok: failures.length === 0, + summary: [ + failures.length === 0 ? "Local release checks passed deterministically." : "Local release checks failed.", + failures.length === 0 ? undefined : failures.map((failure) => `- ${failure}`).join("\n"), + commandSummary(branch), + commandSummary(head), + commandSummary(statusBefore), + commandSummary(typecheck), + unitTests === undefined ? undefined : commandSummary(unitTests), + commandSummary(statusAfter), + ].filter((line): line is string => line !== undefined).join("\n\n"), + }; +} + +function captureReleasePrReference( + release: ValidatedRelease, + expectedHeadRefOid: string, +): PullRequestReferenceVerification { + const prView = runCommand([ + "gh", + "pr", + "view", + release.branch, + "--json", + "url,number,state,baseRefName,headRefName,headRefOid", + ]); + + if (prView.exitCode !== 0) { + return { + ok: false, + summary: ["GitHub PR reference capture command failed.", commandSummary(prView)].join("\n\n"), + }; + } + + const parsed = parseJsonCommand(prView, "GitHub PR reference capture returned invalid JSON."); + if (!parsed.ok) return { ok: false, summary: parsed.summary }; + + const referenceVerification = verifyReleasePullRequestReferenceJson( + parsed.value, + release.branch, + "main", + expectedHeadRefOid, + "OPEN", + ); + if (!referenceVerification.ok) { + return { + ok: false, + prUrl: referenceVerification.prUrl, + prNumber: referenceVerification.prNumber, + summary: [referenceVerification.summary, commandSummary(prView)].join("\n\n"), + }; + } + + const remoteBranch = runCommand(["git", "ls-remote", "--heads", "origin", release.branch]); + const remoteHeadOid = remoteBranch.stdout.split(/\s+/u)[0] ?? ""; + if (remoteBranch.exitCode !== 0 || remoteHeadOid !== expectedHeadRefOid) { + return { + ok: false, + prUrl: referenceVerification.prUrl, + prNumber: referenceVerification.prNumber, + summary: [ + "Remote release branch SHA is not verified.", + `expectedHeadRefOid: ${expectedHeadRefOid}`, + `remoteHeadOid: ${remoteHeadOid || "missing"}`, + commandSummary(prView), + commandSummary(remoteBranch), + ].join("\n\n"), + }; + } + + return { + ok: true, + prUrl: referenceVerification.prUrl, + prNumber: referenceVerification.prNumber, + headRefOid: referenceVerification.headRefOid, + state: referenceVerification.state, + summary: [ + referenceVerification.summary, + "Remote release branch SHA matches the verified release commit.", + commandSummary(prView), + commandSummary(remoteBranch), + ].join("\n\n"), + }; +} + +function verifyReleasePrChecksPassed( + release: ValidatedRelease, + prReference: Extract, +): GateVerification { + const prView = runCommand([ + "gh", + "pr", + "view", + prReference.prUrl, + "--json", + "url,number,state,baseRefName,headRefName,headRefOid", + ]); + + if (prView.exitCode !== 0) { + return { ok: false, summary: ["GitHub PR check preflight command failed.", commandSummary(prView)].join("\n\n") }; + } + + const parsedPr = parseJsonCommand(prView, "GitHub PR check preflight returned invalid JSON."); + if (!parsedPr.ok) return { ok: false, summary: parsedPr.summary }; + + const refreshedReference = verifyReleasePullRequestReferenceJson( + parsedPr.value, + release.branch, + "main", + prReference.headRefOid, + "OPEN", + ); + if (!refreshedReference.ok) { + return { ok: false, summary: [refreshedReference.summary, commandSummary(prView)].join("\n\n") }; + } + + const checks = runCommand([ + "gh", + "pr", + "checks", + prReference.prUrl, + "--required", + "--json", + "name,state,bucket,link,workflow,description", + ]); + + if (checks.exitCode !== 0) { + return { ok: false, summary: ["GitHub PR required checks command failed.", commandSummary(checks)].join("\n\n") }; + } + + const parsedChecks = parseJsonCommand(checks, "GitHub PR required checks returned invalid JSON."); + if (!parsedChecks.ok) return { ok: false, summary: parsedChecks.summary }; + + const checkVerification = verifyPullRequestChecksJson(parsedChecks.value); + if (!checkVerification.ok) { + return { ok: false, summary: [checkVerification.summary, commandSummary(prView), commandSummary(checks)].join("\n\n") }; + } + + return { + ok: true, + summary: [checkVerification.summary, refreshedReference.summary, commandSummary(prView), commandSummary(checks)].join("\n\n"), + }; +} + +function verifyReleasePrMerged( + release: ValidatedRelease, + prSelector: string, + expectedHeadRefOid: string | undefined, +): PullRequestMergeVerification { + const prView = runCommand([ + "gh", + "pr", + "view", + prSelector, + "--json", + "state,mergedAt,mergeCommit,baseRefName,headRefName,headRefOid,url", + ]); + + if (prView.exitCode !== 0) { + return { + ok: false, + summary: ["GitHub PR merge verification command failed.", commandSummary(prView)].join("\n\n"), + }; + } + + const parsed = parseJsonCommand(prView, "GitHub PR merge verification returned invalid JSON."); + if (!parsed.ok) return { ok: false, summary: parsed.summary }; + + const mergeVerification = verifyPullRequestMergedJson(parsed.value, release.branch, "main", expectedHeadRefOid); + if (!mergeVerification.ok) { + return { + ok: false, + prUrl: mergeVerification.prUrl, + summary: [mergeVerification.summary, commandSummary(prView)].join("\n\n"), + }; + } + + const branchCheck = runCommand(["git", "ls-remote", "--heads", "origin", release.branch]); + if (branchCheck.exitCode !== 0 || branchCheck.stdout.length === 0) { + return { + ok: false, + prUrl: mergeVerification.prUrl, + summary: [ + "Remote release branch retention verification failed.", + "The PR is merged, but the release branch was not found on origin.", + commandSummary(prView), + commandSummary(branchCheck), + ].join("\n\n"), + }; + } + + return { + ok: true, + mergeCommitOid: mergeVerification.mergeCommitOid, + prUrl: mergeVerification.prUrl, + summary: [ + mergeVerification.summary, + "Remote release branch is retained on origin.", + commandSummary(prView), + commandSummary(branchCheck), + ].join("\n\n"), + }; +} + +function verifyMainReadyForTag(release: ValidatedRelease, mergeCommitOid: string): MainReadyVerification { + const branch = runCommand(["git", "branch", "--show-current"]); + const head = runCommand(["git", "rev-parse", "HEAD"]); + const originMain = runCommand(["git", "rev-parse", "origin/main"]); + const status = runCommand(["git", "status", "--short"]); + const mergeBase = runCommand(["git", "merge-base", "--is-ancestor", mergeCommitOid, "HEAD"]); + const localTag = runCommand(["git", "rev-parse", "--verify", `refs/tags/${release.version}`]); + const remoteTag = runCommand(["git", "ls-remote", "--tags", "origin", `refs/tags/${release.version}`]); + const failures: string[] = []; + + if (branch.exitCode !== 0 || branch.stdout !== "main") failures.push(`current branch was ${branch.stdout || "missing"}, expected main`); + if (head.exitCode !== 0 || head.stdout.length === 0) failures.push("local main HEAD could not be resolved"); + if (originMain.exitCode !== 0 || originMain.stdout.length === 0) failures.push("origin/main could not be resolved"); + if (head.stdout.length > 0 && originMain.stdout.length > 0 && head.stdout !== originMain.stdout) { + failures.push(`local main HEAD ${head.stdout} did not match origin/main ${originMain.stdout}`); + } + if (status.exitCode !== 0 || status.stdout.length > 0) failures.push("worktree is not clean before tagging"); + if (mergeBase.exitCode !== 0) failures.push(`merge commit ${mergeCommitOid} is not an ancestor of local main HEAD`); + if (localTag.exitCode === 0) failures.push(`local tag ${release.version} already exists`); + if (remoteTag.exitCode !== 0) failures.push(`remote tag lookup for ${release.version} failed`); + if (remoteTag.stdout.length > 0) failures.push(`remote tag ${release.version} already exists`); + + const summary = [ + failures.length === 0 ? "Main is ready for release tagging." : "Main is not ready for release tagging.", + failures.length === 0 ? undefined : failures.map((failure) => `- ${failure}`).join("\n"), + commandSummary(branch), + commandSummary(head), + commandSummary(originMain), + commandSummary(status), + commandSummary(mergeBase), + commandSummary(localTag), + commandSummary(remoteTag), + ].filter((line): line is string => line !== undefined).join("\n\n"); + + if (failures.length > 0 || head.stdout.length === 0) return { ok: false, summary }; + return { ok: true, summary, mainOid: head.stdout }; +} + +function verifyReleaseTagPublished(release: ValidatedRelease, expectedTagTargetOid: string): TagPublicationVerification { + const localTag = runCommand(["git", "rev-parse", `${release.version}^{}`]); + const remoteTag = runCommand(["git", "ls-remote", "--tags", "origin", `refs/tags/${release.version}`]); + const remoteTagTargetOid = remoteTag.stdout.split(/\s+/u)[0] ?? ""; + const failures: string[] = []; + + if (localTag.exitCode !== 0 || localTag.stdout !== expectedTagTargetOid) { + failures.push(`local tag target was ${localTag.stdout || "missing"}, expected ${expectedTagTargetOid}`); + } + if (remoteTag.exitCode !== 0 || remoteTagTargetOid !== expectedTagTargetOid) { + failures.push(`remote tag target was ${remoteTagTargetOid || "missing"}, expected ${expectedTagTargetOid}`); + } + + const summary = [ + failures.length === 0 ? "Release tag publication is deterministically verified." : "Release tag publication is not verified.", + failures.length === 0 ? undefined : failures.map((failure) => `- ${failure}`).join("\n"), + commandSummary(localTag), + commandSummary(remoteTag), + ].filter((line): line is string => line !== undefined).join("\n\n"); + + if (failures.length > 0) return { ok: false, summary }; + return { ok: true, summary, tagTargetOid: expectedTagTargetOid }; +} + +async function verifyPublishWorkflowSucceeded( + release: ValidatedRelease, + expectedHeadSha: string, +): Promise { + let runList: CommandResult | undefined; + let selectedRun: ReturnType | undefined; + + for (let attempt = 1; attempt <= 6; attempt += 1) { + runList = runCommand([ + "gh", + "run", + "list", + "--workflow", + "publish.yml", + "--event", + "push", + "--json", + "databaseId,status,conclusion,url,headBranch,event,workflowName,createdAt,headSha", + "--limit", + "50", + ]); + + if (runList.exitCode !== 0) { + return { + ok: false, + summary: ["GitHub Actions publish run lookup command failed.", commandSummary(runList)].join("\n\n"), + }; + } + + const parsedList = parseJsonCommand(runList, "GitHub Actions publish run lookup returned invalid JSON."); + if (!parsedList.ok) return { ok: false, summary: parsedList.summary }; + + selectedRun = selectPublishWorkflowRunJson(parsedList.value, release.version); + if (selectedRun.ok) break; + if (attempt < 6) await Bun.sleep(10_000); + } + + if (runList === undefined || selectedRun === undefined || !selectedRun.ok) { + return { + ok: false, + summary: [ + selectedRun?.summary ?? "GitHub Actions publish run lookup did not execute.", + runList === undefined ? undefined : commandSummary(runList), + ].filter((line): line is string => line !== undefined).join("\n\n"), + }; + } + + const watch = selectedRun.status === "completed" + ? undefined + : runCommand(["gh", "run", "watch", String(selectedRun.runId), "--exit-status"]); + + if (watch !== undefined && watch.exitCode !== 0) { + return { + ok: false, + runId: selectedRun.runId, + runUrl: selectedRun.runUrl, + summary: [ + "GitHub Actions publish run did not complete successfully while watching.", + selectedRun.summary, + commandSummary(runList), + commandSummary(watch), + ].join("\n\n"), + }; + } + + const runView = runCommand([ + "gh", + "run", + "view", + String(selectedRun.runId), + "--json", + "databaseId,status,conclusion,url,headBranch,event,workflowName,createdAt,headSha", + ]); + + if (runView.exitCode !== 0) { + return { + ok: false, + runId: selectedRun.runId, + runUrl: selectedRun.runUrl, + summary: ["GitHub Actions publish run verification command failed.", commandSummary(runView)].join("\n\n"), + }; + } + + const parsedView = parseJsonCommand(runView, "GitHub Actions publish run verification returned invalid JSON."); + if (!parsedView.ok) { + return { + ok: false, + runId: selectedRun.runId, + runUrl: selectedRun.runUrl, + summary: parsedView.summary, + }; + } + + const publishVerification = verifyPublishWorkflowRunJson(parsedView.value, release.version, expectedHeadSha); + if (!publishVerification.ok) { + return { + ok: false, + runId: publishVerification.runId ?? selectedRun.runId, + runUrl: publishVerification.runUrl ?? selectedRun.runUrl, + summary: [publishVerification.summary, commandSummary(runList), commandSummary(runView)].join("\n\n"), + }; + } + + return { + ok: true, + runId: publishVerification.runId, + runUrl: publishVerification.runUrl, + status: publishVerification.status, + conclusion: publishVerification.conclusion, + headSha: publishVerification.headSha, + summary: [ + publishVerification.summary, + commandSummary(runList), + watch === undefined ? undefined : commandSummary(watch), + commandSummary(runView), + ].filter((line): line is string => line !== undefined).join("\n\n"), + }; +} + +function releaseInstructions(release: ValidatedRelease): string { + return [ + `Release kind: ${release.kind}`, + `Target version: ${release.version}`, + `Release branch to create from current HEAD: ${release.branch}`, + "Repository rules:", + "- Use Bun commands, not npm/yarn/pnpm/npx, for local development steps.", + "- Never include a leading v in the version or tag.", + "- Do not modify already released changelog sections; add entries only under each package CHANGELOG.md `## [Unreleased]` section.", + `- Use \`bun run scripts/bump-version.ts ${release.version}\` and then \`bun install\` for version bumps.`, + "- If credentials, git state, CI, or publish checks block safe progress, report the blocker clearly and stop rather than fabricating success.", + ].join("\n"); +} + +export default defineWorkflow("publish-release") + .description("Automate Atomic release/prerelease branch, PR, merge, tag, and publish monitoring.") + .input("target_version", Type.String({ description: "Version to publish, without a leading v." })) + .input("release_kind", Type.Union([Type.Literal("release"), Type.Literal("prerelease")], { + description: "Release type; release requires MAJOR.MINOR.PATCH and prerelease requires MAJOR.MINOR.PATCH-alpha.REVISION.", + })) + .output("status", statusSchema) + .output("target_version", Type.String({ description: "Validated version supplied to the release workflow." })) + .output("release_kind", releaseKindSchema) + .output("branch", Type.String({ description: "Release branch created by the workflow." })) + .output("pr_url", Type.Optional(Type.String({ description: "Best-effort PR URL detected from the PR stage output." }))) + .output("tag", Type.Optional(Type.String({ description: "Version tag pushed to trigger publishing." }))) + .output("summary", Type.String({ description: "Compact release execution summary." })) + .run(async (ctx) => { + const release = validateReleaseRequest(ctx.inputs.release_kind, ctx.inputs.target_version); + const baseInstructions = releaseInstructions(release); + const sourceHead = runCommand(["git", "rev-parse", "HEAD"]); + + if (sourceHead.exitCode !== 0 || sourceHead.stdout.length === 0) { + return blockedOutput( + release, + "capture-source-head", + "git rev-parse HEAD resolves the source commit before release preparation", + commandSummary(sourceHead), + ); + } + + const prepare = await ctx.task("prepare-release-branch-and-metadata", { + prompt: [ + "Prepare the release branch and metadata changes for this Atomic repository.", + "", + baseInstructions, + "", + "Required actions:", + "1. Inspect `git status --short`, `git branch --show-current`, `git rev-parse HEAD`, `git log -1 --oneline`, and `git remote -v` to record the source branch and exact source commit.", + "2. Ensure you are starting from a safe state for a release. If unrelated uncommitted changes already exist before your release edits, stop and report BLOCKED with the exact files.", + `3. Create and switch to branch \`${release.branch}\` from the recorded source commit \`${sourceHead.stdout}\` if it does not already exist; if it exists, verify it is the intended same-version release branch before continuing.`, + "4. Read package changelogs, especially `packages/*/CHANGELOG.md`, and update only `## [Unreleased]` sections according to AGENTS.md Changelog guidance.", + `5. Run \`bun run scripts/bump-version.ts ${release.version}\` and then \`bun install\`.`, + "6. Inspect the resulting diff and ensure it contains only release metadata/changelog/version/lockfile changes.", + `7. Commit all release changes on \`${release.branch}\` with a concise conventional message such as \`chore: release ${release.version}\`.`, + "", + "Final response format:", + "- Summarize source branch, source HEAD, created/current release branch, release commit hash, `git status --short`, changed files, commands run, and any blockers.", + "- Do not claim the workflow is ready based on prose alone; the workflow body performs deterministic release-preparation verification after this stage.", + ].join("\n"), + }); + + const preparationVerification = await verifyReleasePreparation(release, sourceHead.stdout); + if (!preparationVerification.ok) { + return blockedOutput( + release, + "verify-release-preparation", + "release branch, clean worktree, allowed release files, and package metadata are deterministically verified", + [preparationVerification.summary, "", "Prepare stage output:", excerpt(prepare.text, 2_000)].join("\n"), + ); + } + + const localChecks = runLocalReleaseChecks(release); + if (!localChecks.ok) { + return blockedOutput( + release, + "run-local-release-checks", + "bun run typecheck and bun run test:unit exit successfully on a clean release branch", + localChecks.summary, + "failed", + ); + } + + const pr = await ctx.task("open-release-pr", { + prompt: [ + "Push the release branch and open the release PR with GitHub CLI.", + "", + baseInstructions, + "", + "Deterministic preparation and local checks:", + excerpt([preparationVerification.summary, localChecks.summary].join("\n\n")), + "", + "Required actions:", + `1. Use \`git branch --show-current\` plus \`git rev-parse HEAD\` to verify the current branch is \`${release.branch}\` at commit \`${preparationVerification.releaseCommitOid}\`.`, + `2. Push branch with \`git push -u origin ${release.branch}\`.`, + "3. Use `gh auth status` and `gh repo view` or equivalent non-destructive checks to confirm GitHub access.", + `4. Create a PR from \`${release.branch}\` to \`main\` with title \`Release ${release.version}\` if one does not already exist. If a PR already exists for the branch, reuse it.`, + "5. Include release kind, version, changelog/version bump summary, and validation commands in the PR body.", + "", + "Final response format:", + "- Include the PR URL on its own line if available.", + "- Include PR base, head branch, head SHA, commands run, and any blockers.", + "- Do not use a PR_STATUS marker; the workflow body captures and verifies the PR identity deterministically after this stage.", + ].join("\n"), + }); + + const prReference = captureReleasePrReference(release, preparationVerification.releaseCommitOid); + if (!prReference.ok) { + return blockedOutput( + release, + "capture-release-pr-reference", + "GitHub PR has OPEN state, matching base/head refs, and head SHA equal to the release commit", + [prReference.summary, "", "PR stage output:", excerpt(pr.text, 2_000)].join("\n"), + ); + } + + const ciWait = await ctx.task("wait-for-release-ci", { + prompt: [ + "Wait for required CI checks on the release PR, but do not merge it.", + "", + baseInstructions, + "", + "Deterministic PR reference captured from GitHub:", + excerpt(prReference.summary), + "", + "Required actions:", + `1. Identify the PR using this deterministic selector: ${prReference.prUrl}`, + "2. Wait for required checks using `gh pr checks --watch --required` or an equivalent `gh` workflow that returns a non-zero status on failures.", + "3. If any required check fails, report the failed check names and URLs/log hints. Do not merge.", + "4. If checks appear to pass, stop after summarizing the check evidence. Do not merge.", + "", + "Final response format:", + "- Include commands run, check names/states, URLs/log hints for failures, and any blockers.", + "- The workflow body performs the deterministic required-check gate after this stage.", + ].join("\n"), + }); + + const ciVerification = verifyReleasePrChecksPassed(release, prReference); + if (!ciVerification.ok) { + return blockedOutput( + release, + "verify-release-pr-checks-passed", + "GitHub PR required checks are passing for the exact captured PR head SHA before merge", + [ciVerification.summary, "", "CI wait stage output:", excerpt(ciWait.text, 2_000)].join("\n"), + "failed", + ); + } + + const merge = await ctx.task("merge-verified-release-pr", { + prompt: [ + "Merge the release PR after deterministic CI verification.", + "", + baseInstructions, + "", + "Deterministic CI gate:", + excerpt(ciVerification.summary), + "", + "Required actions:", + `1. Identify the PR using this deterministic selector: ${prReference.prUrl}`, + `2. Merge only the captured head commit \`${prReference.headRefOid ?? preparationVerification.releaseCommitOid}\`; if using \`gh pr merge\`, prefer a method that includes a head-SHA guard such as \`--match-head-commit\` when available.`, + "3. Use the repository-supported merge method. Do not delete the release branch after merge.", + "4. Summarize the merge attempt, commands run, merged commit/ref evidence if available, branch-retention evidence if available, and any blockers.", + "", + "Final response format:", + "- Do not rely on an exact merge status marker; the workflow body verifies GitHub PR merge state, head SHA, and branch retention directly after this stage.", + ].join("\n"), + }); + + const mergeVerification = verifyReleasePrMerged(release, prReference.prUrl, prReference.headRefOid); + if (!mergeVerification.ok) { + return blockedOutput( + release, + "verify-release-pr-merged", + "GitHub PR state MERGED with mergedAt, mergeCommit.oid, matching base/head refs, matching captured head SHA, and retained remote release branch", + [mergeVerification.summary, "", "Merge stage output:", excerpt(merge.text, 2_000)].join("\n"), + ); + } + + const syncMain = await ctx.task("sync-main-after-merge", { + prompt: [ + "Sync local main after the release PR merge. Do not create or push a tag.", + "", + baseInstructions, + "", + "Deterministic merge verification:", + excerpt(mergeVerification.summary), + "", + "Required actions:", + "1. Switch to `main` and run `git pull origin main`.", + `2. Confirm the merged release commit for ${release.version} is present on local main with command-backed evidence such as \`git rev-parse HEAD\` and \`git merge-base --is-ancestor ${mergeVerification.mergeCommitOid} HEAD\`.`, + `3. Confirm tag \`${release.version}\` does not already exist locally or on origin. Do not create the tag in this stage.`, + "", + "Final response format:", + "- Include local main HEAD, origin/main evidence, worktree status, tag existence checks, commands run, and any blockers.", + "- The workflow body performs a deterministic main/tag-readiness gate after this stage.", + ].join("\n"), + }); + + const mainReady = verifyMainReadyForTag(release, mergeVerification.mergeCommitOid); + if (!mainReady.ok) { + return blockedOutput( + release, + "verify-main-ready-for-tag", + "local main is clean, matches origin/main, contains the merge commit, and the release tag does not already exist", + [mainReady.summary, "", "Sync-main stage output:", excerpt(syncMain.text, 2_000)].join("\n"), + ); + } + + const pushTag = await ctx.task("push-release-tag", { + prompt: [ + "Create and push the release tag. This is the sole publish trigger stage.", + "", + baseInstructions, + "", + "Deterministic tag readiness gate:", + excerpt(mainReady.summary), + "", + "Required actions:", + `1. Verify you are still on clean local \`main\` at commit \`${mainReady.mainOid}\`.`, + `2. Run \`git tag ${release.version}\` and \`git push origin ${release.version}\`.`, + "3. Do not force-push or overwrite an existing tag.", + "4. You may start monitoring the publish workflow, but the workflow body will verify the tag and publish run deterministically after this stage.", + "", + "Final response format:", + "- Include pushed tag, local/remote tag SHA evidence, GitHub Actions run URL/status if available, commands run, and any observed blockers.", + ].join("\n"), + }); + + const tagVerification = verifyReleaseTagPublished(release, mainReady.mainOid); + if (!tagVerification.ok) { + return blockedOutput( + release, + "verify-release-tag-published", + "local and remote release tag exist and point to the verified main commit", + [tagVerification.summary, "", "Push-tag stage output:", excerpt(pushTag.text, 2_000)].join("\n"), + "failed", + ); + } + + const publishVerification = await verifyPublishWorkflowSucceeded(release, tagVerification.tagTargetOid); + if (!publishVerification.ok) { + return blockedOutput( + release, + "verify-publish-workflow-succeeded", + "GitHub Actions Publish run for the release tag has matching headSha, status completed, and conclusion success", + [publishVerification.summary, "", "Push-tag stage output:", excerpt(pushTag.text, 2_000)].join("\n"), + "failed", + ); + } + + const prUrl = mergeVerification.prUrl ?? prReference.prUrl; + const actionUrl = publishVerification.runUrl; + const summary = [ + `publish-release completed for ${release.kind} ${release.version}.`, + `Branch: ${release.branch}`, + prUrl === undefined ? "PR URL: see open-release-pr stage output" : `PR URL: ${prUrl}`, + `Tag: ${release.version}`, + actionUrl === undefined ? "Publish run: see push-release-tag stage output" : `Publish run: ${actionUrl}`, + "", + "Stage summaries:", + "## prepare-release-branch-and-metadata", + excerpt(prepare.text, 800), + "", + "## deterministic-release-preparation", + excerpt(preparationVerification.summary, 800), + "", + "## deterministic-local-release-checks", + excerpt(localChecks.summary, 800), + "", + "## open-release-pr", + excerpt(pr.text, 800), + "", + "## deterministic-pr-reference", + excerpt(prReference.summary, 800), + "", + "## wait-for-release-ci", + excerpt(ciWait.text, 800), + "", + "## deterministic-ci-verification", + excerpt(ciVerification.summary, 800), + "", + "## merge-verified-release-pr", + excerpt(merge.text, 800), + "", + "## deterministic-merge-verification", + excerpt(mergeVerification.summary, 800), + "", + "## sync-main-after-merge", + excerpt(syncMain.text, 800), + "", + "## deterministic-main-ready-for-tag", + excerpt(mainReady.summary, 800), + "", + "## push-release-tag", + excerpt(pushTag.text, 800), + "", + "## deterministic-tag-verification", + excerpt(tagVerification.summary, 800), + "", + "## deterministic-publish-verification", + excerpt(publishVerification.summary, 800), + ].join("\n"); + + const result: PublishReleaseOutput = { + status: "completed", + target_version: release.version, + release_kind: release.kind, + branch: release.branch, + tag: release.version, + summary, + }; + + if (prUrl !== undefined) { + return { ...result, pr_url: prUrl }; + } + + return result; + }) + .compile(); diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 6f4dd2855..c2b8deebc 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -21,7 +21,8 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "import": "./dist/index.js", + "default": "./dist/index.js" }, "./hooks": { "types": "./dist/core/hooks/index.d.ts", diff --git a/specs/2026-06-10-publish-release-workflow.md b/specs/2026-06-10-publish-release-workflow.md new file mode 100644 index 000000000..3017c2d89 --- /dev/null +++ b/specs/2026-06-10-publish-release-workflow.md @@ -0,0 +1,221 @@ +# Publish Release Workflow Technical Design Document / RFC + +| Document Metadata | Details | +| ---------------------- | ------- | +| Author(s) | Norin Lavaee | +| Status | Approved for implementation | +| Team / Owner | Atomic maintainers | +| Created / Last Updated | 2026-06-10 | + +## 1. Executive Summary + +This RFC proposes a project-local Atomic workflow named `publish-release` under `.atomic/workflows/publish-release.ts` to automate the repo's documented release/prerelease process. It accepts a required `target_version`, a required `release_kind` (`release` or `prerelease`), starts from the maintainer's current source branch/commit, creates `release/` or `prerelease/` from that exact current HEAD, and uses tracked stages to prepare changelogs/version bumps, open and merge a GitHub PR to `main`, tag the merged commit, push the tag, monitor publishing, and summarize the release. The workflow keeps model stages for flexible work such as changelog wording, PR body creation, CI-log interpretation, and command adaptation, but the release gates themselves are deterministic workflow-code checks. The two dangerous doors are `merge_verified_release_pr` and `publish_release_tag`; they funnel irreversible remote effects through named workflow stages guarded by deterministic preconditions and postcondition verification. + +## 2. Context and Motivation + +### 2.1 Current State + +The release process is documented in `AGENTS.md` and currently depends on an agent or maintainer manually executing the sequence. The repo already supports project-local workflows from `.atomic/workflows/*.{ts,js,mjs,cjs}` and local workflow files import `defineWorkflow` and `Type` from `@bastani/workflows`. + +Relevant current constraints: + +- Release flow is tag-driven: branch/PR merge does not publish; pushing a version tag does. +- Version bumping must use `bun run scripts/bump-version.ts ` followed by `bun install`. +- Changelog updates must be under each package `CHANGELOG.md` `## [Unreleased]` section. +- Development commands must use Bun except npm publish itself inside CI. +- Workflow definitions must export `defineWorkflow(...).compile()` and declare all outputs explicitly. + +### 2.2 The Problem + +Manual release execution is long, stateful, and contains remote side effects. The risky operations are spread across chat instructions rather than a reusable, inspectable workflow graph. Failures in CI or publish monitoring require structured handoff back to the maintainer instead of silent partial progress. + +## 3. Goals and Non-Goals + +### 3.1 Functional Goals + +- Provide a workflow named `publish-release` discoverable from `.atomic/workflows`. +- Require `target_version` and `release_kind` up front. +- Validate release versions: + - `release`: `MAJOR.MINOR.PATCH` + - `prerelease`: `MAJOR.MINOR.PATCH-alpha.REVISION` +- Create branch `release/` or `prerelease/` from the exact current HEAD/source commit, not by resetting to `main` first. +- Update changelogs and versions according to `AGENTS.md`. +- Run `bun run typecheck` and `bun run test:unit` before PR creation. +- Create a PR from the release/prerelease branch to `main`, wait for CI, enable auto-merge / merge when checks pass. +- Keep the release/prerelease branch after merge. +- After merge, switch to `main`, pull `origin/main`, tag ``, push tag, and monitor publish action. +- Return a compact result with status, version, kind, PR reference, tag, and summary. + +### 3.2 Non-Goals + +- Do not publish directly from the local machine. +- Do not support arbitrary prerelease labels beyond `alpha`. +- Do not introduce new release scripts or build steps. +- Do not modify workflow discovery/runtime internals. +- Do not bypass CI, force-push tags, or merge on failing checks. + +### 3.3 Backwards Compatibility + +This is an additive project-local workflow. It must not change existing package APIs, workflow SDK behavior, release scripts, CI configuration, or discovery semantics. Existing `.atomic/workflows` contract/HIL fixtures remain untouched. + +## 4. Proposed Solution (High-Level Design) + +### 4.1 System Architecture Diagram + +```mermaid +flowchart TB + User[Maintainer launches publish-release] --> Input[Workflow input validation] + Input --> Branch[prepare_release_branch] + Branch --> Metadata[update_release_metadata] + Metadata --> LocalChecks[run_release_checks] + LocalChecks --> PR[open_release_pr] + PR --> CI[wait_for_release_ci] + CI --> Merge[merge_verified_release_pr ⚠] + Merge --> Sync[sync_main_after_merge] + Sync --> Tag[publish_release_tag ⚠] + Tag --> Monitor[verify_published_release] + Monitor --> Summary[release_summary] +``` + +### 4.2 Architectural Pattern + +Selected starter pattern: **Classify-and-act + loop until done + adversarial verification**. + +- Classify-and-act: `release_kind` selects branch prefix and version regex. +- Loop until done: CI and publish monitoring are bounded wait/check stages that continue until success/failure evidence exists. +- Adversarial verification: local validation and CI/publish status stages verify the generated release state before dangerous doors proceed. + +### 4.3 Key Components + +| Component | Responsibility | Implementation | +| --------- | -------------- | -------------- | +| Workflow definition | Declares inputs/outputs and tracked stages | `.atomic/workflows/publish-release.ts` | +| Agent stages | Execute git/Bun/gh release work with tool access | `ctx.task(...)` prompts | +| Human/runtime failure handling | Ask user only when checks fail or publish fails | Stage prompt uses available UI/tooling; workflow remains inspectable | +| Output contract | Expose release status and references | Declared `.output(...)` keys | + +### 4.4 The Door Set at a Glance + +`launch_publish_release`, `validate_release_request`, `prepare_release_branch`, `update_release_metadata`, `verify_release_preparation`, `run_local_release_checks`, `open_release_pr`, `verify_release_pr_reference`, `wait_for_release_ci`, `verify_release_pr_checks_passed`, `merge_verified_release_pr` ⚠, `verify_release_pr_merged`, `sync_main_after_merge`, `verify_main_ready_for_tag`, `publish_release_tag` ⚠, `verify_release_tag_published`, `verify_published_release`, `summarize_release`. + +## 5. Detailed Design + +### 5.1 The Doors (Entrypoint Contracts) + +```ts +type ReleaseKind = "release" | "prerelease"; +type ReleaseStatus = "completed" | "blocked" | "failed"; + +launch_publish_release(input: { target_version: string; release_kind: ReleaseKind }): ReleaseRun +// Guarantee: starts exactly one tracked release workflow for the supplied version. + +validate_release_request(input): ValidReleaseRequest | VersionFormatError +// Guarantee: returns typed release metadata only when kind and version format agree. + +verify_release_pr_checks_passed(pr: ReleasePr): CheckedReleasePr | CiFailure +// Guarantee: returns checked PR evidence only when required checks pass for the captured PR head SHA. + +merge_verified_release_pr(pr: CheckedReleasePr): MergedReleasePr | CiFailure +// Guarantee: merges only a PR whose required checks have passed. IRREVERSIBLE remote effect. + +verify_main_ready_for_tag(merged: MergedReleasePr): TaggableMain | TagFailure +// Guarantee: returns taggable main evidence only when local main matches origin/main, contains the merge commit, and the tag does not already exist. + +publish_release_tag(main: TaggableMain): PublishedTag | TagFailure +// Guarantee: pushes the version tag that triggers CI publishing. IRREVERSIBLE remote effect. +``` + +| Door | Joint | One-sentence guarantee | Refusals | Chokepoint | +| ---- | ----- | ---------------------- | -------- | ---------- | +| `validate_release_request` | Release request validity | Produces validated release metadata. | Wrong release/prerelease format; leading `v`; invalid alpha revision. | Input airlock | +| `verify_release_pr_checks_passed` | CI pass evidence | Accepts only required checks passing for the captured PR head SHA. | Failed/pending/missing checks; PR head changed; wrong PR state. | Deterministic CI gate | +| `merge_verified_release_pr` ⚠ | Merge release PR | Merges only verified release changes. | Failing CI; missing PR; wrong branch; gh auth failure. | Sole merge door | +| `verify_main_ready_for_tag` | Tag readiness | Accepts only clean local main matching origin/main with no existing release tag. | Missing main sync; existing local/remote tag; merge commit absent. | Deterministic tag precondition | +| `publish_release_tag` ⚠ | Publish release tag | Pushes the tag that starts publishing. | Missing main sync; existing tag; failed merge; git push failure. | Sole publish trigger | +| `verify_release_tag_published` | Tag publication evidence | Accepts only a local and remote tag pointing to the verified main commit. | Missing tag; tag points to wrong commit; push failed. | Deterministic tag postcondition | +| `verify_published_release` | Release completion evidence | Reports publish outcome from GitHub Actions. | Failed action; timed out/unknown status; run head SHA differs from tag target. | Final verification gate | + +### 5.2 Workflow Inputs + +```ts +.input("target_version", Type.String({ description: "Version to publish, without a leading v." })) +.input("release_kind", Type.Union([Type.Literal("release"), Type.Literal("prerelease")], { description: "Release type; must match target_version format." })) +``` + +The workflow should fail early when: + +- `release_kind === "release"` and `target_version` does not match `^\d+\.\d+\.\d+$`. +- `release_kind === "prerelease"` and `target_version` does not match `^\d+\.\d+\.\d+-alpha\.[1-9]\d*$`. +- `target_version` starts with `v`. + +### 5.3 Workflow Outputs + +```ts +.output("status", Type.Union([Type.Literal("completed"), Type.Literal("blocked"), Type.Literal("failed")])) +.output("target_version", Type.String()) +.output("release_kind", Type.Union([Type.Literal("release"), Type.Literal("prerelease")])) +.output("branch", Type.String()) +.output("pr_url", Type.Optional(Type.String())) +.output("tag", Type.Optional(Type.String())) +.output("summary", Type.String()) +``` + +### 5.4 Stage Plan + +1. `validate-release-request` — deterministic TypeScript validation in the workflow body. +2. `prepare-release-branch-and-metadata` — toolful stage that: + - inspects git state and records source branch/source SHA, + - creates `release/` or `prerelease/` from the exact current HEAD/source commit, + - updates relevant changelogs under `## [Unreleased]`, + - runs `bun run scripts/bump-version.ts ` and `bun install`, + - commits changes. +3. Deterministic release-preparation verification in the workflow body — checks current branch, clean worktree, changed-file allowlist, package manifest versions/private flags, and release commit identity. +4. Deterministic local checks in the workflow body — runs `bun run typecheck` and `bun run test:unit` directly and blocks on non-zero exits or a dirty worktree. +5. `open-release-pr` — toolful stage that pushes branch and creates/reuses a PR targeting `main` with useful PR title/body content. +6. Deterministic PR reference verification in the workflow body — runs `gh pr view` and `git ls-remote` to require `OPEN` state, `main` base, release branch head, and exact release commit SHA. +7. `wait-for-release-ci` — toolful stage that may wait for/check CI and summarize failures, but does not merge. +8. Deterministic CI verification in the workflow body — runs `gh pr checks --required --json ...` and requires a non-empty required-check list where every check passes for the captured PR head SHA. +9. `merge-verified-release-pr` — toolful stage that performs the repository-supported merge after the deterministic CI gate and keeps the release/prerelease branch. +10. Deterministic merge verification in the workflow body — runs `gh pr view --json state,mergedAt,mergeCommit,baseRefName,headRefName,headRefOid,url` and `git ls-remote --heads origin `; GitHub state, captured head SHA, and branch retention are the source of truth before tagging. +11. `sync-main-after-merge` — toolful stage that switches to `main`, pulls `origin/main`, and does not tag. +12. Deterministic tag-readiness verification in the workflow body — requires clean local `main`, `HEAD === origin/main`, merge commit ancestry, and no existing local/remote release tag. +13. `push-release-tag` — toolful stage that creates and pushes the version tag, without force-pushing. +14. Deterministic tag/publish verification in the workflow body — requires the local and remote tag to point to the verified main commit, then verifies the publish workflow run has matching `headSha`, `status === completed`, and `conclusion === success`. +15. Workflow returns summary outputs. + +Large command output should stay in stage transcripts/artifacts, while the final returned `summary` stays compact. + +## 6. Alternatives Considered + +| Option | Pros | Cons | Decision | +| ------ | ---- | ---- | -------- | +| One giant stage | Simple file | Harder to inspect, recover, and attach to precise failure points | Rejected | +| Deterministic shell script | Repeatable | Loses workflow graph/HIL/status benefits and needs exact gh/CI scripting | Rejected | +| Multi-stage workflow with toolful agents | Inspectable, resumable, aligns with Atomic workflow model | Depends on agent/tool competence for command adaptation | Selected | + +## 7. Cross-Cutting Concerns + +- **Security:** The workflow relies on local git and `gh` credentials; it must not fabricate success when auth is missing. +- **Irreversibility:** Merging and tag pushing are separate honest doors; after the merge stage, the workflow body performs a deterministic GitHub verification so a formatting error in an agent response cannot block after a successful merge. +- **Failure behavior:** CI/publish failures produce `blocked`/`failed` summaries with evidence rather than continuing. +- **Concurrency:** The workflow should refuse dirty or conflicting git state unless the stage can safely commit existing release changes as intended. +- **Bun compliance:** All local validation/version/dependency commands use Bun. +- **Evidence:** Stage responses should prefer programmatically verifiable `git`/`gh` evidence such as source SHAs, PR JSON fields, check status, remote branch presence, tag SHA, and Actions run URLs over prose assertions. + +## 8. Test Plan + +- Typecheck the new workflow with `bun run typecheck`. +- Reload workflow discovery with the workflow tool. +- Inspect workflow inputs with `workflow({ action: "inputs", workflow: "publish-release" })`. +- Review prompt contents to confirm they capture source HEAD before branch creation, create the release/prerelease branch from that exact HEAD, target PRs to `main`, retain the release/prerelease branch after merge, and request compact `git`/`gh` evidence. +- Perform safe negative validation by running with a mismatched version/kind and confirming early failure, e.g. prerelease kind with `1.2.3`. +- Do not run a real happy-path release as validation unless explicitly authorized because it can push branches/tags and trigger publishing. + +## 9. Open Questions / Unresolved Issues + +Resolved before implementation: + +- Workflow name: `publish-release`. +- Authority: fully autonomous after required inputs; pause/report only on failures or missing credentials. +- Inputs: require both `target_version` and `release_kind`. +- Local validation: run `bun run typecheck` and `bun run test:unit` before PR creation. diff --git a/test/unit/publish-release-helpers.test.ts b/test/unit/publish-release-helpers.test.ts new file mode 100644 index 000000000..4d0ccff80 --- /dev/null +++ b/test/unit/publish-release-helpers.test.ts @@ -0,0 +1,257 @@ +import { describe, test } from "bun:test"; +import assert from "node:assert/strict"; +import { + prereleaseVersionPattern, + releaseVersionPattern, + selectPublishWorkflowRunJson, + validateReleaseRequest, + verifyPublishWorkflowRunJson, + verifyPullRequestChecksJson, + verifyPullRequestMergedJson, + verifyReleasePullRequestReferenceJson, + type JsonValue, +} from "../../.atomic/workflows/lib/publish-release.js"; + +describe("publish-release version validation", () => { + test("accepts stable release versions only for release requests", () => { + assert.equal(releaseVersionPattern.test("1.2.3"), true); + assert.equal(releaseVersionPattern.test("1.2.3-alpha.1"), false); + + assert.deepEqual(validateReleaseRequest("release", "1.2.3"), { + kind: "release", + version: "1.2.3", + branch: "release/1.2.3", + }); + assert.throws( + () => validateReleaseRequest("release", "1.2.3-alpha.1"), + /expected MAJOR\.MINOR\.PATCH/u, + ); + }); + + test("accepts alpha prerelease revisions starting at one only for prerelease requests", () => { + assert.equal(prereleaseVersionPattern.test("1.2.3-alpha.1"), true); + assert.equal(prereleaseVersionPattern.test("1.2.3-alpha.0"), false); + assert.equal(prereleaseVersionPattern.test("1.2.3-beta.1"), false); + assert.equal(prereleaseVersionPattern.test("1.2.3"), false); + + assert.deepEqual(validateReleaseRequest("prerelease", "1.2.3-alpha.1"), { + kind: "prerelease", + version: "1.2.3-alpha.1", + branch: "prerelease/1.2.3-alpha.1", + }); + assert.throws( + () => validateReleaseRequest("prerelease", "1.2.3"), + /expected MAJOR\.MINOR\.PATCH-alpha\.REVISION/u, + ); + }); + + test("rejects versions with a leading v before applying kind-specific validation", () => { + assert.throws( + () => validateReleaseRequest("release", "v1.2.3"), + /must not include a leading "v"/u, + ); + assert.throws( + () => validateReleaseRequest("prerelease", "v1.2.3-alpha.1"), + /must not include a leading "v"/u, + ); + }); +}); + +describe("publish-release GitHub PR reference verification", () => { + const releasePr: JsonValue = { + number: 123, + state: "OPEN", + baseRefName: "main", + headRefName: "release/1.2.3", + headRefOid: "def456", + url: "https://github.com/earendil-works/pi-mono/pull/123", + }; + + test("accepts GitHub PR JSON only when the URL, number, and refs match the release branch", () => { + assert.deepEqual(verifyReleasePullRequestReferenceJson(releasePr, "release/1.2.3"), { + ok: true, + summary: [ + "GitHub PR reference is verified.", + "number: 123", + "url: https://github.com/earendil-works/pi-mono/pull/123", + "baseRefName: main", + "headRefName: release/1.2.3", + "headRefOid: def456", + "state: OPEN", + ].join("\n"), + prUrl: "https://github.com/earendil-works/pi-mono/pull/123", + prNumber: 123, + headRefOid: "def456", + state: "OPEN", + }); + }); + + test("rejects GitHub PR JSON for an unrelated branch before merge verification", () => { + const result = verifyReleasePullRequestReferenceJson( + { ...releasePr, headRefName: "release/other" }, + "release/1.2.3", + ); + + assert.equal(result.ok, false); + assert.match(result.summary, /headRefName was release\/other, expected release\/1\.2\.3/u); + }); +}); + +describe("publish-release GitHub merge verification", () => { + const mergedPr: JsonValue = { + state: "MERGED", + mergedAt: "2026-06-12T08:00:00Z", + mergeCommit: { oid: "abc123" }, + baseRefName: "main", + headRefName: "release/1.2.3", + headRefOid: "def456", + url: "https://github.com/earendil-works/pi-mono/pull/123", + }; + + test("accepts GitHub PR JSON only when merged with matching refs and merge commit", () => { + assert.deepEqual(verifyPullRequestMergedJson(mergedPr, "release/1.2.3"), { + ok: true, + summary: [ + "GitHub PR is verified as merged.", + "state: MERGED", + "mergedAt: 2026-06-12T08:00:00Z", + "mergeCommit.oid: abc123", + "baseRefName: main", + "headRefName: release/1.2.3", + "headRefOid: def456", + "url: https://github.com/earendil-works/pi-mono/pull/123", + ].join("\n"), + mergeCommitOid: "abc123", + prUrl: "https://github.com/earendil-works/pi-mono/pull/123", + }); + }); + + test("rejects unmerged or mismatched GitHub PR JSON", () => { + const result = verifyPullRequestMergedJson({ ...mergedPr, state: "OPEN", headRefName: "release/other" }, "release/1.2.3"); + + assert.equal(result.ok, false); + assert.match(result.summary, /state was OPEN, expected MERGED/u); + assert.match(result.summary, /headRefName was release\/other, expected release\/1\.2\.3/u); + }); +}); + +describe("publish-release GitHub PR checks verification", () => { + test("accepts only non-empty required check lists where every check is passing", () => { + assert.deepEqual(verifyPullRequestChecksJson([ + { name: "typecheck", bucket: "pass", state: "SUCCESS" }, + { name: "unit", state: "SUCCESS" }, + ]), { + ok: true, + summary: [ + "GitHub PR required checks are verified as passing.", + "checkCount: 2", + ].join("\n"), + checkCount: 2, + }); + }); + + test("rejects empty, failing, pending, or malformed required check lists", () => { + assert.equal(verifyPullRequestChecksJson([]).ok, false); + + const result = verifyPullRequestChecksJson([ + { name: "typecheck", bucket: "fail", state: "FAILURE", link: "https://example.test/check" }, + { name: "unit", bucket: "pending", state: "PENDING" }, + ]); + + assert.equal(result.ok, false); + assert.match(result.summary, /typecheck bucket=fail state=FAILURE link=https:\/\/example\.test\/check/u); + assert.match(result.summary, /unit bucket=pending state=PENDING/u); + }); + + test("does not treat completed check status as passing without a pass bucket", () => { + const result = verifyPullRequestChecksJson([ + { name: "typecheck", state: "COMPLETED" }, + ]); + + assert.equal(result.ok, false); + assert.match(result.summary, /typecheck bucket=missing state=COMPLETED/u); + }); +}); + +describe("publish-release GitHub Actions publish verification", () => { + const successfulRun: JsonValue = { + databaseId: 987654321, + workflowName: "Publish", + headBranch: "1.2.3", + event: "push", + status: "completed", + conclusion: "success", + headSha: "abc123", + url: "https://github.com/earendil-works/pi-mono/actions/runs/987654321", + }; + + test("selects the newest push run for the release tag from gh run list JSON", () => { + const result = selectPublishWorkflowRunJson([ + { ...successfulRun, databaseId: 111, headBranch: "1.2.4" }, + { ...successfulRun, status: "in_progress", conclusion: null }, + ], "1.2.3"); + + assert.deepEqual(result, { + ok: true, + summary: [ + "GitHub Actions publish run is selected.", + "databaseId: 987654321", + "headBranch: 1.2.3", + "event: push", + "status: in_progress", + "headSha: abc123", + "url: https://github.com/earendil-works/pi-mono/actions/runs/987654321", + ].join("\n"), + runId: 987654321, + runUrl: "https://github.com/earendil-works/pi-mono/actions/runs/987654321", + status: "in_progress", + conclusion: undefined, + headSha: "abc123", + }); + }); + + test("rejects run lists without a matching tag-triggered publish run", () => { + const result = selectPublishWorkflowRunJson([ + { ...successfulRun, headBranch: "1.2.4" }, + { ...successfulRun, event: "workflow_dispatch" }, + ], "1.2.3"); + + assert.equal(result.ok, false); + assert.match(result.summary, /expected headBranch: 1\.2\.3/u); + assert.match(result.summary, /headBranch=1\.2\.4 event=push/u); + assert.match(result.summary, /headBranch=1\.2\.3 event=workflow_dispatch/u); + }); + + test("accepts only completed successful publish runs for the release tag", () => { + assert.deepEqual(verifyPublishWorkflowRunJson(successfulRun, "1.2.3"), { + ok: true, + summary: [ + "GitHub Actions publish run is verified as successful.", + "databaseId: 987654321", + "workflowName: Publish", + "headBranch: 1.2.3", + "event: push", + "status: completed", + "conclusion: success", + "headSha: abc123", + "url: https://github.com/earendil-works/pi-mono/actions/runs/987654321", + ].join("\n"), + runId: 987654321, + runUrl: "https://github.com/earendil-works/pi-mono/actions/runs/987654321", + status: "completed", + conclusion: "success", + headSha: "abc123", + }); + }); + + test("rejects unsuccessful or mismatched publish run JSON", () => { + const result = verifyPublishWorkflowRunJson( + { ...successfulRun, headBranch: "1.2.4", status: "completed", conclusion: "failure" }, + "1.2.3", + ); + + assert.equal(result.ok, false); + assert.match(result.summary, /headBranch was 1\.2\.4, expected 1\.2\.3/u); + assert.match(result.summary, /conclusion was failure, expected success/u); + }); +});