diff --git a/.github/actions/publish-mcp/action.yml b/.github/actions/publish-mcp/action.yml new file mode 100644 index 0000000000..4655b7aa07 --- /dev/null +++ b/.github/actions/publish-mcp/action.yml @@ -0,0 +1,41 @@ +name: Publish MCP Registry metadata +description: Validate and publish one released server.json, preserving an identical existing version +inputs: + directory: + description: Directory containing the released server.json + default: . +runs: + using: composite + steps: + - name: Install MCP Registry publisher + shell: bash + working-directory: ${{ inputs.directory }} + env: + MCP_PUBLISHER_VERSION: v1.8.0 + MCP_PUBLISHER_SHA256_LINUX_AMD64: 1370446bbe74d562608e8005a6ccce02d146a661fbd78674e11cc70b9618d6cf + run: | + archive="mcp-publisher_linux_amd64.tar.gz" + curl -fsSLo "${archive}" \ + "https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/${archive}" + echo "${MCP_PUBLISHER_SHA256_LINUX_AMD64} ${archive}" | sha256sum --check --strict + tar xzf "${archive}" mcp-publisher + chmod +x mcp-publisher + - name: Validate MCP Registry metadata + shell: bash + working-directory: ${{ inputs.directory }} + run: ./mcp-publisher validate + - name: Check existing registry version + id: registry + shell: bash + working-directory: ${{ inputs.directory }} + run: node "${GITHUB_ACTION_PATH}/registry-state.mjs" + - name: Authenticate to MCP Registry + if: steps.registry.outputs.exists != 'true' + shell: bash + working-directory: ${{ inputs.directory }} + run: ./mcp-publisher login github-oidc + - name: Publish MCP Registry metadata + if: steps.registry.outputs.exists != 'true' + shell: bash + working-directory: ${{ inputs.directory }} + run: ./mcp-publisher publish diff --git a/.github/actions/publish-mcp/registry-state.d.mts b/.github/actions/publish-mcp/registry-state.d.mts new file mode 100644 index 0000000000..f26edc81b9 --- /dev/null +++ b/.github/actions/publish-mcp/registry-state.d.mts @@ -0,0 +1,4 @@ +export function registryPublicationState( + server: Readonly<{ name: string; version: string } & Record>, + request?: (url: URL, init: RequestInit) => Promise, +): Promise<"present" | "absent">; diff --git a/.github/actions/publish-mcp/registry-state.mjs b/.github/actions/publish-mcp/registry-state.mjs new file mode 100644 index 0000000000..e3a32867b4 --- /dev/null +++ b/.github/actions/publish-mcp/registry-state.mjs @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { appendFile, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** Exact-version routes currently return false 404s; enumerate the filtered listing. */ +export async function registryPublicationState(server, request = fetch) { + assert.equal(typeof server?.name, "string"); + assert.equal(typeof server?.version, "string"); + const cursors = new Set(); + let cursor; + const matches = []; + do { + const url = new URL( + "https://registry.modelcontextprotocol.io/v0.1/servers", + ); + url.search = new URLSearchParams({ + search: server.name, + version: server.version, + include_deleted: "true", + limit: "100", + }).toString(); + if (cursor !== undefined) url.searchParams.set("cursor", cursor); + const response = await request(url, { + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) + throw new Error(`Registry lookup failed: HTTP ${response.status}`); + const page = await response.json(); + assert(Array.isArray(page?.servers), "Registry response omitted servers"); + assert( + page.metadata && typeof page.metadata === "object", + "Registry response omitted metadata", + ); + for (const entry of page.servers) { + assert( + typeof entry?.server?.name === "string" && + typeof entry.server.version === "string", + "Malformed registry server record", + ); + if ( + entry.server.name === server.name && + entry.server.version === server.version + ) + matches.push(entry); + } + cursor = page.metadata.nextCursor; + if (cursor !== undefined) { + assert( + typeof cursor === "string" && cursor.length > 0 && !cursors.has(cursor), + "Registry returned a non-advancing cursor", + ); + cursors.add(cursor); + } + } while (cursor !== undefined); + assert( + matches.length <= 1, + "Registry returned ambiguous exact-version records", + ); + if (matches.length === 0) return "absent"; + assert.equal( + matches[0]._meta?.["io.modelcontextprotocol.registry/official"]?.status, + "active", + "Existing registry version is not active", + ); + assert.deepEqual( + matches[0].server, + server, + "Existing registry version differs from released metadata", + ); + return "present"; +} + +if ( + process.argv[1] !== undefined && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + const state = await registryPublicationState( + JSON.parse(await readFile("server.json", "utf8")), + ); + assert(process.env.GITHUB_OUTPUT, "GITHUB_OUTPUT is required"); + await appendFile( + process.env.GITHUB_OUTPUT, + `exists=${state === "present"}\n`, + ); + process.stdout.write( + `Registry version is ${state}; ${state === "present" ? "preserving identical published metadata" : "publication may proceed"}.\n`, + ); +} diff --git a/.github/workflows/release-recovery.yml b/.github/workflows/release-recovery.yml new file mode 100644 index 0000000000..befb77310d --- /dev/null +++ b/.github/workflows/release-recovery.yml @@ -0,0 +1,105 @@ +name: Recover published release + +on: + workflow_dispatch: + inputs: + version: + description: Existing npm and GitHub release version + required: true + type: string + release_run_id: + description: Original Release run with successful exact-source qualification and npm publication + required: true + type: string + +concurrency: + group: release-recovery-${{ inputs.version }} + cancel-in-progress: false + +jobs: + verify: + name: Verify existing published package + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + actions: read + outputs: + source_sha: ${{ steps.release.outputs.source_sha }} + version: ${{ steps.release.outputs.version }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version-file: .nvmrc + package-manager-cache: false + - run: npm ci + - name: Validate original release authority + id: release + env: + GH_TOKEN: ${{ github.token }} + RECOVERY_VERSION: ${{ inputs.version }} + RECOVERY_RUN_ID: ${{ inputs.release_run_id }} + run: node scripts/ci/release-recovery.mjs resolve + - name: Check out released metadata + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ steps.release.outputs.source_sha }} + path: .cache/release-source + persist-credentials: false + - name: Download original qualified package + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: verified-release-package + run-id: ${{ steps.release.outputs.run_id }} + github-token: ${{ github.token }} + path: .cache/verified-package + - name: Restore original verifier runtime + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: rea-test-runtime + run-id: ${{ steps.release.outputs.run_id }} + github-token: ${{ github.token }} + path: . + - name: Match published npm bytes to the qualified package + env: + RECOVERY_VERSION: ${{ steps.release.outputs.version }} + RECOVERY_RUN_ID: ${{ steps.release.outputs.run_id }} + RECOVERY_RUN_ATTEMPT: ${{ steps.release.outputs.run_attempt }} + RECOVERY_SOURCE_SHA: ${{ steps.release.outputs.source_sha }} + RECOVERY_SOURCE_DIRECTORY: .cache/release-source + RECOVERY_ARTIFACT_DIRECTORY: .cache/verified-package + run: node scripts/ci/release-recovery.mjs verify + - name: Verify published CLI, MCP and update + env: + RECOVERY_VERSION: ${{ steps.release.outputs.version }} + run: node scripts/verify-published-package.mjs "$RECOVERY_VERSION" + - name: Validate released registry metadata + working-directory: .cache/release-source + run: npm run mcp-registry:check + + publish-mcp: + name: Finish MCP Registry publication + needs: verify + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + - name: Check out verified released metadata + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ needs.verify.outputs.source_sha }} + path: .cache/release-source + persist-credentials: false + - uses: ./.github/actions/publish-mcp + with: + directory: .cache/release-source diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 66d1221e9e..ddfbbd00bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -371,8 +371,6 @@ jobs: published_version="$(npm view --prefer-online "rea-agents@${version}" version --json 2>/dev/null || true)" if [ "${published_version}" = "\"${version}\"" ]; then echo "rea-agents@${version} is visible on npm after attempt ${attempt}/${max_attempts}; verifying the published CLI." - npm exec --yes --package="rea-agents@${version}" -- rea --help - npm exec --yes --package="rea-agents@${version}" -- rea --llms node "${GITHUB_WORKSPACE}/scripts/verify-published-package.mjs" "${version}" exit 0 else @@ -406,23 +404,4 @@ jobs: - name: Verify package and registry metadata versions run: npm run mcp-registry:check - - name: Install MCP Registry publisher - env: - MCP_PUBLISHER_VERSION: v1.8.0 - MCP_PUBLISHER_SHA256_LINUX_AMD64: 1370446bbe74d562608e8005a6ccce02d146a661fbd78674e11cc70b9618d6cf - run: | - archive="mcp-publisher_linux_amd64.tar.gz" - curl -fsSLo "${archive}" \ - "https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/${archive}" - echo "${MCP_PUBLISHER_SHA256_LINUX_AMD64} ${archive}" | sha256sum --check --strict - tar xzf "${archive}" mcp-publisher - chmod +x mcp-publisher - - - name: Validate MCP Registry metadata - run: ./mcp-publisher validate - - - name: Authenticate to MCP Registry - run: ./mcp-publisher login github-oidc - - - name: Publish MCP Registry metadata - run: ./mcp-publisher publish + - uses: ./.github/actions/publish-mcp diff --git a/docs/releasing.md b/docs/releasing.md index 60fdb23cfd..644edbe7ae 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -18,6 +18,36 @@ both publication jobs and the published-package canary; a merged PR or GitHub tag alone does not establish that publication finished. Retry failed jobs in the original run after a partial publication. +### Recover an already published npm release + +Rerunning a job uses its original workflow and source revision. When a verifier +or publishing workflow needs a code fix, land that fix on main first, then use +the separate recovery workflow instead of repeating release creation: + +```bash +gh workflow run release-recovery.yml --ref main \ + -f version=6.5.0 -f release_run_id=38564502044 +``` + +Select the existing version and its original automatic Release run. Recovery +requires that run's successful exact-source CI gate and npm publish step. It +uses the existing release tag, original qualified tarball and verifier runtime; +expired or missing artifacts stop recovery. The archive's source, version, +original run/attempt and Node version must match, and its SHA-512 must equal +npm's published integrity. The corrected controller runs the public CLI, MCP +catalog/session and previous-version update canary against that released runtime. +The installation completes before MCP's handshake begins; dependency lifecycle +scripts are disabled as in the installed-package verifier. + +Only after verification succeeds does recovery publish the released +`server.json` to MCP Registry. It does not create tags or GitHub releases, +publish npm bytes, or change dist-tags. Both publication paths share the same +MCP publisher: a paginated registry lookup preserves an identical active +version, refuses conflicting/inactive metadata, and fails on lookup errors. +The original failed run remains a historical failure; the recovery run records +the new verification and remaining registry publication. Manual frozen-branch +releases are outside this recovery path. + For a release that needs an independently frozen application checkpoint, use the optional manual path below. It retains explicit prepare and publish phases. diff --git a/scripts/ci/plan.mjs b/scripts/ci/plan.mjs index fdfc3518c5..a3155feb6d 100644 --- a/scripts/ci/plan.mjs +++ b/scripts/ci/plan.mjs @@ -13,6 +13,7 @@ const GUARDED_WORKFLOWS = new Set([ ".github/workflows/pr-readiness.yml", ".github/workflows/ci-full-pr.yml", ".github/workflows/release.yml", + ".github/workflows/release-recovery.yml", ".github/workflows/real-ghidra-windows.yml", ".github/workflows/sqlite-runtime.yml", ".github/workflows/ci-timings.yml", diff --git a/scripts/ci/release-recovery.d.mts b/scripts/ci/release-recovery.d.mts new file mode 100644 index 0000000000..266f15b62b --- /dev/null +++ b/scripts/ci/release-recovery.d.mts @@ -0,0 +1,28 @@ +export function selectRecoveryRelease(input: { + repository: string; + version: string; + run: unknown; + jobs: unknown; + release: unknown; + tagSha: string; +}): { + version: string; + source_sha: string; + run_id: number; + run_attempt: number; +}; + +export function verifyRecoveryPackage(input: { + directory: string; + sourceSha: string; + runId: number; + runAttempt: number; + version: string; + integrity: string; +}): Promise<{ + version: string; + sourceSha: string; + runId: number; + runAttempt: number; + integrity: string; +}>; diff --git a/scripts/ci/release-recovery.mjs b/scripts/ci/release-recovery.mjs new file mode 100644 index 0000000000..cb5046ba00 --- /dev/null +++ b/scripts/ci/release-recovery.mjs @@ -0,0 +1,216 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { appendFile, readFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { valid } from "semver"; +import { z } from "zod"; + +import { validateVerifiedPackage } from "./verified-package.mjs"; + +const execute = promisify(execFile); +const sha = z.string().regex(/^[a-f0-9]{40}$/u); +const positive = z.coerce + .number() + .int() + .positive() + .max(Number.MAX_SAFE_INTEGER); +const versionSchema = z + .string() + .refine( + (value) => valid(value) === value && !value.includes("+"), + "Expected an exact release version without build metadata", + ); +const repositorySchema = z.string().regex(/^[\w.-]+\/[\w.-]+$/u); + +/** Require the original release's successful qualification and npm write. */ +export function selectRecoveryRelease({ + repository, + version, + run, + jobs, + release, + tagSha, +}) { + repositorySchema.parse(repository); + versionSchema.parse(version); + sha.parse(tagSha); + const original = z + .object({ + id: positive, + run_attempt: positive, + path: z.literal(".github/workflows/release.yml"), + event: z.literal("pull_request"), + status: z.literal("completed"), + repository: z.object({ full_name: z.literal(repository) }), + head_repository: z.object({ full_name: z.literal(repository) }), + }) + .parse(run); + z.object({ + tag_name: z.literal(`rea-agents-${version}`), + draft: z.literal(false), + published_at: z.string().min(1), + }).parse(release); + const parsedJobs = z + .array( + z.object({ + name: z.string(), + status: z.string(), + conclusion: z.string().nullable(), + steps: z + .array( + z.object({ name: z.string(), conclusion: z.string().nullable() }), + ) + .default([]), + }), + ) + .parse(jobs); + const requireJob = (name) => { + const matches = parsedJobs.filter((job) => job.name === name); + if (matches.length !== 1 || matches[0].status !== "completed") + throw new Error(`Expected one completed original job: ${name}`); + return matches[0]; + }; + for (const name of [ + "Release reviewed main merge", + "Qualify exact release revision / CI required", + ]) + if (requireJob(name).conclusion !== "success") + throw new Error(`Original release did not pass ${name}`); + const publish = requireJob("Publish to npm"); + const writes = publish.steps.filter((step) => step.name === "Publish"); + if (writes.length !== 1 || writes[0].conclusion !== "success") + throw new Error("Original npm publication did not succeed"); + return { + version, + source_sha: tagSha, + run_id: original.id, + run_attempt: original.run_attempt, + }; +} + +/** Bind existing npm bytes to the original qualified archive, without rebuilding it. */ +export async function verifyRecoveryPackage({ + directory, + sourceSha, + runId, + runAttempt, + version, + integrity, +}) { + const tarball = await validateVerifiedPackage({ + directory, + sourceSha, + runId, + runAttempt, + packageVersion: version, + nodeVersion: process.version, + }); + const hash = createHash("sha512"); + for await (const chunk of createReadStream(tarball)) hash.update(chunk); + if (integrity !== `sha512-${hash.digest("base64")}`) + throw new Error( + "Published npm integrity differs from the original qualified package", + ); + return { version, sourceSha, runId, runAttempt, integrity }; +} + +async function command(program, args, cwd) { + return ( + await execute(program, args, { cwd, maxBuffer: 16 * 1024 * 1024 }) + ).stdout.trim(); +} + +async function main() { + const [operation, ...extra] = process.argv.slice(2); + if (extra.length || !["resolve", "verify"].includes(operation)) + throw new Error( + "Usage: node scripts/ci/release-recovery.mjs resolve|verify", + ); + const version = versionSchema.parse(process.env.RECOVERY_VERSION); + const runId = positive.parse(process.env.RECOVERY_RUN_ID); + if (operation === "resolve") { + if (process.env.GITHUB_REF !== "refs/heads/main") + throw new Error("Run release recovery from main"); + const repository = repositorySchema.parse(process.env.GITHUB_REPOSITORY); + const api = async (path) => + JSON.parse(await command("gh", ["api", `repos/${repository}/${path}`])); + const run = await api(`actions/runs/${runId}`); + const pages = z + .array(z.object({ jobs: z.array(z.unknown()) })) + .parse( + JSON.parse( + await command("gh", [ + "api", + "--paginate", + "--slurp", + `repos/${repository}/actions/runs/${runId}/attempts/${positive.parse(run.run_attempt)}/jobs?per_page=100`, + ]), + ), + ); + const release = await api(`releases/tags/rea-agents-${version}`); + const tagSha = await command("git", [ + "rev-parse", + "--verify", + `refs/tags/rea-agents-${version}^{commit}`, + ]); + const selection = selectRecoveryRelease({ + repository, + version, + run, + jobs: pages.flatMap((page) => page.jobs), + release, + tagSha, + }); + await appendFile( + z.string().min(1).parse(process.env.GITHUB_OUTPUT), + Object.entries(selection) + .map(([key, value]) => `${key}=${value}\n`) + .join(""), + ); + process.stdout.write(`${JSON.stringify(selection)}\n`); + return; + } + const sourceDirectory = z + .string() + .min(1) + .parse(process.env.RECOVERY_SOURCE_DIRECTORY); + const sourceSha = sha.parse(process.env.RECOVERY_SOURCE_SHA); + if ( + (await command("git", ["rev-parse", "HEAD"], sourceDirectory)) !== sourceSha + ) + throw new Error("Recovery checkout differs from the released source"); + z.object({ version: z.literal(version) }).parse( + JSON.parse(await readFile(join(sourceDirectory, "package.json"), "utf8")), + ); + const integrity = z + .string() + .parse( + JSON.parse( + await command("npm", [ + "view", + `rea-agents@${version}`, + "dist.integrity", + "--json", + "--registry=https://registry.npmjs.org", + ]), + ), + ); + const result = await verifyRecoveryPackage({ + directory: z.string().min(1).parse(process.env.RECOVERY_ARTIFACT_DIRECTORY), + sourceSha, + runId, + runAttempt: positive.parse(process.env.RECOVERY_RUN_ATTEMPT), + version, + integrity, + }); + process.stdout.write(`${JSON.stringify(result)}\n`); +} + +if ( + process.argv[1] !== undefined && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) + await main(); diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index 7ea5063ce4..17a0c8e0ff 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -90,6 +90,13 @@ export const SCOPES = { ], // Four-host installed-package matrix and the Windows curated lane. package: [ + ".github/actions/publish-mcp/**", + ".github/workflows/release-recovery.yml", + "scripts/ci/release-recovery.mjs", + "scripts/ci/verified-package.mjs", + "scripts/verify-published-package.mjs", + "tests/boundary/filesystem/releaseRecovery.test.ts", + "tests/boundary/filesystem/mcpRegistryPublication.test.ts", "src/windows/**", "native/windows/**", "src/process/capture/FilesystemSnapshot.ts", diff --git a/scripts/ci/verified-package.mjs b/scripts/ci/verified-package.mjs index 1053760b26..87d74bab84 100644 --- a/scripts/ci/verified-package.mjs +++ b/scripts/ci/verified-package.mjs @@ -78,7 +78,7 @@ export async function retainVerifiedPackage({ } /** Validate a retained package against the current checkout and workflow attempt. */ -async function validateVerifiedPackage({ +export async function validateVerifiedPackage({ directory, sourceSha, runId, diff --git a/tests/boundary/filesystem/mcpRegistryPublication.test.ts b/tests/boundary/filesystem/mcpRegistryPublication.test.ts new file mode 100644 index 0000000000..71d03f9cc1 --- /dev/null +++ b/tests/boundary/filesystem/mcpRegistryPublication.test.ts @@ -0,0 +1,76 @@ +import { expect, it } from "vitest"; +import { registryPublicationState } from "../../../.github/actions/publish-mcp/registry-state.mjs"; + +const server = { + name: "io.github.fixture/rea", + version: "6.5.0", + description: "Released metadata", +}; +const record = { + server, + _meta: { "io.modelcontextprotocol.registry/official": { status: "active" } }, +}; +const page = (servers: unknown[], metadata = {}) => + Response.json({ servers, metadata }); + +it("finds an identical existing version after paging past substring matches", async () => { + const urls: URL[] = []; + const result = await registryPublicationState(server, async (url: URL) => { + urls.push(url); + return urls.length === 1 + ? page([{ server: { ...server, name: `${server.name}-other` } }], { + nextCursor: "next", + }) + : page([record]); + }); + expect(result).toBe("present"); + expect(urls[0]?.searchParams.get("search")).toBe(server.name); + expect(urls[0]?.searchParams.get("version")).toBe(server.version); + expect(urls[0]?.searchParams.get("include_deleted")).toBe("true"); + expect(urls[1]?.searchParams.get("cursor")).toBe("next"); +}); + +it("permits publication only after a complete successful lookup finds no exact match", async () => { + await expect( + registryPublicationState(server, async () => page([])), + ).resolves.toBe("absent"); + await expect( + registryPublicationState( + server, + async () => new Response("unavailable", { status: 503 }), + ), + ).rejects.toThrow("HTTP 503"); + await expect( + registryPublicationState(server, async () => + Response.json({ metadata: {} }), + ), + ).rejects.toThrow("omitted servers"); + await expect( + registryPublicationState(server, async () => + page([], { nextCursor: "same" }), + ), + ).rejects.toThrow("non-advancing cursor"); +}); + +it("refuses changed, inactive or ambiguous published metadata", async () => { + await expect( + registryPublicationState(server, async () => + page([{ ...record, server: { ...server, description: "Different" } }]), + ), + ).rejects.toThrow("differs from released metadata"); + await expect( + registryPublicationState(server, async () => + page([ + { + ...record, + _meta: { + "io.modelcontextprotocol.registry/official": { status: "deleted" }, + }, + }, + ]), + ), + ).rejects.toThrow("not active"); + await expect( + registryPublicationState(server, async () => page([record, record])), + ).rejects.toThrow("ambiguous"); +}); diff --git a/tests/boundary/filesystem/releaseRecovery.test.ts b/tests/boundary/filesystem/releaseRecovery.test.ts new file mode 100644 index 0000000000..05689d5024 --- /dev/null +++ b/tests/boundary/filesystem/releaseRecovery.test.ts @@ -0,0 +1,207 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; +import { parse } from "yaml"; +import { z } from "zod"; + +import { + selectRecoveryRelease, + verifyRecoveryPackage, +} from "../../../scripts/ci/release-recovery.mjs"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +const sourceSha = "a".repeat(40); +const job = (name: string, conclusion = "success") => ({ + name, + status: "completed", + conclusion, + steps: [], +}); +const selection = () => ({ + repository: "fixture/rea", + version: "6.5.0", + tagSha: sourceSha, + run: { + id: 123, + run_attempt: 1, + path: ".github/workflows/release.yml", + event: "pull_request", + status: "completed", + repository: { full_name: "fixture/rea" }, + head_repository: { full_name: "fixture/rea" }, + }, + release: { + tag_name: "rea-agents-6.5.0", + draft: false, + published_at: "2026-10-14T12:32:45Z", + }, + jobs: [ + job("Release reviewed main merge"), + job("Qualify exact release revision / CI required"), + { + ...job("Publish to npm", "failure"), + steps: [{ name: "Publish", conclusion: "success" }], + }, + ], +}); + +it("recovers a failed post-publication run using its successful original qualification", () => { + expect(selectRecoveryRelease(selection())).toEqual({ + version: "6.5.0", + source_sha: sourceSha, + run_id: 123, + run_attempt: 1, + }); +}); + +it.each(["failure", "skipped", "cancelled"])( + "refuses recovery when original qualification was %s", + (conclusion) => { + const input = selection(); + input.jobs[1]!.conclusion = conclusion; + expect(() => selectRecoveryRelease(input)).toThrow("did not pass"); + }, +); + +it("refuses absent or ambiguous qualification and unsuccessful npm publication", () => { + const missing = selection(); + missing.jobs.splice(1, 1); + expect(() => selectRecoveryRelease(missing)).toThrow( + "Expected one completed", + ); + const duplicate = selection(); + duplicate.jobs.push(job("Qualify exact release revision / CI required")); + expect(() => selectRecoveryRelease(duplicate)).toThrow( + "Expected one completed", + ); + const unpublished = selection(); + unpublished.jobs[2]!.steps[0]!.conclusion = "failure"; + expect(() => selectRecoveryRelease(unpublished)).toThrow( + "publication did not succeed", + ); +}); + +it("refuses foreign runs, unrelated workflows and unpublished releases", () => { + const foreign = selection(); + foreign.run.head_repository.full_name = "outside/rea"; + expect(() => selectRecoveryRelease(foreign)).toThrow(); + const unrelated = selection(); + unrelated.run.path = ".github/workflows/ci.yml"; + expect(() => selectRecoveryRelease(unrelated)).toThrow(); + const draft = selection(); + draft.release.draft = true; + expect(() => selectRecoveryRelease(draft)).toThrow(); + const wrongTag = selection(); + wrongTag.release.tag_name = "rea-agents-6.6.0"; + expect(() => selectRecoveryRelease(wrongTag)).toThrow(); +}); + +it("requires npm bytes to match the retained archive and original source/run", async () => { + const directory = await createTestTempDirectory("rea-release-recovery-"); + const archive = "original qualified archive"; + await writeFile(join(directory, "package.tgz"), archive); + await writeFile( + join(directory, "manifest.json"), + JSON.stringify({ + schemaVersion: 1, + sourceSha, + runId: 123, + runAttempt: 1, + platform: "linux", + architecture: "x64", + packageVersion: "6.5.0", + nodeVersion: process.version, + sha256: createHash("sha256").update(archive).digest("hex"), + }), + ); + const input = { + directory, + sourceSha, + runId: 123, + runAttempt: 1, + version: "6.5.0", + integrity: `sha512-${createHash("sha512").update(archive).digest("base64")}`, + }; + await expect(verifyRecoveryPackage(input)).resolves.toMatchObject({ + version: "6.5.0", + sourceSha, + }); + await expect( + verifyRecoveryPackage({ ...input, integrity: "sha512-wrong" }), + ).rejects.toThrow("npm integrity differs"); + await expect( + verifyRecoveryPackage({ ...input, sourceSha: "b".repeat(40) }), + ).rejects.toThrow("source SHA mismatch"); + await expect(verifyRecoveryPackage({ ...input, runId: 456 })).rejects.toThrow( + "workflow run ID mismatch", + ); +}); + +it("limits recovery to verification and dependent MCP publication from the released source", async () => { + const step = z.object({ + uses: z.string().optional(), + run: z.string().optional(), + with: z.record(z.string(), z.unknown()).optional(), + }); + const workflow = z + .object({ + on: z.record(z.string(), z.unknown()), + jobs: z.object({ + verify: z.object({ + if: z.string(), + permissions: z.record(z.string(), z.string()), + steps: z.array(step), + }), + "publish-mcp": z.object({ + needs: z.string(), + permissions: z.record(z.string(), z.string()), + steps: z.array(step), + }), + }), + }) + .parse( + parse(await readFile(".github/workflows/release-recovery.yml", "utf8")), + ); + expect(Object.keys(workflow.on)).toEqual(["workflow_dispatch"]); + expect(workflow.jobs.verify.if).toBe("github.ref == 'refs/heads/main'"); + expect(workflow.jobs.verify.permissions).toEqual({ + contents: "read", + actions: "read", + }); + const steps = workflow.jobs.verify.steps; + for (const name of ["verified-release-package", "rea-test-runtime"]) + expect( + steps.find((candidate) => candidate.with?.name === name)?.with, + ).toMatchObject({ + "run-id": "${{ steps.release.outputs.run_id }}", + "github-token": "${{ github.token }}", + }); + const digest = steps.findIndex( + (candidate) => + candidate.run === "node scripts/ci/release-recovery.mjs verify", + ); + const canary = steps.findIndex((candidate) => + candidate.run?.includes("verify-published-package.mjs"), + ); + expect(digest).toBeGreaterThanOrEqual(0); + expect(canary).toBeGreaterThan(digest); + const publisher = workflow.jobs["publish-mcp"]; + expect(publisher.needs).toBe("verify"); + expect( + publisher.steps.find( + (candidate) => candidate.with?.path === ".cache/release-source", + )?.with?.ref, + ).toBe("${{ needs.verify.outputs.source_sha }}"); + expect( + publisher.steps.find( + (candidate) => candidate.uses === "./.github/actions/publish-mcp", + )?.with, + ).toEqual({ directory: ".cache/release-source" }); + for (const candidate of [...steps, ...publisher.steps]) { + expect(candidate.run ?? "").not.toMatch( + /npm publish|git tag|gh release create/u, + ); + expect(candidate.uses ?? "").not.toContain("release-please"); + } +});