fix(release): recover verified partial registry publications

Validate original exact-source qualification, release tag, retained package provenance and npm integrity before the corrected public-package canary. Publish only the existing release's MCP metadata through a shared idempotent publisher; never recreate tags or npm versions.
This commit is contained in:
morluto
2026-10-14 21:15:34 +08:00
parent 0b4fd11c8c
commit 790657dc0d
13 changed files with 806 additions and 23 deletions
+41
View File
@@ -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
@@ -0,0 +1,4 @@
export function registryPublicationState(
server: Readonly<{ name: string; version: string } & Record<string, unknown>>,
request?: (url: URL, init: RequestInit) => Promise<Response>,
): Promise<"present" | "absent">;
@@ -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`,
);
}
+105
View File
@@ -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
+1 -22
View File
@@ -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
+30
View File
@@ -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.
+1
View File
@@ -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",
+28
View File
@@ -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;
}>;
+216
View File
@@ -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();
+7
View File
@@ -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",
+1 -1
View File
@@ -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,
@@ -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");
});
@@ -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");
}
});