mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
@@ -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`,
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
@@ -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();
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user