From 1d6b404655043a3fd12802e4a99d527ff74a3a65 Mon Sep 17 00:00:00 2001 From: Douwe de Vries Date: Fri, 28 Aug 2026 08:31:58 +0200 Subject: [PATCH] Harden release publication transactions --- .../03-assurance-hardening/decisions.tsv | 3 + .github/workflows/release.yml | 106 ++- docs/development.md | 10 + scripts/release-monitor.mjs | 36 +- scripts/release-publish.ts | 792 ++++++++++++++++++ tests/documentation-contract.test.ts | 13 +- tests/release-monitor.test.ts | 55 +- tests/release-publish.test.ts | 757 +++++++++++++++++ 8 files changed, 1702 insertions(+), 70 deletions(-) create mode 100644 scripts/release-publish.ts create mode 100644 tests/release-publish.test.ts diff --git a/.agents/plans/03-assurance-hardening/decisions.tsv b/.agents/plans/03-assurance-hardening/decisions.tsv index d32a7c39..bfe3c0d3 100644 --- a/.agents/plans/03-assurance-hardening/decisions.tsv +++ b/.agents/plans/03-assurance-hardening/decisions.tsv @@ -61,3 +61,6 @@ ts phase decision why evidence result 2026-08-28T05:41:49Z phase-8 made the sealed bundle release authority digest records could be internally consistent without proving their verdict regradeQualificationBundle; assertQualificationBundle; release workflow verifier reopens all objects, regrades 76 attempts, rederives canary, provenance, usage, authority, and decision; forged decision and missing source bundles fail 2026-08-28T05:55:21Z phase-8 fixed all actionable release-authority review findings ignored fixed roles, unsafe reader artifacts, unbounded references, stale campaigns, ambiguous bundles, swallowed corruption, and script cycles weakened the gate fixed-role equality; read-time tar scan; total cap; seven-day policy; unique SHA-emitting selection; injected decision authority current release source must match the sealed closure and exactly one clean fresh bundle must reproduce 2026-08-28T05:55:21Z phase-8 completed independent release regrading verification forged decisions, omitted sources, contradictory roles/manifests, stale campaigns, digest-only records, and corrupted siblings needed executable rejection positive 76-cell CLI plus adversarial reseals; full check; replay; live smoke; final reviews 598 pass, 1 intentional skip, 13 of 13 replays, live smoke pass, no production blockers +2026-08-28T06:01:16Z phase-8 merged independent release-time regrading the exact head passed every required CI job and an isolated frozen-install verifier PR 56; merge f505b0d merged to main +2026-08-28T06:08:00Z phase-9 selected a bounded publication state machine inline shell inferred absence from errors, lacked current-main proof, and could destructively clobber assets two architecture candidates; independent arena judge repository-owned TypeScript ref proof and postcondition-driven npm/GitHub reconciliation; no blind mutation retries or clobber +2026-08-28T06:25:00Z phase-9 made the exact draft the durable transaction proof separate npm and GitHub steps could not recover after npm succeeded and main advanced adversarial multi-model review; draft-inclusive GitHub API contract full main proof creates exact draft and precedes npm; tag-only proof finalizes it; conflicts, extras, prereleases, pending digests, and superseded workflow failures fail or reconcile explicitly diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a25d88d..ca89924d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,7 @@ permissions: jobs: decide: runs-on: ubuntu-latest + timeout-minutes: 5 outputs: publish: ${{ steps.resolve.outputs.publish }} tag: ${{ steps.resolve.outputs.tag }} @@ -42,9 +43,12 @@ jobs: verify-main-and-tag: needs: decide runs-on: ubuntu-latest + timeout-minutes: 30 steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -58,15 +62,19 @@ jobs: bun-version: 1.3.14 - name: Install dependencies + timeout-minutes: 5 run: bun install --frozen-lockfile - name: Run deterministic checks + timeout-minutes: 10 run: bun run check - name: Rebuild release candidate + timeout-minutes: 2 run: bun pm pack --destination . - name: Report release evidence readiness without publishing + timeout-minutes: 5 shell: bash run: | set -euo pipefail @@ -78,12 +86,18 @@ jobs: needs: [decide, verify-main-and-tag] if: needs.decide.outputs.publish == 'true' runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: release-publication + cancel-in-progress: false permissions: contents: write id-token: write steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -93,6 +107,7 @@ jobs: package-manager-cache: false - name: Set up npm trusted publishing support + timeout-minutes: 5 run: | npm install --global npm@11.18.0 node --version @@ -104,24 +119,30 @@ jobs: bun-version: 1.3.14 - name: Install dependencies + timeout-minutes: 5 run: bun install --frozen-lockfile - name: Validate release tag and changelog + timeout-minutes: 2 run: bun run release:metadata -- --tag "${{ needs.decide.outputs.tag }}" --notes-file release-notes.md - name: Run checks + timeout-minutes: 10 run: bun run check - name: Run pinned live OpenCode smoke + timeout-minutes: 5 run: bun run smoke:live - name: Prepare package + timeout-minutes: 2 shell: bash run: | set -euo pipefail bun pm pack --destination . - name: Verify independently regraded qualification bundle and fresh canary + timeout-minutes: 5 shell: bash run: | set -euo pipefail @@ -134,36 +155,34 @@ jobs: --canary "evals/canary/${version}.json" shasum -a 256 "$tarball" > "${tarball}.sha256" - - name: Publish to npm + - name: Prepare exact GitHub release draft + timeout-minutes: 15 + env: + GH_TOKEN: ${{ github.token }} shell: bash run: | set -euo pipefail - package_name="$(node -p "require('./package.json').name")" - package_version="$(node -p "require('./package.json').version")" + tag="${{ needs.decide.outputs.tag }}" tarball="$(ls opencode-plugin-flow-*.tgz)" - local_integrity="$(node --input-type=module -e ' - import { createHash } from "node:crypto"; - import { readFileSync } from "node:fs"; - process.stdout.write("sha512-" + createHash("sha512").update(readFileSync(process.argv[1])).digest("base64")); - ' "$tarball")" - registry_error_file="$(mktemp)" - - if published_integrity="$(npm view "${package_name}@${package_version}" dist.integrity 2>"$registry_error_file")"; then - if [[ "$published_integrity" != "$local_integrity" ]]; then - echo "::error::npm already contains ${package_name}@${package_version} with different tarball integrity." - exit 1 - fi - echo "npm already contains the exact ${package_name}@${package_version} tarball; skipping publish." - else - registry_error="$(<"$registry_error_file")" - if [[ "$registry_error" != *"E404"* && "$registry_error" != *"No match found"* ]]; then - printf '%s\n' "$registry_error" >&2 - exit 1 - fi - npm publish "$tarball" --access public - fi + bun run scripts/release-publish.ts github-prepare \ + --tag "$tag" \ + --commit "${GITHUB_SHA}" \ + --notes release-notes.md \ + --asset "$tarball" \ + --asset "${tarball}.sha256" + + - name: Publish to npm after a current-main proof + timeout-minutes: 10 + shell: bash + run: | + set -euo pipefail + tarball="$(ls opencode-plugin-flow-*.tgz)" + bun run scripts/release-publish.ts npm \ + --artifact "$tarball" \ + --tag "${{ needs.decide.outputs.tag }}" - - name: Publish GitHub release assets + - name: Publish the exact GitHub release draft + timeout-minutes: 15 env: GH_TOKEN: ${{ github.token }} shell: bash @@ -171,34 +190,9 @@ jobs: set -euo pipefail tag="${{ needs.decide.outputs.tag }}" tarball="$(ls opencode-plugin-flow-*.tgz)" - release_query_error_file="$(mktemp)" - - retry_idempotent() { - local attempt=1 - local maximum_attempts=5 - local retry_delay_seconds - - until "$@"; do - if (( attempt >= maximum_attempts )); then - return 1 - fi - retry_delay_seconds=$((attempt * 15)) - echo "Retrying idempotent GitHub release operation in ${retry_delay_seconds}s (attempt $((attempt + 1))/${maximum_attempts})." - sleep "$retry_delay_seconds" - attempt=$((attempt + 1)) - done - } - - if gh release view "$tag" >/dev/null 2>"$release_query_error_file"; then - retry_idempotent gh release edit "$tag" --title "$tag" --notes-file release-notes.md - else - release_query_error="$(<"$release_query_error_file")" - if [[ "$release_query_error" != *"release not found"* && "$release_query_error" != *"HTTP 404"* ]]; then - printf '%s\n' "$release_query_error" >&2 - exit 1 - fi - retry_idempotent gh release create "$tag" --title "$tag" --notes-file release-notes.md --target "${GITHUB_SHA}" - fi - - retry_idempotent gh release upload "$tag" "$tarball" --clobber - retry_idempotent gh release upload "$tag" "${tarball}.sha256" --clobber + bun run scripts/release-publish.ts github-publish \ + --tag "$tag" \ + --commit "${GITHUB_SHA}" \ + --notes release-notes.md \ + --asset "$tarball" \ + --asset "${tarball}.sha256" diff --git a/docs/development.md b/docs/development.md index 46d7070a..43b16a8c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -155,3 +155,13 @@ Release tags use `v`. Blocking release checks include the normal repository gate, package smoke, packed live OpenCode smoke, package integrity generation, npm publication, and GitHub release assets. There is no cross-version active-session gate because v6 is an explicit hard cutover. + +Publication accepts both annotated and lightweight tags, but the freshly fetched +tag, workflow event, checkout, and current remote `main` tip must identify the +same commit immediately before npm publication. Network calls have explicit +deadlines. npm publication reconciles the immutable package integrity after every +result, including timeouts. GitHub publication first builds an exact draft under +the same ref proof. That draft is the recovery marker if npm succeeds and `main` +then advances. Finalization rechecks the remote tag, refuses conflicting metadata +or assets, and publishes only after every asset digest matches. Reruns converge +after partial success without replacing published bytes. diff --git a/scripts/release-monitor.mjs b/scripts/release-monitor.mjs index edb3a4c3..64d6179d 100644 --- a/scripts/release-monitor.mjs +++ b/scripts/release-monitor.mjs @@ -41,6 +41,7 @@ function usage() { " --expect Comma-separated workflow names to require", " --repo GitHub repository passed to gh --repo", " --timeout Maximum wait time (default: 900)", + " --command-timeout Maximum one git or gh call may take (default: 30)", " --interval Poll interval (default: 15)", " --once Check once and exit without polling", " --json Print final JSON summary", @@ -63,6 +64,7 @@ function parseArgs(argv) { expect: null, repo: null, timeoutMs: 900_000, + commandTimeoutMs: 30_000, intervalMs: 15_000, once: false, json: false, @@ -98,6 +100,10 @@ function parseArgs(argv) { options.intervalMs = secondsToMs(readFlag(argv, index, arg), arg); index += 1; break; + case "--command-timeout": + options.commandTimeoutMs = secondsToMs(readFlag(argv, index, arg), arg); + index += 1; + break; case "--once": options.once = true; break; @@ -112,6 +118,9 @@ function parseArgs(argv) { throw new Error(`Unknown option: ${arg}`); } } + if (options.commandTimeoutMs > options.timeoutMs) { + throw new Error("--command-timeout cannot exceed --timeout."); + } return { ...options, expectedWorkflows: @@ -127,21 +136,24 @@ function secondsToMs(value, flag) { return seconds * 1000; } -function run(command, args) { +function run(command, args, timeoutMs) { const result = spawnSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], + timeout: timeoutMs, }); - if (result.status !== 0) { - throw new Error( - `${command} ${args.join(" ")} failed: ${result.stderr || result.stdout}`, - ); + if (result.error || result.status !== 0) { + const errorDetail = + result.error?.code === "ETIMEDOUT" + ? "timed out" + : result.error?.message || result.stderr || result.stdout; + throw new Error(`${command} ${args.join(" ")} failed: ${errorDetail}`); } return result.stdout.trim(); } -function resolveCommit(commit) { - return commit ?? run("git", ["rev-parse", "HEAD"]); +function resolveCommit(commit, commandTimeoutMs) { + return commit ?? run("git", ["rev-parse", "HEAD"], commandTimeoutMs); } function workflowName(run) { @@ -180,7 +192,7 @@ function listRuns(options) { JSON_FIELDS, ]; if (options.repo) args.push("--repo", options.repo); - return JSON.parse(run("gh", args)); + return JSON.parse(run("gh", args, options.commandTimeoutMs)); } function evaluate(runs, options) { @@ -193,7 +205,7 @@ function evaluate(runs, options) { const missing = required .filter((entry) => entry.run === null) .map((entry) => entry.name); - const failed = runsForCommit.filter( + const failed = [...byWorkflow.values()].filter( (run) => run.status === "completed" && FAILURE_CONCLUSIONS.has(run.conclusion), ); @@ -263,7 +275,7 @@ async function main(argv) { process.stdout.write(`${usage()}\n`); return; } - options.commit = resolveCommit(options.commit); + options.commit = resolveCommit(options.commit, options.commandTimeoutMs); const started = Date.now(); let lastSummary = null; while (Date.now() - started <= options.timeoutMs) { @@ -275,7 +287,9 @@ async function main(argv) { if (!options.json) { process.stderr.write(`${formatSummary(lastSummary)}\n\n`); } - await delay(options.intervalMs); + const remainingMs = options.timeoutMs - (Date.now() - started); + if (remainingMs <= 0) break; + await delay(Math.min(options.intervalMs, remainingMs)); } finish(lastSummary, options.json); } diff --git a/scripts/release-publish.ts b/scripts/release-publish.ts new file mode 100644 index 00000000..9d26d974 --- /dev/null +++ b/scripts/release-publish.ts @@ -0,0 +1,792 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { basename } from "node:path"; + +const LOCAL_COMMAND_TIMEOUT_MS = 10_000; +const REMOTE_COMMAND_TIMEOUT_MS = 45_000; +const NPM_PUBLISH_TIMEOUT_MS = 180_000; +const REQUEST_TIMEOUT_MS = 30_000; +const OBSERVATION_ATTEMPTS = 4; +const MUTATION_ATTEMPTS = 3; +const RETRY_DELAY_MS = 5_000; +const MAX_COMMAND_OUTPUT_BYTES = 1_000_000; +const MAX_RELEASE_PAGES = 10; + +export type CommandResult = { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; + readonly timedOut: boolean; +}; + +type Fetch = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +export type PublicationRuntime = { + readonly fetch: Fetch; + readonly run: ( + command: string, + args: readonly string[], + timeoutMs: number, + ) => Promise; + readonly sleep: (milliseconds: number) => Promise; +}; + +export type ReleaseRefEvidence = { + readonly expectedTag: string; + readonly eventTag: string; + readonly eventSha: string; + readonly headSha: string; + readonly localTagObjectSha: string; + readonly localTagCommitSha: string; + readonly remoteTagObjectSha: string; + readonly remoteTagCommitSha: string; + readonly mainCommitSha: string; +}; + +type NpmPublicationInput = { + readonly packageName: string; + readonly packageVersion: string; + readonly artifactPath: string; + readonly beforePublish?: () => Promise; +}; + +type NpmState = + | { readonly state: "absent" } + | { readonly state: "unknown"; readonly reason: string } + | { readonly state: "exact"; readonly integrity: string } + | { readonly state: "conflict"; readonly integrity: string }; + +type GithubReleaseInput = { + readonly repository: string; + readonly token: string; + readonly tag: string; + readonly commitSha: string; + readonly notes: string; + readonly assets: readonly string[]; + readonly mode: "prepare" | "publish"; + readonly beforeMutation?: () => Promise; +}; + +type ReleaseAsset = { + readonly name: string; + readonly size: number; + readonly digest: string | null; +}; + +type Release = { + readonly id: number; + readonly tag_name: string; + readonly name: string | null; + readonly body: string | null; + readonly target_commitish: string; + readonly draft: boolean; + readonly prerelease: boolean; + readonly assets: readonly ReleaseAsset[]; +}; + +type DesiredAsset = { + readonly path: string; + readonly name: string; + readonly bytes: Uint8Array; + readonly digest: string; +}; + +function usage(): string { + return [ + "usage: bun run scripts/release-publish.ts [options]", + "", + "commands:", + " verify-ref --tag ", + " npm --artifact --tag ", + " github-prepare --tag --commit --notes --asset ...", + " github-publish --tag --commit --notes --asset ...", + ].join("\n"); +} + +function appendOutput(current: string, chunk: Buffer): string { + const remaining = MAX_COMMAND_OUTPUT_BYTES - Buffer.byteLength(current); + if (remaining <= 0) return current; + return current + chunk.subarray(0, remaining).toString("utf8"); +} + +async function runCommand( + command: string, + args: readonly string[], + timeoutMs: number, +): Promise { + return await new Promise((resolve, reject) => { + const child = spawn(command, [...args], { + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + let killTimer: ReturnType | undefined; + child.stdout.on("data", (chunk: Buffer) => { + stdout = appendOutput(stdout, chunk); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr = appendOutput(stderr, chunk); + }); + child.once("error", reject); + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + killTimer = setTimeout(() => child.kill("SIGKILL"), 5_000); + }, timeoutMs); + child.once("close", (code) => { + clearTimeout(timer); + if (killTimer) clearTimeout(killTimer); + resolve({ + exitCode: code ?? 1, + stdout, + stderr, + timedOut, + }); + }); + }); +} + +const defaultRuntime: PublicationRuntime = { + fetch: globalThis.fetch, + run: runCommand, + sleep: async (milliseconds) => { + await Bun.sleep(milliseconds); + }, +}; + +function sha256(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function sha512Integrity(bytes: Uint8Array): string { + return `sha512-${createHash("sha512").update(bytes).digest("base64")}`; +} + +function commandFailure( + command: string, + args: readonly string[], + result: CommandResult, +): Error { + const detail = result.timedOut + ? "timed out" + : result.stderr.trim() || result.stdout.trim() || "failed"; + return new Error(`${command} ${args.join(" ")} ${detail}`); +} + +async function checkedCommand( + runtime: PublicationRuntime, + command: string, + args: readonly string[], + timeoutMs: number, +): Promise { + const result = await runtime.run(command, args, timeoutMs); + if (result.exitCode !== 0 || result.timedOut) { + throw commandFailure(command, args, result); + } + return result.stdout.trim(); +} + +export function releaseTagIssue(evidence: ReleaseRefEvidence): string | null { + if (evidence.eventTag !== evidence.expectedTag) { + return `Release tag/version mismatch: expected ${evidence.expectedTag}, received ${evidence.eventTag}.`; + } + if (evidence.localTagObjectSha !== evidence.remoteTagObjectSha) { + return "The remote tag object no longer matches the checked-out release tag."; + } + if (evidence.localTagCommitSha !== evidence.remoteTagCommitSha) { + return "The remote tag commit no longer matches the checked-out release tag."; + } + if ( + evidence.eventSha !== evidence.localTagCommitSha || + evidence.headSha !== evidence.localTagCommitSha + ) { + return "The workflow event, checkout, and release tag do not identify one commit."; + } + return null; +} + +export function releaseRefIssue(evidence: ReleaseRefEvidence): string | null { + const tagIssue = releaseTagIssue(evidence); + if (tagIssue) return tagIssue; + if (evidence.mainCommitSha !== evidence.localTagCommitSha) { + return "The release tag is not the current origin/main commit."; + } + return null; +} + +async function revParse( + runtime: PublicationRuntime, + revision: string, +): Promise { + return await checkedCommand( + runtime, + "git", + ["rev-parse", "--verify", revision], + LOCAL_COMMAND_TIMEOUT_MS, + ); +} + +async function verifyReleaseRef( + tag: string, + runtime: PublicationRuntime, + requireCurrentMain = true, +): Promise { + const packageJson = JSON.parse(await readFile("package.json", "utf8")) as { + version?: unknown; + }; + if (typeof packageJson.version !== "string") { + throw new Error("package.json does not contain a release version."); + } + const expectedTag = `v${packageJson.version}`; + const eventTag = process.env.GITHUB_REF_NAME; + const eventSha = process.env.GITHUB_SHA; + if (process.env.GITHUB_REF_TYPE !== "tag" || !eventTag || !eventSha) { + throw new Error("Release ref verification requires a GitHub tag event."); + } + if (tag !== eventTag) { + throw new Error( + `Release tag argument ${tag} does not match event tag ${eventTag}.`, + ); + } + const runIdentity = `${process.env.GITHUB_RUN_ID ?? "local"}-${process.env.GITHUB_RUN_ATTEMPT ?? "1"}`; + const mainRef = `refs/flow-release/${runIdentity}/main`; + const tagRef = `refs/flow-release/${runIdentity}/tag`; + const fetchArgs = [ + "fetch", + "--force", + "--no-tags", + "origin", + `+refs/tags/${tag}:${tagRef}`, + ]; + if (requireCurrentMain) { + fetchArgs.splice(5, 0, `+refs/heads/main:${mainRef}`); + } + await checkedCommand(runtime, "git", fetchArgs, REMOTE_COMMAND_TIMEOUT_MS); + try { + const evidence: ReleaseRefEvidence = { + expectedTag, + eventTag, + eventSha, + headSha: await revParse(runtime, "HEAD^{commit}"), + localTagObjectSha: await revParse(runtime, `refs/tags/${tag}`), + localTagCommitSha: await revParse(runtime, `refs/tags/${tag}^{commit}`), + remoteTagObjectSha: await revParse(runtime, tagRef), + remoteTagCommitSha: await revParse(runtime, `${tagRef}^{commit}`), + mainCommitSha: requireCurrentMain + ? await revParse(runtime, `${mainRef}^{commit}`) + : "", + }; + const issue = requireCurrentMain + ? releaseRefIssue(evidence) + : releaseTagIssue(evidence); + if (issue) throw new Error(issue); + return evidence; + } finally { + for (const ref of requireCurrentMain ? [mainRef, tagRef] : [tagRef]) { + await runtime.run( + "git", + ["update-ref", "-d", ref], + LOCAL_COMMAND_TIMEOUT_MS, + ); + } + } +} + +async function fetchBounded( + runtime: PublicationRuntime, + input: string, + init: RequestInit = {}, +): Promise { + try { + return await runtime.fetch(input, { + ...init, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (error) { + throw new Error( + `Network request failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +async function observeNpm( + input: NpmPublicationInput, + localIntegrity: string, + runtime: PublicationRuntime, +): Promise { + const packagePath = input.packageName + .split("/") + .map((part) => encodeURIComponent(part)) + .join("%2F"); + let lastReason = "registry observation did not run"; + for (let attempt = 1; attempt <= OBSERVATION_ATTEMPTS; attempt += 1) { + try { + const response = await fetchBounded( + runtime, + `https://registry.npmjs.org/${packagePath}/${encodeURIComponent(input.packageVersion)}`, + { headers: { accept: "application/json" } }, + ); + if (response.status === 404) return { state: "absent" }; + if (!response.ok) { + lastReason = `registry returned HTTP ${response.status}`; + } else { + const body = (await response.json()) as { + dist?: { integrity?: unknown }; + }; + const integrity = body.dist?.integrity; + if (typeof integrity !== "string") { + lastReason = "registry response omitted dist.integrity"; + } else if (integrity === localIntegrity) { + return { state: "exact", integrity }; + } else { + return { state: "conflict", integrity }; + } + } + } catch (error) { + lastReason = error instanceof Error ? error.message : String(error); + } + if (attempt < OBSERVATION_ATTEMPTS) await runtime.sleep(RETRY_DELAY_MS); + } + return { state: "unknown", reason: lastReason }; +} + +function assertNpmState(state: NpmState): void { + if (state.state === "conflict") { + throw new Error( + `npm already contains this package version with different integrity: ${state.integrity}`, + ); + } + if (state.state === "unknown") { + throw new Error(`Release could not determine npm state: ${state.reason}`); + } +} + +export async function convergeNpmPublication( + input: NpmPublicationInput, + runtime: PublicationRuntime = defaultRuntime, +): Promise<{ readonly state: "exact"; readonly integrity: string }> { + const artifact = await readFile(input.artifactPath); + const localIntegrity = sha512Integrity(artifact); + const before = await observeNpm(input, localIntegrity, runtime); + assertNpmState(before); + if (before.state === "exact") return before; + + await input.beforePublish?.(); + const publish = await runtime.run( + "npm", + ["publish", input.artifactPath, "--access", "public"], + NPM_PUBLISH_TIMEOUT_MS, + ); + const after = await observeNpm(input, localIntegrity, runtime); + assertNpmState(after); + if (after.state === "exact") return after; + throw commandFailure("npm", ["publish", input.artifactPath], publish); +} + +function githubHeaders(token: string): Record { + return { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "x-github-api-version": "2026-03-10", + }; +} + +function githubApi(input: GithubReleaseInput, path: string): string { + return `https://api.github.com/repos/${input.repository}${path}`; +} + +async function observeGithubRelease( + input: GithubReleaseInput, + runtime: PublicationRuntime, +): Promise { + const matches: Release[] = []; + for (let page = 1; page <= MAX_RELEASE_PAGES; page += 1) { + let releases: Release[] | null = null; + let lastReason = "GitHub release observation did not run"; + for (let attempt = 1; attempt <= OBSERVATION_ATTEMPTS; attempt += 1) { + try { + const response = await fetchBounded( + runtime, + githubApi(input, `/releases?per_page=100&page=${page}`), + { headers: githubHeaders(input.token) }, + ); + if (response.ok) { + const body = (await response.json()) as unknown; + if (!Array.isArray(body)) { + throw new Error("GitHub release listing was not an array."); + } + releases = body as Release[]; + break; + } + lastReason = `GitHub returned HTTP ${response.status}`; + } catch (error) { + lastReason = error instanceof Error ? error.message : String(error); + } + if (attempt < OBSERVATION_ATTEMPTS) await runtime.sleep(RETRY_DELAY_MS); + } + if (!releases) { + throw new Error( + `Release could not determine GitHub state: ${lastReason}`, + ); + } + matches.push( + ...releases.filter((release) => release.tag_name === input.tag), + ); + if (releases.length < 100) break; + if (page === MAX_RELEASE_PAGES) { + throw new Error( + "GitHub release listing exceeded the bounded page limit.", + ); + } + } + if (matches.length > 1) { + throw new Error(`GitHub contains multiple releases for tag ${input.tag}.`); + } + return matches[0] ?? null; +} + +function assertReleaseIdentity( + release: Release, + input: GithubReleaseInput, +): void { + if ( + release.tag_name !== input.tag || + release.name !== input.tag || + release.body !== input.notes || + release.target_commitish !== input.commitSha || + release.prerelease + ) { + throw new Error( + "Existing GitHub release metadata conflicts with this release.", + ); + } +} + +async function desiredAssets( + paths: readonly string[], +): Promise { + const assets = await Promise.all( + paths.map(async (path) => { + const bytes = await readFile(path); + return { + path, + name: basename(path), + bytes, + digest: `sha256:${sha256(bytes)}`, + }; + }), + ); + if (new Set(assets.map((asset) => asset.name)).size !== assets.length) { + throw new Error("GitHub release assets must have unique names."); + } + return assets; +} + +function assertNoUnexpectedAssets( + release: Release, + desired: readonly DesiredAsset[], +): void { + const desiredNames = new Set(desired.map((asset) => asset.name)); + const actualNames = release.assets.map((asset) => asset.name); + if (new Set(actualNames).size !== actualNames.length) { + throw new Error("GitHub release contains duplicate asset names."); + } + const unexpected = actualNames.filter((name) => !desiredNames.has(name)); + if (unexpected.length > 0) { + throw new Error( + `GitHub release contains unexpected asset ${unexpected[0]}.`, + ); + } +} + +function assetIssue( + release: Release, + desired: DesiredAsset, +): "missing" | "pending" | null { + const matches = release.assets.filter((asset) => asset.name === desired.name); + if (matches.length === 0) return "missing"; + if (matches.length !== 1) { + throw new Error(`GitHub release has duplicate asset ${desired.name}.`); + } + const actual = matches[0]; + if (actual?.size !== desired.bytes.byteLength) { + throw new Error(`Found conflicting GitHub release asset ${desired.name}.`); + } + if (actual.digest === null) return "pending"; + if (actual.digest !== desired.digest) { + throw new Error(`Found conflicting GitHub release asset ${desired.name}.`); + } + return null; +} + +async function createDraft( + input: GithubReleaseInput, + runtime: PublicationRuntime, + beforeMutation: () => Promise, +): Promise { + for (let attempt = 1; attempt <= MUTATION_ATTEMPTS; attempt += 1) { + await beforeMutation(); + await fetchBounded(runtime, githubApi(input, "/releases"), { + method: "POST", + headers: { + ...githubHeaders(input.token), + "content-type": "application/json", + }, + body: JSON.stringify({ + tag_name: input.tag, + target_commitish: input.commitSha, + name: input.tag, + body: input.notes, + draft: true, + }), + }).catch(() => null); + const observed = await observeGithubRelease(input, runtime); + if (observed) return observed; + if (attempt < MUTATION_ATTEMPTS) await runtime.sleep(RETRY_DELAY_MS); + } + throw new Error("GitHub release draft did not become observable."); +} + +async function uploadAsset( + input: GithubReleaseInput, + release: Release, + asset: DesiredAsset, + runtime: PublicationRuntime, + beforeMutation: () => Promise, +): Promise { + for (let attempt = 1; attempt <= MUTATION_ATTEMPTS; attempt += 1) { + if (assetIssue(release, asset) === "missing") { + await beforeMutation(); + const url = `https://uploads.github.com/repos/${input.repository}/releases/${release.id}/assets?name=${encodeURIComponent(asset.name)}`; + await fetchBounded(runtime, url, { + method: "POST", + headers: { + ...githubHeaders(input.token), + "content-type": "application/octet-stream", + }, + body: asset.bytes, + }).catch(() => null); + } + const observed = await observeGithubRelease(input, runtime); + if (!observed) { + throw new Error("GitHub release disappeared while uploading assets."); + } + assertReleaseIdentity(observed, input); + if (assetIssue(observed, asset) === null) return observed; + if (attempt < MUTATION_ATTEMPTS) await runtime.sleep(RETRY_DELAY_MS); + } + throw new Error( + `GitHub release asset ${asset.name} did not become observable.`, + ); +} + +async function publishDraft( + input: GithubReleaseInput, + release: Release, + runtime: PublicationRuntime, + beforeMutation: () => Promise, +): Promise { + let current = release; + for (let attempt = 1; attempt <= MUTATION_ATTEMPTS; attempt += 1) { + if (current.draft) { + await beforeMutation(); + await fetchBounded(runtime, githubApi(input, `/releases/${release.id}`), { + method: "PATCH", + headers: { + ...githubHeaders(input.token), + "content-type": "application/json", + }, + body: JSON.stringify({ draft: false }), + }).catch(() => null); + } + const observed = await observeGithubRelease(input, runtime); + if (!observed) + throw new Error("GitHub release disappeared while publishing."); + assertReleaseIdentity(observed, input); + if (!observed.draft) return observed; + current = observed; + if (attempt < MUTATION_ATTEMPTS) await runtime.sleep(RETRY_DELAY_MS); + } + throw new Error("GitHub release did not become published."); +} + +export async function convergeGithubRelease( + input: GithubReleaseInput, + runtime: PublicationRuntime = defaultRuntime, +): Promise<{ + readonly state: "prepared" | "exact"; + readonly releaseId: number; +}> { + const assets = await desiredAssets(input.assets); + const beforeMutation = input.beforeMutation ?? (async () => {}); + if (input.mode === "publish") await beforeMutation(); + let release = await observeGithubRelease(input, runtime); + if (!release) { + if (input.mode === "publish") { + throw new Error("GitHub release draft proof is missing."); + } + release = await createDraft(input, runtime, beforeMutation); + } + assertReleaseIdentity(release, input); + assertNoUnexpectedAssets(release, assets); + if (input.mode === "prepare" && !release.draft) { + for (const asset of assets) assetIssue(release, asset); + return { state: "prepared", releaseId: release.id }; + } + for (const asset of assets) { + if (assetIssue(release, asset) === null) continue; + release = await uploadAsset(input, release, asset, runtime, beforeMutation); + assertNoUnexpectedAssets(release, assets); + } + if (input.mode === "prepare") { + return { + state: "prepared", + releaseId: release.id, + }; + } + if (release.draft) { + release = await publishDraft(input, release, runtime, beforeMutation); + } + assertReleaseIdentity(release, input); + assertNoUnexpectedAssets(release, assets); + for (const asset of assets) { + if (assetIssue(release, asset) === null) continue; + release = await uploadAsset(input, release, asset, runtime, beforeMutation); + } + assertNoUnexpectedAssets(release, assets); + for (const asset of assets) { + if (assetIssue(release, asset) !== null) { + throw new Error(`GitHub release asset ${asset.name} is not exact.`); + } + } + return { state: "exact", releaseId: release.id }; +} + +function valuesFor(argv: readonly string[], flag: string): string[] { + const values: string[] = []; + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] !== flag) continue; + const value = argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${flag} requires a value.`); + } + values.push(value); + } + return values; +} + +function rejectUnknownOptions( + argv: readonly string[], + allowed: ReadonlySet, +): void { + for (let index = 0; index < argv.length; index += 2) { + const flag = argv[index]; + if (!flag || !allowed.has(flag)) { + throw new Error( + `Unknown release publication option: ${flag ?? "(missing)"}`, + ); + } + if (!argv[index + 1] || argv[index + 1]?.startsWith("--")) { + throw new Error(`${flag} requires a value.`); + } + } +} + +function valueFor(argv: readonly string[], flag: string): string { + const values = valuesFor(argv, flag); + if (values.length !== 1) + throw new Error(`${flag} must be provided exactly once.`); + return values[0] as string; +} + +async function main(argv: readonly string[]): Promise { + const [command, ...options] = argv; + if (!command || command === "--help" || command === "-h") { + process.stdout.write(`${usage()}\n`); + return; + } + if (command === "verify-ref") { + rejectUnknownOptions(options, new Set(["--tag"])); + const evidence = await verifyReleaseRef( + valueFor(options, "--tag"), + defaultRuntime, + ); + process.stdout.write( + `Release ref verified at current origin/main commit ${evidence.mainCommitSha}.\n`, + ); + return; + } + if (command === "npm") { + rejectUnknownOptions(options, new Set(["--artifact", "--tag"])); + const packageJson = JSON.parse(await readFile("package.json", "utf8")) as { + name?: unknown; + version?: unknown; + }; + if ( + typeof packageJson.name !== "string" || + typeof packageJson.version !== "string" + ) { + throw new Error( + "package.json does not contain a package name and version.", + ); + } + const result = await convergeNpmPublication({ + packageName: packageJson.name, + packageVersion: packageJson.version, + artifactPath: valueFor(options, "--artifact"), + beforePublish: async () => { + await verifyReleaseRef(valueFor(options, "--tag"), defaultRuntime); + }, + }); + process.stdout.write(`npm publication verified at ${result.integrity}.\n`); + return; + } + if (command === "github-prepare" || command === "github-publish") { + rejectUnknownOptions( + options, + new Set(["--tag", "--commit", "--notes", "--asset"]), + ); + const token = process.env.GH_TOKEN; + const repository = process.env.GITHUB_REPOSITORY; + if (!token || !repository) { + throw new Error( + "GitHub publication requires GH_TOKEN and GITHUB_REPOSITORY.", + ); + } + const result = await convergeGithubRelease({ + repository, + token, + tag: valueFor(options, "--tag"), + commitSha: valueFor(options, "--commit"), + notes: await readFile(valueFor(options, "--notes"), "utf8"), + assets: valuesFor(options, "--asset"), + mode: command === "github-prepare" ? "prepare" : "publish", + beforeMutation: async () => { + await verifyReleaseRef( + valueFor(options, "--tag"), + defaultRuntime, + command === "github-prepare", + ); + }, + }); + process.stdout.write( + `GitHub release publication verified at release ${result.releaseId}.\n`, + ); + return; + } + throw new Error(`Unknown release publication command: ${command}`); +} + +if (import.meta.main) { + main(process.argv.slice(2)).catch((error) => { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; + }); +} diff --git a/tests/documentation-contract.test.ts b/tests/documentation-contract.test.ts index b60a9868..ff554605 100644 --- a/tests/documentation-contract.test.ts +++ b/tests/documentation-contract.test.ts @@ -502,18 +502,27 @@ describe("Flow documentation contract", () => { expect(combined).toContain("bun run check"); expect(combined).toContain("bun run smoke:live"); expect(combined).toContain("tests/workspace-persistence.test.ts"); - expect(combined).toContain("npm publish"); + expect(combined).toContain("scripts/release-publish.ts npm"); const release = await readFile(".github/workflows/release.yml", "utf8"); expect(release).toMatch(/^ {2}push:\n {4}branches:/m); expect(release).toContain("tags:"); expect(release).toMatch(/tag="v\$\{version\}"/); - expect(release).toMatch(/--target "\$\{GITHUB_SHA\}"/); + expect(release).toMatch(/--commit "\$\{GITHUB_SHA\}"/); expect(release).toContain( "Verify independently regraded qualification bundle and fresh canary", ); expect(release).toContain("bun run eval:canary -- verify"); expect(release).toContain("--mode dry-run"); expect(release).toMatch(/evals\/canary\/\$\{version\}\.json/); + expect(release.match(/^ {4}timeout-minutes:/gm)).toHaveLength(3); + expect(release).toContain( + "bun run scripts/release-publish.ts github-prepare", + ); + expect(release).toContain("bun run scripts/release-publish.ts npm"); + expect(release).toContain( + "bun run scripts/release-publish.ts github-publish", + ); + expect(release).not.toContain("--clobber"); expect(release).not.toContain("canary-not-enabled"); // Model-driven evals need credentials and cost real money, so they run on a diff --git a/tests/release-monitor.test.ts b/tests/release-monitor.test.ts index a60c4c9a..8bec4f13 100644 --- a/tests/release-monitor.test.ts +++ b/tests/release-monitor.test.ts @@ -1,15 +1,23 @@ import { describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; const COMMIT = "0123456789abcdef0123456789abcdef01234567"; -function runMonitor(args: string[], runs?: unknown[]) { +function runMonitor( + args: string[], + runs?: unknown[], + environment: Record = {}, +) { return spawnSync("node", ["scripts/release-monitor.mjs", ...args], { cwd: process.cwd(), encoding: "utf8", env: { ...process.env, ...(runs ? { FLOW_RELEASE_MONITOR_RUNS_JSON: JSON.stringify(runs) } : {}), + ...environment, }, }); } @@ -38,6 +46,37 @@ describe("release monitor", () => { expect(result.status).toBe(0); expect(result.stdout).toContain("release-monitor.mjs"); expect(result.stdout).toContain("--commit"); + expect(result.stdout).toContain("--command-timeout"); + }); + + test("rejects a command timeout longer than the overall deadline", () => { + const result = runMonitor([ + "--timeout", + "1", + "--command-timeout", + "2", + "--once", + ]); + expect(result.status).toBe(2); + expect(result.stderr).toContain( + "--command-timeout cannot exceed --timeout", + ); + }); + + test("terminates a hung command within its command deadline", () => { + const fakeBin = mkdtempSync(join(tmpdir(), "flow-monitor-bin-")); + const fakeGit = join(fakeBin, "git"); + writeFileSync(fakeGit, "#!/bin/sh\nsleep 5\n"); + chmodSync(fakeGit, 0o755); + const started = Date.now(); + const result = runMonitor( + ["--timeout", "1", "--command-timeout", "0.05", "--once"], + undefined, + { PATH: `${fakeBin}:${process.env.PATH ?? ""}` }, + ); + expect(result.status).toBe(2); + expect(result.stderr).toContain("timed out"); + expect(Date.now() - started).toBeLessThan(1_000); }); test("succeeds when expected CI and release workflows passed", () => { @@ -79,4 +118,18 @@ describe("release monitor", () => { summary.failed.map((run: { workflowName: string }) => run.workflowName), ).toContain("Release"); }); + + test("uses the latest rerun instead of a superseded failure", () => { + const failed = workflowRun("CI", "completed", "failure"); + const passed = { + ...workflowRun("CI", "completed", "success"), + updatedAt: "2026-07-08T10:06:00Z", + }; + const result = runMonitor( + ["--commit", COMMIT, "--expect", "CI", "--once", "--json"], + [failed, passed], + ); + expect(result.status).toBe(0); + expect(JSON.parse(result.stdout).success).toBe(true); + }); }); diff --git a/tests/release-publish.test.ts b/tests/release-publish.test.ts new file mode 100644 index 00000000..ab3e3188 --- /dev/null +++ b/tests/release-publish.test.ts @@ -0,0 +1,757 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { + type CommandResult, + convergeGithubRelease, + convergeNpmPublication, + type PublicationRuntime, + type ReleaseRefEvidence, + releaseRefIssue, +} from "../scripts/release-publish.js"; + +const COMMIT = "0123456789abcdef0123456789abcdef01234567"; +const TAG_OBJECT = "89abcdef0123456789abcdef0123456789abcdef"; + +function refEvidence( + overrides: Partial = {}, +): ReleaseRefEvidence { + return { + expectedTag: "v1.2.3", + eventTag: "v1.2.3", + eventSha: COMMIT, + headSha: COMMIT, + localTagObjectSha: TAG_OBJECT, + localTagCommitSha: COMMIT, + remoteTagObjectSha: TAG_OBJECT, + remoteTagCommitSha: COMMIT, + mainCommitSha: COMMIT, + ...overrides, + }; +} + +function result(exitCode: number, stdout = "", stderr = ""): CommandResult { + return { exitCode, stdout, stderr, timedOut: false }; +} + +async function artifactFixture() { + const root = await mkdtemp(join(tmpdir(), "flow-release-publish-")); + const artifactPath = join(root, "package.tgz"); + await writeFile(artifactPath, "exact package bytes"); + const bytes = await readFile(artifactPath); + return { + artifactPath, + integrity: `sha512-${createHash("sha512").update(bytes).digest("base64")}`, + sha256: createHash("sha256").update(bytes).digest("hex"), + }; +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(body === null ? null : JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("release ref proof", () => { + test("documents the required tag on the npm command", () => { + const help = spawnSync( + "bun", + ["run", "scripts/release-publish.ts", "--help"], + { cwd: process.cwd(), encoding: "utf8" }, + ); + expect(help.status).toBe(0); + expect(help.stdout).toContain("npm --artifact --tag "); + }); + + test("accepts an annotated tag at the exact event and main commit", () => { + expect(releaseRefIssue(refEvidence())).toBeNull(); + }); + + test("accepts a lightweight tag at the exact event and main commit", () => { + expect( + releaseRefIssue( + refEvidence({ + localTagObjectSha: COMMIT, + remoteTagObjectSha: COMMIT, + }), + ), + ).toBeNull(); + }); + + test("rejects a stale tag even when it is an ancestor of main", () => { + expect( + releaseRefIssue( + refEvidence({ + mainCommitSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }), + ), + ).toContain("current origin/main"); + }); + + test("rejects moved remote tags and mismatched workflow refs", () => { + expect( + releaseRefIssue( + refEvidence({ + remoteTagObjectSha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }), + ), + ).toContain("remote tag"); + expect(releaseRefIssue(refEvidence({ eventTag: "v1.2.2" }))).toContain( + "tag/version", + ); + }); +}); + +describe("npm publication reconciliation", () => { + test("skips an exact immutable package and refuses a conflict", async () => { + const fixture = await artifactFixture(); + let publishes = 0; + const exactRuntime: PublicationRuntime = { + fetch: async () => + jsonResponse(200, { dist: { integrity: fixture.integrity } }), + run: async () => { + publishes += 1; + return result(0); + }, + sleep: async () => {}, + }; + expect( + await convergeNpmPublication( + { + packageName: "opencode-plugin-flow", + packageVersion: "1.2.3", + artifactPath: fixture.artifactPath, + }, + exactRuntime, + ), + ).toEqual({ state: "exact", integrity: fixture.integrity }); + expect(publishes).toBe(0); + + const conflictRuntime = { + ...exactRuntime, + fetch: async () => + jsonResponse(200, { dist: { integrity: "sha512-conflict" } }), + }; + await expect( + convergeNpmPublication( + { + packageName: "opencode-plugin-flow", + packageVersion: "1.2.3", + artifactPath: fixture.artifactPath, + }, + conflictRuntime, + ), + ).rejects.toThrow("different integrity"); + }); + + test("reconciles an ambiguous publish only after exact registry evidence", async () => { + const fixture = await artifactFixture(); + let observations = 0; + let publishes = 0; + let refChecks = 0; + const runtime: PublicationRuntime = { + fetch: async () => { + observations += 1; + return observations === 1 + ? jsonResponse(404, { error: "not_found" }) + : jsonResponse(200, { dist: { integrity: fixture.integrity } }); + }, + run: async () => { + publishes += 1; + return { ...result(1, "", "connection closed"), timedOut: true }; + }, + sleep: async () => {}, + }; + expect( + await convergeNpmPublication( + { + packageName: "opencode-plugin-flow", + packageVersion: "1.2.3", + artifactPath: fixture.artifactPath, + beforePublish: async () => { + refChecks += 1; + }, + }, + runtime, + ), + ).toEqual({ state: "exact", integrity: fixture.integrity }); + expect(publishes).toBe(1); + expect(refChecks).toBe(1); + }); + + test("never treats a registry failure as package absence", async () => { + const fixture = await artifactFixture(); + let publishes = 0; + const runtime: PublicationRuntime = { + fetch: async () => jsonResponse(503, { error: "unavailable" }), + run: async () => { + publishes += 1; + return result(0); + }, + sleep: async () => {}, + }; + await expect( + convergeNpmPublication( + { + packageName: "opencode-plugin-flow", + packageVersion: "1.2.3", + artifactPath: fixture.artifactPath, + }, + runtime, + ), + ).rejects.toThrow("could not determine npm state"); + expect(publishes).toBe(0); + }); +}); + +describe("GitHub release reconciliation", () => { + test("reconciles an ambiguous draft creation before publishing", async () => { + let release: + | { + id: number; + tag_name: string; + name: string; + body: string; + target_commitish: string; + draft: boolean; + prerelease: boolean; + assets: never[]; + } + | undefined; + let mutations = 0; + let mainProofs = 0; + let tagProofs = 0; + const runtime: PublicationRuntime = { + fetch: async (_url, init) => { + const method = init?.method ?? "GET"; + if (method === "GET") { + return jsonResponse(200, release ? [release] : []); + } + mutations += 1; + if (method === "POST") { + release = { + id: 8, + tag_name: "v1.2.3", + name: "v1.2.3", + body: "exact notes\n", + target_commitish: COMMIT, + draft: true, + prerelease: false, + assets: [], + }; + throw new Error("connection closed after create"); + } + if (!release) throw new Error("Expected a draft before publishing."); + release = { ...release, draft: false }; + return jsonResponse(200, release); + }, + run: async () => result(0), + sleep: async () => {}, + }; + expect( + await convergeGithubRelease( + { + repository: "owner/repo", + token: "token", + tag: "v1.2.3", + commitSha: COMMIT, + notes: "exact notes\n", + assets: [], + mode: "prepare", + beforeMutation: async () => { + mainProofs += 1; + }, + }, + runtime, + ), + ).toEqual({ state: "prepared", releaseId: 8 }); + expect(mutations).toBe(1); + expect(mainProofs).toBe(1); + await convergeGithubRelease( + { + repository: "owner/repo", + token: "token", + tag: "v1.2.3", + commitSha: COMMIT, + notes: "exact notes\n", + assets: [], + mode: "prepare", + beforeMutation: async () => { + throw new Error("current main advanced after the exact draft"); + }, + }, + runtime, + ); + expect(mutations).toBe(1); + expect( + await convergeGithubRelease( + { + repository: "owner/repo", + token: "token", + tag: "v1.2.3", + commitSha: COMMIT, + notes: "exact notes\n", + assets: [], + mode: "publish", + beforeMutation: async () => { + tagProofs += 1; + }, + }, + runtime, + ), + ).toEqual({ state: "exact", releaseId: 8 }); + expect(mutations).toBe(2); + expect(tagProofs).toBe(2); + }); + + test("recovers a partial exact draft without clobbering assets", async () => { + const fixture = await artifactFixture(); + const checksumPath = `${fixture.artifactPath}.sha256`; + await writeFile( + checksumPath, + `${fixture.sha256} ${basename(fixture.artifactPath)}\n`, + ); + const checksumBytes = await readFile(checksumPath); + const checksumDigest = createHash("sha256") + .update(checksumBytes) + .digest("hex"); + let release = { + id: 7, + tag_name: "v1.2.3", + name: "v1.2.3", + body: "exact notes\n", + target_commitish: COMMIT, + draft: true, + prerelease: false, + assets: [ + { + name: basename(fixture.artifactPath), + size: (await readFile(fixture.artifactPath)).byteLength, + digest: `sha256:${fixture.sha256}`, + }, + ], + }; + let uploads = 0; + let publishes = 0; + const runtime: PublicationRuntime = { + fetch: async (url, init) => { + const method = init?.method ?? "GET"; + if (method === "GET") return jsonResponse(200, [release]); + if (String(url).includes("/assets?")) { + uploads += 1; + release = { + ...release, + assets: [ + ...release.assets, + { + name: basename(checksumPath), + size: checksumBytes.byteLength, + digest: `sha256:${checksumDigest}`, + }, + ], + }; + return jsonResponse(201, release.assets[1]); + } + publishes += 1; + release = { ...release, draft: false }; + return jsonResponse(200, release); + }, + run: async () => result(0), + sleep: async () => {}, + }; + await convergeGithubRelease( + { + repository: "owner/repo", + token: "token", + tag: "v1.2.3", + commitSha: COMMIT, + notes: "exact notes\n", + assets: [fixture.artifactPath, checksumPath], + mode: "publish", + }, + runtime, + ); + expect(uploads).toBe(1); + expect(publishes).toBe(1); + }); + + test("reconciles a stale observation after publishing the draft", async () => { + let patches = 0; + let readsAfterPatch = 0; + const draft = { + id: 12, + tag_name: "v1.2.3", + name: "v1.2.3", + body: "exact notes\n", + target_commitish: COMMIT, + draft: true, + prerelease: false, + assets: [], + }; + const runtime: PublicationRuntime = { + fetch: async (_url, init) => { + if ((init?.method ?? "GET") === "PATCH") { + patches += 1; + return jsonResponse(200, { ...draft, draft: false }); + } + if (patches > 0) readsAfterPatch += 1; + return jsonResponse(200, [ + readsAfterPatch >= 2 ? { ...draft, draft: false } : draft, + ]); + }, + run: async () => result(0), + sleep: async () => {}, + }; + await convergeGithubRelease( + { + repository: "owner/repo", + token: "token", + tag: "v1.2.3", + commitSha: COMMIT, + notes: "exact notes\n", + assets: [], + mode: "publish", + }, + runtime, + ); + expect(patches).toBe(2); + }); + + for (const postPublishState of ["missing", "pending"] as const) { + test(`reconciles an asset that is ${postPublishState} after draft publication`, async () => { + const fixture = await artifactFixture(); + const bytes = await readFile(fixture.artifactPath); + let phase: "draft" | "unsettled" | "settled" = "draft"; + let uploads = 0; + const release = ( + draft: boolean, + assetState: typeof postPublishState | "exact", + ) => ({ + id: 13, + tag_name: "v1.2.3", + name: "v1.2.3", + body: "exact notes\n", + target_commitish: COMMIT, + draft, + prerelease: false, + assets: + assetState === "missing" + ? [] + : [ + { + name: basename(fixture.artifactPath), + size: bytes.byteLength, + digest: + assetState === "pending" + ? null + : `sha256:${fixture.sha256}`, + }, + ], + }); + const runtime: PublicationRuntime = { + fetch: async (url, init) => { + const method = init?.method ?? "GET"; + if (method === "PATCH") { + phase = "unsettled"; + return jsonResponse(200, release(false, postPublishState)); + } + if (method === "POST" && String(url).includes("/assets?")) { + uploads += 1; + phase = "settled"; + return jsonResponse(201, release(false, "exact").assets[0]); + } + if (phase === "draft") + return jsonResponse(200, [release(true, "exact")]); + if (phase === "unsettled") { + const observed = release(false, postPublishState); + if (postPublishState === "pending") phase = "settled"; + return jsonResponse(200, [observed]); + } + return jsonResponse(200, [release(false, "exact")]); + }, + run: async () => result(0), + sleep: async () => {}, + }; + await convergeGithubRelease( + { + repository: "owner/repo", + token: "token", + tag: "v1.2.3", + commitSha: COMMIT, + notes: "exact notes\n", + assets: [fixture.artifactPath], + mode: "publish", + }, + runtime, + ); + expect(uploads).toBe(postPublishState === "missing" ? 1 : 0); + }); + } + + test("refuses conflicting release assets without uploading or clobbering", async () => { + const fixture = await artifactFixture(); + let mutations = 0; + const runtime: PublicationRuntime = { + fetch: async (_url, init) => { + if ((init?.method ?? "GET") !== "GET") mutations += 1; + return jsonResponse(200, [ + { + id: 7, + tag_name: "v1.2.3", + name: "v1.2.3", + body: "exact notes\n", + target_commitish: COMMIT, + draft: true, + prerelease: false, + assets: [ + { + name: basename(fixture.artifactPath), + size: 1, + digest: "sha256:conflict", + }, + ], + }, + ]); + }, + run: async () => result(0), + sleep: async () => {}, + }; + await expect( + convergeGithubRelease( + { + repository: "owner/repo", + token: "token", + tag: "v1.2.3", + commitSha: COMMIT, + notes: "exact notes\n", + assets: [fixture.artifactPath], + mode: "prepare", + }, + runtime, + ), + ).rejects.toThrow("conflicting GitHub release asset"); + expect(mutations).toBe(0); + }); + + test("recovers a published release that is missing an exact asset", async () => { + const fixture = await artifactFixture(); + let release = { + id: 9, + tag_name: "v1.2.3", + name: "v1.2.3", + body: "exact notes\n", + target_commitish: COMMIT, + draft: false, + prerelease: false, + assets: [] as Array<{ name: string; size: number; digest: string }>, + }; + let mutations = 0; + let refChecks = 0; + const runtime: PublicationRuntime = { + fetch: async (url, init) => { + if ((init?.method ?? "GET") === "GET") { + return jsonResponse(200, [release]); + } + mutations += 1; + if (!String(url).includes("/assets?")) { + throw new Error( + "Published recovery must only upload the missing asset.", + ); + } + const bytes = await readFile(fixture.artifactPath); + release = { + ...release, + assets: [ + { + name: basename(fixture.artifactPath), + size: bytes.byteLength, + digest: `sha256:${fixture.sha256}`, + }, + ], + }; + return jsonResponse(201, release.assets[0]); + }, + run: async () => result(0), + sleep: async () => {}, + }; + expect( + await convergeGithubRelease( + { + repository: "owner/repo", + token: "token", + tag: "v1.2.3", + commitSha: COMMIT, + notes: "exact notes\n", + assets: [fixture.artifactPath], + mode: "prepare", + beforeMutation: async () => { + throw new Error("main advanced after npm publication"); + }, + }, + runtime, + ), + ).toEqual({ state: "prepared", releaseId: 9 }); + expect(mutations).toBe(0); + const npmRuntime: PublicationRuntime = { + fetch: async () => + jsonResponse(200, { dist: { integrity: fixture.integrity } }), + run: async () => { + throw new Error("Exact npm state must skip publication."); + }, + sleep: async () => {}, + }; + await convergeNpmPublication( + { + packageName: "opencode-plugin-flow", + packageVersion: "1.2.3", + artifactPath: fixture.artifactPath, + beforePublish: async () => { + throw new Error("Exact npm state must skip the current-main proof."); + }, + }, + npmRuntime, + ); + await convergeGithubRelease( + { + repository: "owner/repo", + token: "token", + tag: "v1.2.3", + commitSha: COMMIT, + notes: "exact notes\n", + assets: [fixture.artifactPath], + mode: "publish", + beforeMutation: async () => { + refChecks += 1; + }, + }, + runtime, + ); + expect(mutations).toBe(1); + expect(refChecks).toBe(2); + }); + + test("waits for a pending digest without uploading the asset again", async () => { + const fixture = await artifactFixture(); + const bytes = await readFile(fixture.artifactPath); + let observations = 0; + let mutations = 0; + const runtime: PublicationRuntime = { + fetch: async (_url, init) => { + if ((init?.method ?? "GET") !== "GET") mutations += 1; + observations += 1; + return jsonResponse(200, [ + { + id: 10, + tag_name: "v1.2.3", + name: "v1.2.3", + body: "exact notes\n", + target_commitish: COMMIT, + draft: true, + prerelease: false, + assets: [ + { + name: basename(fixture.artifactPath), + size: bytes.byteLength, + digest: observations === 1 ? null : `sha256:${fixture.sha256}`, + }, + ], + }, + ]); + }, + run: async () => result(0), + sleep: async () => {}, + }; + await convergeGithubRelease( + { + repository: "owner/repo", + token: "token", + tag: "v1.2.3", + commitSha: COMMIT, + notes: "exact notes\n", + assets: [fixture.artifactPath], + mode: "prepare", + }, + runtime, + ); + expect(mutations).toBe(0); + expect(observations).toBe(2); + }); + + test("refuses unexpected assets and prerelease metadata", async () => { + const fixture = await artifactFixture(); + const input = { + repository: "owner/repo", + token: "token", + tag: "v1.2.3", + commitSha: COMMIT, + notes: "exact notes\n", + assets: [fixture.artifactPath], + mode: "prepare" as const, + }; + const release = { + id: 11, + tag_name: "v1.2.3", + name: "v1.2.3", + body: "exact notes\n", + target_commitish: COMMIT, + draft: true, + prerelease: false, + assets: [{ name: "unexpected.txt", size: 1, digest: "sha256:conflict" }], + }; + const runtime = (value: typeof release): PublicationRuntime => ({ + fetch: async () => jsonResponse(200, [value]), + run: async () => result(0), + sleep: async () => {}, + }); + await expect( + convergeGithubRelease(input, runtime(release)), + ).rejects.toThrow("unexpected asset"); + await expect( + convergeGithubRelease( + input, + runtime({ ...release, prerelease: true, assets: [] }), + ), + ).rejects.toThrow("metadata conflicts"); + }); + + test("requires a remote tag proof even when the published release is exact", async () => { + const runtime: PublicationRuntime = { + fetch: async () => + jsonResponse(200, [ + { + id: 14, + tag_name: "v1.2.3", + name: "v1.2.3", + body: "exact notes\n", + target_commitish: COMMIT, + draft: false, + prerelease: false, + assets: [], + }, + ]), + run: async () => result(0), + sleep: async () => {}, + }; + await expect( + convergeGithubRelease( + { + repository: "owner/repo", + token: "token", + tag: "v1.2.3", + commitSha: COMMIT, + notes: "exact notes\n", + assets: [], + mode: "publish", + beforeMutation: async () => { + throw new Error("remote tag moved"); + }, + }, + runtime, + ), + ).rejects.toThrow("remote tag moved"); + }); +});