diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d8e387..7dd17f9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,20 +3,6 @@ on: push: branches: - main - workflow_dispatch: - inputs: - bootstrap: - description: Explicit one-time openapi-mcp 0.0.0 bootstrap exception - type: boolean - default: false - approved_owner: - description: Named approving owner (must equal authenticated actor and configured owner) - type: string - required: true - approval_evidence: - description: GitHub URL recording owner approval and bootstrap scope - type: string - required: true permissions: contents: write issues: write @@ -106,6 +92,7 @@ jobs: consumer=$(mktemp -d "$RUNNER_TEMP/openapi-public-XXXXXX") cd "$consumer" npm init -y + node "$GITHUB_WORKSPACE/scripts/openapi-mcp-release.mjs" wait-for-version "$version" npm install --ignore-scripts --save-exact --registry=https://registry.npmjs.org/ "@knitli/openapi-mcp@$version" npm audit signatures --json --include-attestations > "$evidence/npm-audit-signatures.json" node "$GITHUB_WORKSPACE/scripts/openapi-mcp-release.mjs" verify-audit "$evidence/npm-audit-signatures.json" "$tarball" "$version" > "$evidence/verified-provenance.json" @@ -119,121 +106,6 @@ jobs: path: ${{ runner.temp }}/openapi-mcp-release/ if-no-files-found: ignore - # This exception is disabled unless the named owner explicitly configures and - # dispatches it. Remove its token wiring/secret and revoke the token after use; - # the steady-state adapter refuses publication while that wiring remains. - bootstrap-openapi-mcp-pack: - if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.bootstrap && vars.OPENAPI_MCP_BOOTSTRAP_ENABLED == 'true' && inputs.approved_owner == vars.OPENAPI_MCP_BOOTSTRAP_OWNER && github.actor == vars.OPENAPI_MCP_BOOTSTRAP_OWNER - runs-on: ubuntu-latest - environment: npmrelease - permissions: - contents: read - outputs: - sha256: ${{ steps.pack.outputs.sha256 }} - steps: - - uses: actions/checkout@v7 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.4.0 - - uses: actions/setup-node@v7 - with: - node-version: 24.19.0 - - name: Record named owner approval - env: - APPROVED_OWNER: ${{ inputs.approved_owner }} - APPROVAL_EVIDENCE: ${{ inputs.approval_evidence }} - run: | - test "$APPROVED_OWNER" = "$GITHUB_ACTOR" - case "$APPROVAL_EVIDENCE" in https://github.com/knitli/toolshed/*) ;; *) exit 1 ;; esac - mkdir -p "$RUNNER_TEMP/openapi-mcp-bootstrap" - node --input-type=module -e 'import {writeFileSync} from "node:fs"; writeFileSync(process.env.RUNNER_TEMP + "/openapi-mcp-bootstrap/approval.json", JSON.stringify({owner:process.env.APPROVED_OWNER,evidence:process.env.APPROVAL_EVIDENCE,sourceCommit:process.env.GITHUB_SHA,runId:process.env.GITHUB_RUN_ID},null,2));' - - run: bun i --frozen-lockfile - - name: Build and test bootstrap source - run: | - node -e 'const p=require("./packages/openapi-mcp/package.json"); if(p.name!=="@knitli/openapi-mcp" || p.version!=="0.0.0" || p.private!==false) process.exit(1);' - bun scripts/generate.mts --check - bun test scripts/openapi-mcp-release.test.ts - bun test packages/openapi-mcp - bun run --cwd packages/openapi-mcp test:node-transport - bun run --cwd packages/openapi-mcp test:node-stdio - bun run --cwd packages/openapi-mcp build - bun run validate - - name: Pack and exercise the exact bootstrap artifact - id: pack - shell: bash - run: | - tarball="$RUNNER_TEMP/openapi-mcp-bootstrap/knitli-openapi-mcp-0.0.0.tgz" - (cd packages/openapi-mcp && bun pm pack --filename "$tarball" --quiet) - OPENAPI_MCP_TARBALL="$tarball" bun test packages/openapi-mcp/tests/package-consumers.test.ts - cd "$RUNNER_TEMP/openapi-mcp-bootstrap" - sha256sum knitli-openapi-mcp-0.0.0.tgz > SHA256SUMS - printf 'sha256=%s\n' "$(cut -d ' ' -f1 SHA256SUMS)" >> "$GITHUB_OUTPUT" - - uses: actions/upload-artifact@v4 - with: - name: openapi-mcp-bootstrap-tested-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/openapi-mcp-bootstrap/ - if-no-files-found: error - - bootstrap-openapi-mcp-publish: - needs: bootstrap-openapi-mcp-pack - if: github.event_name == 'workflow_dispatch' && inputs.bootstrap - runs-on: ubuntu-latest - environment: npmrelease - permissions: - contents: read - id-token: write - steps: - - uses: actions/checkout@v7 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.4.0 - - uses: actions/setup-node@v7 - with: - node-version: 24.19.0 - registry-url: https://registry.npmjs.org - - run: bun i --frozen-lockfile - - uses: actions/download-artifact@v4 - with: - name: openapi-mcp-bootstrap-tested-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/openapi-mcp-bootstrap/ - - name: Rehash downloaded tested artifact and publish only bootstrap - env: - EXPECTED_SHA256: ${{ needs.bootstrap-openapi-mcp-pack.outputs.sha256 }} - NODE_AUTH_TOKEN: ${{ secrets.OPENAPI_MCP_BOOTSTRAP_TOKEN }} - shell: bash - run: | - test -n "$NODE_AUTH_TOKEN" - cd "$RUNNER_TEMP/openapi-mcp-bootstrap" - sha256sum --check SHA256SUMS - actual=$(sha256sum knitli-openapi-mcp-0.0.0.tgz | cut -d ' ' -f1) - test "$actual" = "$EXPECTED_SHA256" - tarball="$RUNNER_TEMP/openapi-mcp-bootstrap/knitli-openapi-mcp-0.0.0.tgz" - publish_dir=$(mktemp -d "$RUNNER_TEMP/openapi-bootstrap-publish-XXXXXX") - cd "$publish_dir" - npm publish "$tarball" --access public --tag bootstrap --provenance --registry=https://registry.npmjs.org/ - test "$(sha256sum "$tarball" | cut -d ' ' -f1)" = "$EXPECTED_SHA256" - - name: Verify bootstrap public install and cryptographic provenance - env: - NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/openapi-public-empty-user.npmrc - NPM_CONFIG_GLOBALCONFIG: ${{ runner.temp }}/openapi-public-empty-global.npmrc - shell: bash - run: | - evidence="$RUNNER_TEMP/openapi-mcp-bootstrap" - consumer=$(mktemp -d "$RUNNER_TEMP/openapi-bootstrap-public-XXXXXX") - cd "$consumer" - npm init -y - npm install --ignore-scripts --save-exact --registry=https://registry.npmjs.org/ @knitli/openapi-mcp@0.0.0 - npm audit signatures --json --include-attestations > "$evidence/npm-audit-signatures.json" - node "$GITHUB_WORKSPACE/scripts/openapi-mcp-release.mjs" verify-audit "$evidence/npm-audit-signatures.json" "$evidence/knitli-openapi-mcp-0.0.0.tgz" 0.0.0 > "$evidence/verified-provenance.json" - cp "$GITHUB_WORKSPACE/packages/openapi-mcp/test-consumers/node.mts" "$consumer/node.mts" - node node.mts > "$evidence/public-consumer.json" - sha256sum "$evidence/npm-audit-signatures.json" > "$evidence/npm-audit-signatures.sha256" - - uses: actions/upload-artifact@v4 - if: always() - with: - name: openapi-mcp-bootstrap-registry-evidence-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/openapi-mcp-bootstrap/ - if-no-files-found: error release-marketplace: if: github.event_name == 'push' needs: diff --git a/docs/superpowers/reviews/2026-09-05-openapi-mcp-bootstrap.md b/docs/superpowers/reviews/2026-09-05-openapi-mcp-bootstrap.md new file mode 100644 index 0000000..7a87444 --- /dev/null +++ b/docs/superpowers/reviews/2026-09-05-openapi-mcp-bootstrap.md @@ -0,0 +1,75 @@ +# OpenAPI MCP bootstrap publication and OIDC transition + +**Version 1.0.0** · Knitli · 2026-09-05 + +## AI reading instruction + +Read `[SPEC]` and `[BUG]` blocks for verified facts and release requirements. +`[NOTE]` records context; `[?]` records settings reported by the owner but not +independently verified. This record does not authorize a new publication. + +## 1. Verified bootstrap + +**[SPEC]** + +- Package: `@knitli/openapi-mcp@0.0.0`, public, dist-tag `bootstrap`. +- Source commit: `4f39c6c541568328b8566329c8eaa23fac1c52a0` on `main`. +- Workflow: `knitli/toolshed/.github/workflows/release.yml`; environment: `npmrelease`. +- Run: [33977232028, attempt 1](https://github.com/knitli/toolshed/actions/runs/33977232028). +- Named owner approval: [bootstrap scope recorded on PR #23](https://github.com/knitli/toolshed/pull/23#issuecomment-5552963096). +- Tested artifact: `openapi-mcp-bootstrap-tested-33977232028-1`, artifact ID `9972685133`. +- Tarball: `knitli-openapi-mcp-0.0.0.tgz`. +- Tarball SHA-256: `5a08830a006091bc0d4c371ab9530b91c0c506cf811a37a5b23f48cb6a93bca5`. +- Tarball SHA-512: `e346a9357baceccd9cd529af4c56f0554f129207c95a6ddc047792b5fba677fd753cf75932d0561f18fa2fe646420520376ee2c50699fb275f34e12993318337`. +- Publication completed at `2026-09-05T16:15:56Z`; [publish job](https://github.com/knitli/toolshed/actions/runs/33977232028/job/101336147561). +- No stable `latest` release, semantic-release Git tag, or OS deployment is established by this bootstrap. + +## 2. Verification after registry propagation + +**[SPEC]** + +- A fresh public installation of exact version `0.0.0` passed without npm credentials and with lifecycle scripts disabled. +- `npm audit signatures --json --include-attestations` exited zero, with no invalid or missing evidence. +- `verify-audit` bound the verified provenance to the exact CI-tested tarball, package/version, source commit, repository, workflow path/ref, run ID, and attempt above. +- The installed Node consumer passed runtime, public MCP read, rejected-admission recovery, rollback, and CLI checks. +- Verification used Node `24.19.0`, matching the release workflow. +- Retained local evidence: `/private/tmp/openapi-bootstrap-verification-3l6HHy/`; raw audit: `npm-audit-signatures.json`. +- Raw audit SHA-256: `a6a76157126cd943ab2aea20b08cadfb32d0a7d9c02922d745af66e356b10f36`. +- These were subsequent local checks, not a green rerun of the failed CI job. The local evidence directory is not a durable hosted artifact. + +**[BUG] Immediate registry lookup after publication** + +- Symptom: the publish step succeeded, but the public install returned `E404` approximately 0.68 seconds later; the workflow remained failed. +- Cause: the package listing was not yet visible to that lookup. A later exact-version request succeeded while a package-listing request still returned `404`. +- Fix: poll the same public package listing used for installation with bounded requests before installing. Do not retry `npm publish`, waive cryptographic checks, or treat an uploaded artifact as proof that verification finished. + +**[NOTE]** + +The run also uploaded `openapi-mcp-bootstrap-registry-evidence-33977232028-1` +(artifact ID `9972692303`), but installation failed before its audit and consumer +commands ran. Its existence alone is not completed registry verification. + +## 3. Transition status and remaining gates + +**[SPEC]** + +- GitHub's `npmrelease` environment secret listing was empty after the owner removed `OPENAPI_MCP_BOOTSTRAP_TOKEN` on 2026-09-05. +- This cleanup removes the bootstrap dispatch and jobs from the workflow while retaining the adapter's rejection of token wiring. +- `OPENAPI_MCP_OIDC_READY` was unset at inspection; this PR does not set it or start a release. +- The bootstrap owner/enablement repository variables were still present at inspection. They are unused once the bootstrap workflow path is removed and can be retired separately. +- Before enabling the first regular release, confirm the exact trusted publisher: owner `knitli`, repository `toolshed`, workflow filename `release.yml`, environment `npmrelease`, permission to publish. +- Confirm revocation of the bootstrap npm token, not merely removal of its GitHub secret, before enabling regular publication. +- GitHub must issue the standard issuer `https://token.actions.githubusercontent.com`; the earlier bootstrap attempt was rejected for the enterprise-specific `/knitli` issuer. +- After cleanup is merged and external setup confirmed, an operator may enable `OPENAPI_MCP_OIDC_READY`. A successful OIDC-authenticated publication and its public verification are still required to establish the first regular release. +- The native OS integration retains its separate stable-release and API approval gates. + +**[?]** + +- The owner reports configuring npm trusted publishing and disabling the token/publication route. Public `npm trust list` returned `E401`, so the exact publisher fields and npm token revocation were not independently inspected. + +## 4. Changelog + +**[NOTE]** + +1.0.0 records the successful bootstrap, subsequent public verification, registry +propagation failure, and the remaining OIDC transition gates. diff --git a/packages/openapi-mcp/README.md b/packages/openapi-mcp/README.md index c3f697d..aaa41ac 100644 --- a/packages/openapi-mcp/README.md +++ b/packages/openapi-mcp/README.md @@ -16,10 +16,14 @@ established; use Node for `serve`. `/runtime` and `/conformance` pass an isolate WebWorker-only declaration check and browser bundle. This is not evidence of an actual Cloudflare D1 deployment or D1 conformance. -The source version is `0.0.0`. Registry publication, trusted publishing, and -registry provenance verification remain release gates, not claims made by this -README. For a locally built and tested tarball, install its absolute path from a -clean consumer directory: +The source version is `0.0.0`. That version was published under the `bootstrap` +dist-tag on 2026-09-05; its public installation, signatures, provenance, and +installed consumer were verified. See the [bootstrap release record](../../docs/superpowers/reviews/2026-09-05-openapi-mcp-bootstrap.md) +for the exact artifact and workflow binding. This is not a stable `latest` +release or evidence of a successful trusted-publishing release. + +For a locally built and tested tarball, install its absolute path from a clean +consumer directory: ```sh npm install --ignore-scripts /absolute/path/to/openapi-mcp.tgz @@ -28,8 +32,8 @@ node --version After a real stable version is observed in npm, pin that exact verified version with `npm install --save-exact @knitli/openapi-mcp@`. A bootstrap -`0.0.0` publication, if needed, uses the `bootstrap` dist-tag and is not a stable -release. The CLI examples below use the consumer's `node_modules/.bin` binary. +`0.0.0` publication uses the `bootstrap` dist-tag and is not a stable release. +The CLI examples below use the consumer's `node_modules/.bin` binary. ## Generate a signing key and compile a release @@ -341,14 +345,21 @@ external setup and bootstrap-cleanup gates. Until then, build/test/validation still run and publication is explicitly reported as pending. A real build, publication, or audit failure continues to fail the job and its dependents. -If the package does not yet exist, bootstrap is a separate exception requiring -recorded named owner approval and protected-environment approval. It publishes -only `0.0.0`, with `--access public --tag bootstrap --provenance`; it does not run -semantic-release or create a Git tag/release. A short-lived granular token may be -injected only into that protected job, never into local files, logs, commits, or -artifacts. After bootstrap, configure the exact trusted publisher, remove token -variables and the GitHub secret, revoke the token, and commit the OIDC-only -workflow before allowing the first real `latest` release. +The one-time `0.0.0` bootstrap is complete. Its manual dispatch and token-backed +jobs have been removed; do not rerun publication of that version. The adapter +still rejects token environment mappings, including mappings in disabled jobs. +The [bootstrap release record](../../docs/superpowers/reviews/2026-09-05-openapi-mcp-bootstrap.md) +separates verified publication and secret removal from owner-reported npm setup +and the remaining first-`latest` gate. + +After publication, the verification step waits for the exact version to appear +in the public package listing before attempting a fresh installation. Only a +missing package or missing version is retried: at most six requests, each with +a ten-second timeout, separated by ten-second waits. Invalid metadata and other +errors fail the check. This readiness probe cannot guarantee every registry +replica or tarball URL is ready. Installation, cryptographic verification, and +consumer checks remain mandatory; publication itself is never retried by this +probe. For bootstrap and every stable release, retain a fresh public install's `npm audit signatures --json --include-attestations` output in the protected job diff --git a/scripts/openapi-mcp-release.mjs b/scripts/openapi-mcp-release.mjs index 0c0cc72..ab9e23b 100644 --- a/scripts/openapi-mcp-release.mjs +++ b/scripts/openapi-mcp-release.mjs @@ -12,6 +12,68 @@ const { parse: parseYaml } = createRequire( )("yaml"); const hash = (bytes) => createHash("sha256").update(bytes).digest("hex"); +/** Wait only for packument visibility; installation and verification can still fail. */ +export async function waitForRegistryVersion(version, dependencies = {}) { + if (typeof version !== "string" || !/^\d+\.\d+\.\d+$/.test(version)) + throw new Error("Registry readiness requires an exact stable version"); + const fetchRegistry = dependencies.fetch ?? fetch; + const sleep = + dependencies.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms))); + for (let attempt = 0; attempt < 6; attempt++) { + const signal = AbortSignal.timeout(10000); + let onAbort; + let response; + let metadata; + try { + [response, metadata] = await Promise.race([ + (async () => { + const result = await fetchRegistry( + `${registry}@knitli%2fopenapi-mcp`, + { + headers: { Accept: "application/vnd.npm.install-v1+json" }, + signal, + }, + ); + return [ + result, + result.status === 200 ? await result.json() : undefined, + ]; + })(), + new Promise((_, reject) => { + onAbort = () => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) onAbort(); + }), + ]); + } finally { + signal.removeEventListener("abort", onAbort); + } + if (response.status === 200) { + if ( + !metadata || + metadata.name !== packageName || + !metadata.versions || + typeof metadata.versions !== "object" || + Array.isArray(metadata.versions) + ) + throw new Error("Malformed or mismatched registry packument"); + if (Object.hasOwn(metadata.versions, version)) { + const entry = metadata.versions[version]; + if (!entry || entry.name !== packageName || entry.version !== version) + throw new Error( + "Registry entry does not match the exact package version", + ); + return; + } + } else if (response.status !== 404) + throw new Error(`Registry request failed with HTTP ${response.status}`); + if (attempt < 5) await sleep(10000); + } + throw new Error( + `Registry version ${version} was not visible after 6 attempts`, + ); +} + /** Bind npm's successfully verified audit output to this exact release. * This is not a cryptographic verifier: the caller MUST first require npm audit * signatures --json --include-attestations to exit zero on a fresh public install. @@ -100,7 +162,7 @@ function releaseVersion(context) { async function checkContext(context) { const env = context.env; - if (env.NPM_TOKEN || env.NODE_AUTH_TOKEN) + if (Object.hasOwn(env, "NPM_TOKEN") || Object.hasOwn(env, "NODE_AUTH_TOKEN")) throw new Error( "Steady-state release must use OIDC without token environment variables", ); @@ -333,6 +395,12 @@ if ( console.log( JSON.stringify({ ...verified, auditSha256: hash(auditBytes) }, null, 2), ); +} else if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) && + process.argv[2] === "wait-for-version" +) { + await waitForRegistryVersion(process.argv[3]); } else if ( process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url) diff --git a/scripts/openapi-mcp-release.test.ts b/scripts/openapi-mcp-release.test.ts index 2260ed4..e10467b 100644 --- a/scripts/openapi-mcp-release.test.ts +++ b/scripts/openapi-mcp-release.test.ts @@ -1,12 +1,184 @@ -import { expect, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import * as release from "./openapi-mcp-release.mjs"; import { createReleaseAdapter, verifyAuditBinding, } from "./openapi-mcp-release.mjs"; +test("registry readiness retries packument visibility and accepts only the exact release", async () => { + expect(typeof release.waitForRegistryVersion).toBe("function"); + const responses = [ + new Response(null, { status: 404 }), + Response.json({ name: "@knitli/openapi-mcp", versions: {} }), + Response.json({ + name: "@knitli/openapi-mcp", + versions: { + "1.2.3": { name: "@knitli/openapi-mcp", version: "1.2.3" }, + }, + }), + ]; + const delays: number[] = []; + let requests = 0; + await release.waitForRegistryVersion("1.2.3", { + fetch: async (url: string, options: RequestInit) => { + expect(url).toBe("https://registry.npmjs.org/@knitli%2fopenapi-mcp"); + expect(options.headers).toEqual({ + Accept: "application/vnd.npm.install-v1+json", + }); + expect(options.signal).toBeInstanceOf(AbortSignal); + requests++; + const response = responses.shift(); + if (!response) throw new Error("Unexpected extra registry request"); + return response; + }, + sleep: async (delay: number) => { + delays.push(delay); + }, + }); + expect(requests).toBe(3); + expect(delays).toEqual([10000, 10000]); +}); + +test("registry readiness exhausts six visibility attempts without a final sleep", async () => { + for (const response of [ + () => new Response(null, { status: 404 }), + () => Response.json({ name: "@knitli/openapi-mcp", versions: {} }), + ]) { + let requests = 0; + const delays: number[] = []; + await expect( + release.waitForRegistryVersion("1.2.3", { + fetch: async () => { + requests++; + return response(); + }, + sleep: async (ms: number) => { + delays.push(ms); + }, + }), + ).rejects.toThrow("6 attempts"); + expect(requests).toBe(6); + expect(delays).toEqual([10000, 10000, 10000, 10000, 10000]); + } +}); + +test("registry readiness rejects malformed or mismatched metadata and other HTTP errors immediately", async () => { + for (const response of [ + new Response("not json"), + Response.json(null), + Response.json({ name: "other", versions: {} }), + Response.json({ name: "@knitli/openapi-mcp" }), + Response.json({ name: "@knitli/openapi-mcp", versions: [] }), + Response.json({ name: "@knitli/openapi-mcp", versions: { "1.2.3": null } }), + Response.json({ + name: "@knitli/openapi-mcp", + versions: { "1.2.3": { name: "other", version: "1.2.3" } }, + }), + Response.json({ + name: "@knitli/openapi-mcp", + versions: { "1.2.3": { name: "@knitli/openapi-mcp", version: "1.2.4" } }, + }), + new Response(null, { status: 401 }), + new Response(null, { status: 429 }), + new Response(null, { status: 500 }), + ]) { + let requests = 0; + let sleeps = 0; + await expect( + release.waitForRegistryVersion("1.2.3", { + fetch: async () => { + requests++; + return response; + }, + sleep: async () => { + sleeps++; + }, + }), + ).rejects.toThrow(); + expect(requests).toBe(1); + expect(sleeps).toBe(0); + } +}); + +test("registry readiness fails closed on network errors and request or body timeouts", async () => { + for (const stage of ["network", "request", "body"]) { + const controller = new AbortController(); + const timeout = spyOn(AbortSignal, "timeout").mockImplementation((ms) => { + expect(ms).toBe(10000); + return controller.signal; + }); + let requests = 0; + let sleeps = 0; + try { + await expect( + Promise.race([ + release.waitForRegistryVersion("1.2.3", { + fetch: async () => { + requests++; + if (stage === "network") throw new Error("network failure"); + if (stage === "request") { + setTimeout( + () => + controller.abort( + new DOMException("timed out", "TimeoutError"), + ), + 0, + ); + return new Promise(() => {}); + } + return new Response( + new ReadableStream({ + start(stream) { + stream.enqueue(new TextEncoder().encode('{"name":')); + setTimeout( + () => + controller.abort( + new DOMException("timed out", "TimeoutError"), + ), + 0, + ); + }, + }), + ); + }, + sleep: async () => { + sleeps++; + }, + }), + new Promise((_, reject) => + setTimeout( + () => reject(new Error("body read escaped its deadline")), + 100, + ), + ), + ]), + ).rejects.toThrow(stage === "network" ? "network failure" : "timed out"); + expect(requests).toBe(1); + expect(sleeps).toBe(0); + } finally { + timeout.mockRestore(); + } + } +}); + +test("registry readiness rejects non-exact versions before making requests", async () => { + for (const version of [undefined, "", "latest", "^1.2.3", "1.2.3/other"]) { + let requests = 0; + await expect( + release.waitForRegistryVersion(version, { + fetch: async () => { + requests++; + throw new Error("unexpected request"); + }, + }), + ).rejects.toThrow("exact stable version"); + expect(requests).toBe(0); + } +}); + async function fixture() { const root = await mkdtemp(join(tmpdir(), "openapi-release-test-")); const cwd = join(root, "repository/packages/openapi-mcp"); @@ -87,6 +259,9 @@ test("release prepares nextRelease.version with Bun and publishes only the teste expect(tarball?.startsWith(f.runnerTemp)).toBe(true); const result = await f.adapter.publish({}, f.context); const publish = f.calls.find((call) => call.command[0] === "npm"); + expect(f.calls.filter((call) => call.command[1] === "publish")).toHaveLength( + 1, + ); expect(publish?.command).toEqual([ "npm", "publish", @@ -132,7 +307,7 @@ test("bootstrap cleanup checks environment mappings while allowing harmless toke await writeFile( workflow, // biome-ignore lint/suspicious/noTemplateCurlyInString: literal GitHub Actions expression is fixture input. - 'jobs:\n bootstrap:\n steps:\n - env: {"NODE_AUTH_TOKEN": "${{ secrets.OPENAPI_MCP_BOOTSTRAP_TOKEN }}"}\n', + 'jobs:\n bootstrap:\n if: false\n steps:\n - env: {"NODE_AUTH_TOKEN": "${{ secrets.OPENAPI_MCP_BOOTSTRAP_TOKEN }}"}\n', ); await expect(f.adapter.verifyConditions({}, f.context)).rejects.toThrow( "bootstrap token wiring", @@ -218,13 +393,14 @@ test("audit gate requires the verified package, tested tarball digest, source co ).toThrow(); }); -test("manual bootstrap dispatch cannot run unrelated releases or stable publication", async () => { +test("OIDC-only workflow rejects manual releases and gates publication and verification on readiness", async () => { const workflow = Bun.YAML.parse( await readFile( new URL("../.github/workflows/release.yml", import.meta.url), "utf8", ), ) as { + on: Record; jobs: Record< string, { @@ -275,15 +451,16 @@ test("manual bootstrap dispatch cannot run unrelated releases or stable publicat } for (const job of ["release", "release-marketplace", "release-openapi-mcp"]) expect(runs(job)).toBe(false); - expect(runs("bootstrap-openapi-mcp-pack")).toBe(true); - expect(runs("bootstrap-openapi-mcp-publish")).toBe(true); - context.github.actor = "unapproved-actor"; - expect(runs("bootstrap-openapi-mcp-publish")).toBe(false); + expect(workflow.on.workflow_dispatch).toBeUndefined(); + expect(Object.keys(workflow.jobs).sort()).toEqual([ + "release", + "release-marketplace", + "release-openapi-mcp", + ]); context.github.event_name = "push"; expect(runs("release")).toBe(true); expect(runs("release-openapi-mcp")).toBe(true); expect(runs("release-marketplace")).toBe(true); - expect(runs("bootstrap-openapi-mcp-publish")).toBe(false); for (const name of [ "Release exact tested openapi-mcp tarball through OIDC", "Verify fresh public install and registry attestations", @@ -301,6 +478,131 @@ test("manual bootstrap dispatch cannot run unrelated releases or stable publicat expect(runs("release-marketplace")).toBe(true); context.vars.OPENAPI_MCP_OIDC_READY = "true"; } + const f = await fixture(); + await writeFile( + join(f.root, "repository/.github/workflows/release.yml"), + await readFile( + new URL("../.github/workflows/release.yml", import.meta.url), + ), + ); + await f.adapter.verifyConditions({}, f.context); +}); + +test("public verification waits before install and stops installation when readiness fails", async () => { + const workflow = Bun.YAML.parse( + await readFile( + new URL("../.github/workflows/release.yml", import.meta.url), + "utf8", + ), + ) as { + jobs: Record; + }; + const script = workflow.jobs["release-openapi-mcp"].steps.find( + (step) => + step.name === "Verify fresh public install and registry attestations", + )?.run; + if (!script) throw new Error("Missing public verification step"); + const withoutReadiness = script + .split("\n") + .filter((line) => !line.includes(" wait-for-version ")) + .join("\n"); + const commands = ` +node() { + if [ "$1" = "-p" ]; then printf '1.2.3\\n'; return; fi + case "$2" in + wait-for-version) + test "$3" = "1.2.3" + printf 'wait 1.2.3\\n' >> "$TRACE" + if [ "$FAIL_READINESS" = "true" ]; then return 41; fi + touch "$READY" + ;; + verify-audit) + test "$5" = "1.2.3" + printf 'verify-audit\\n' >> "$TRACE" + printf '{}\\n' + ;; + *) + test "$1" = "node.mts" + printf 'consumer\\n' >> "$TRACE" + printf '{}\\n' + ;; + esac +} +npm() { + case "$1" in + init) printf 'init\\n' >> "$TRACE" ;; + install) + if [ ! -f "$READY" ]; then printf 'install-before-readiness\\n' >> "$TRACE"; return 42; fi + test "$*" = 'install --ignore-scripts --save-exact --registry=https://registry.npmjs.org/ @knitli/openapi-mcp@1.2.3' + printf 'install 1.2.3\\n' >> "$TRACE" + ;; + audit) + test "$*" = 'audit signatures --json --include-attestations' + printf 'audit\\n' >> "$TRACE" + printf '{}\\n' + ;; + *) return 43 ;; + esac +} +sha256sum() { printf 'checksum\\n' >> "$TRACE"; printf 'fixture-checksum\\n'; } +`; + for (const scenario of [ + { + script, + failure: "false", + exit: 0, + effects: + "init\nwait 1.2.3\ninstall 1.2.3\naudit\nverify-audit\nconsumer\nchecksum\n", + }, + { script, failure: "true", exit: 41, effects: "init\nwait 1.2.3\n" }, + { + script: withoutReadiness, + failure: "false", + exit: 42, + effects: "init\ninstall-before-readiness\n", + }, + ]) { + const root = await mkdtemp(join(tmpdir(), "openapi-verification-step-")); + await mkdir(join(root, "openapi-mcp-release")); + await writeFile( + join(root, "openapi-mcp-release/tested-artifact.json"), + '{"version":"1.2.3"}', + ); + await mkdir(join(root, "packages/openapi-mcp/test-consumers"), { + recursive: true, + }); + await writeFile( + join(root, "packages/openapi-mcp/test-consumers/node.mts"), + "// fixture consumer\n", + ); + const trace = join(root, "trace"); + const child = Bun.spawn( + [ + "/bin/bash", + "--noprofile", + "--norc", + "-e", + "-o", + "pipefail", + "-c", + commands + scenario.script, + ], + { + env: { + PATH: "/usr/bin:/bin", + RUNNER_TEMP: root, + GITHUB_WORKSPACE: root, + TRACE: trace, + READY: join(root, "ready"), + FAIL_READINESS: scenario.failure, + }, + stdout: "pipe", + stderr: "pipe", + }, + ); + expect(await child.exited).toBe(scenario.exit); + expect(await readFile(trace, "utf8")).toBe(scenario.effects); + } }); test("release rejects token authentication, bootstrap version, wrong package and wrong workflow context", async () => { @@ -308,6 +610,9 @@ test("release rejects token authentication, bootstrap version, wrong package and (f: Awaited>) => { Object.assign(f.context.env, { NODE_AUTH_TOKEN: "fixture-only" }); }, + (f: Awaited>) => { + Object.assign(f.context.env, { NPM_TOKEN: "" }); + }, (f: Awaited>) => { f.context.nextRelease.version = "0.0.0"; },