From 0cb84a42bd02a0373670de15a243e622b3ac9144 Mon Sep 17 00:00:00 2001 From: TensorNull Date: Mon, 27 Jul 2026 16:42:38 +0800 Subject: [PATCH] fix: recover alpha.1 registry publication --- .github/workflows/publish.yml | 281 +++++++++++++++++++++++++++++++ scripts/publish-artifact.sh | 1 + tests/publish-artifact.test.mjs | 14 +- tests/workflow-contract.test.mjs | 31 +++- 4 files changed, 321 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 538657e..3948e5a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,6 +4,12 @@ on: release: types: - published + workflow_dispatch: + inputs: + source_run_id: + description: Failed v0.1.0-alpha.1 Publish run containing the verified artifact + required: true + type: string permissions: contents: read @@ -15,6 +21,7 @@ concurrency: jobs: verify: name: Verify the immutable release artifact + if: github.event_name == 'release' runs-on: ubuntu-latest timeout-minutes: 30 outputs: @@ -343,3 +350,277 @@ jobs: process.exitCode = 1; }); EOF + + recover-verify: + name: Verify the failed alpha.1 publication source + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + dist-tag: next + release-commit: ${{ steps.trust.outputs.release-commit }} + source-run-id: ${{ steps.trust.outputs.source-run-id }} + version: 0.1.0-alpha.1 + permissions: + actions: read + contents: read + steps: + - name: Check out the recovery implementation + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + persist-credentials: false + - name: Reject an untrusted recovery source + id: trust + env: + EXPECTED_REPOSITORY: cometapi-dev/cometapi-node + EXPECTED_TAG: v0.1.0-alpha.1 + GH_TOKEN: ${{ github.token }} + SOURCE_RUN_ID: ${{ inputs.source_run_id }} + shell: bash + run: | + set -euo pipefail + if [[ "$GITHUB_REPOSITORY" != "$EXPECTED_REPOSITORY" || \ + "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "Recovery is restricted to the canonical repository's main branch." >&2 + exit 1 + fi + if [[ ! "$SOURCE_RUN_ID" =~ ^[1-9][0-9]*$ ]]; then + echo "source_run_id must be a positive integer." >&2 + exit 1 + fi + + git fetch --no-tags origin \ + "+refs/tags/${EXPECTED_TAG}:refs/tags/${EXPECTED_TAG}" \ + "+refs/heads/main:refs/remotes/origin/main" + release_commit="$(git rev-parse --verify "refs/tags/${EXPECTED_TAG}^{commit}")" + if ! git merge-base --is-ancestor "$release_commit" refs/remotes/origin/main; then + echo "The immutable release tag is not reachable from origin/main." >&2 + exit 1 + fi + + release_json="$(gh api \ + "repos/${GITHUB_REPOSITORY}/releases/tags/${EXPECTED_TAG}")" + run_json="$(gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RUN_ID}")" + jobs_json="$(gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RUN_ID}/jobs?per_page=100")" + artifacts_json="$(gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RUN_ID}/artifacts?per_page=100")" + + RELEASE_JSON="$release_json" RUN_JSON="$run_json" \ + JOBS_JSON="$jobs_json" ARTIFACTS_JSON="$artifacts_json" \ + RELEASE_COMMIT="$release_commit" EXPECTED_TAG="$EXPECTED_TAG" node <<'EOF' + const release = JSON.parse(process.env.RELEASE_JSON); + const run = JSON.parse(process.env.RUN_JSON); + const jobs = JSON.parse(process.env.JOBS_JSON).jobs; + const artifacts = JSON.parse(process.env.ARTIFACTS_JSON).artifacts; + const expectedCommit = process.env.RELEASE_COMMIT; + const expectedTag = process.env.EXPECTED_TAG; + + const reject = (message) => { + throw new Error(message); + }; + if ( + release.tag_name !== expectedTag || + release.draft !== false || + release.prerelease !== true || + release.immutable !== true + ) { + reject("Recovery requires the published immutable alpha.1 prerelease."); + } + if ( + run.event !== "release" || + run.path !== ".github/workflows/publish.yml" || + run.head_branch !== expectedTag || + run.head_sha !== expectedCommit || + run.status !== "completed" || + run.conclusion !== "failure" + ) { + reject("The source run does not match the failed alpha.1 release workflow."); + } + const conclusions = new Map(jobs.map((job) => [job.name, job.conclusion])); + if ( + conclusions.get("Verify the immutable release artifact") !== "success" || + conclusions.get("Verify the release tag against CometAPI") !== "success" || + conclusions.get( + "Publish with npm Trusted Publishing or alpha.1 bootstrap", + ) !== "failure" + ) { + reject("The source run does not have the required verify/live success boundary."); + } + const candidates = artifacts.filter( + (artifact) => + artifact.name === "npm-package-0.1.0-alpha.1" && + artifact.expired === false, + ); + if (candidates.length !== 1 || !candidates[0].digest) { + reject("The source run must contain one unexpired verified alpha.1 artifact."); + } + EOF + + echo "release-commit=${release_commit}" >> "$GITHUB_OUTPUT" + echo "source-run-id=${SOURCE_RUN_ID}" >> "$GITHUB_OUTPUT" + + recover-publish: + name: Recover the verified alpha.1 npm publication + needs: + - recover-verify + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: + name: npm + url: https://www.npmjs.com/package/cometapi/v/0.1.0-alpha.1 + permissions: + actions: read + contents: read + id-token: write + steps: + - name: Check out the reviewed recovery implementation + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - name: Set up Node.js 24 + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 24.x + registry-url: https://registry.npmjs.org + - name: Use a Trusted Publishing-capable npm CLI + run: npm install --global npm@11.12.1 + - name: Download the original verified release artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + github-token: ${{ github.token }} + name: npm-package-0.1.0-alpha.1 + path: release-artifacts + repository: cometapi-dev/cometapi-node + run-id: ${{ needs.recover-verify.outputs.source-run-id }} + - name: Publish the exact recovered artifact with provenance + env: + ALPHA1_BOOTSTRAP_ENABLED: ${{ vars.NPM_ALPHA1_BOOTSTRAP_ENABLED }} + DIST_TAG: ${{ needs.recover-verify.outputs.dist-tag }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_ALPHA1_BOOTSTRAP_TOKEN }} + VERSION: ${{ needs.recover-verify.outputs.version }} + run: bash scripts/publish-artifact.sh + - name: Verify the recovered public registry artifact + env: + DIST_TAG: ${{ needs.recover-verify.outputs.dist-tag }} + VERSION: ${{ needs.recover-verify.outputs.version }} + shell: bash + run: | + set -euo pipefail + mapfile -t tarballs < <(find "$GITHUB_WORKSPACE/release-artifacts" -maxdepth 1 -type f -name '*.tgz' -print) + if [[ "${#tarballs[@]}" -ne 1 ]]; then + echo "Expected exactly one downloaded artifact for registry verification." >&2 + exit 1 + fi + local_integrity="$(node -e 'const {createHash}=require("node:crypto");const {readFileSync}=require("node:fs");process.stdout.write("sha512-"+createHash("sha512").update(readFileSync(process.argv[1])).digest("base64"))' "${tarballs[0]}")" + registry_ready="false" + for attempt in {1..12}; do + resolved="$(npm view "cometapi@${VERSION}" version 2>/dev/null || true)" + tagged="$(npm view "cometapi@${DIST_TAG}" version 2>/dev/null || true)" + registry_dist="$(npm view "cometapi@${VERSION}" dist --json 2>/dev/null || true)" + if [[ "$resolved" == "$VERSION" && "$tagged" == "$VERSION" && -n "$registry_dist" ]] && \ + REGISTRY_DIST="$registry_dist" LOCAL_INTEGRITY="$local_integrity" node <<'EOF' + let ready = false; + try { + const dist = JSON.parse(process.env.REGISTRY_DIST); + ready = + dist.integrity === process.env.LOCAL_INTEGRITY && + Boolean(dist.attestations?.url) && + dist.attestations?.provenance?.predicateType === + "https://slsa.dev/provenance/v1"; + } catch {} + process.exitCode = ready ? 0 : 1; + EOF + then + registry_ready="true" + break + fi + if [[ "$attempt" -lt 12 ]]; then + sleep 10 + fi + done + if [[ "$registry_ready" != "true" ]]; then + echo "Registry state did not converge for cometapi@${VERSION}, ${DIST_TAG}, integrity, and provenance." >&2 + exit 1 + fi + + verify_dir="$(mktemp -d)" + cd "$verify_dir" + npm init --yes >/dev/null + npm install --ignore-scripts --no-audit --no-fund \ + "openai@6.47.0" "cometapi@${VERSION}" + signatures_verified="false" + for attempt in {1..3}; do + if npm audit signatures; then + signatures_verified="true" + break + fi + if [[ "$attempt" -lt 3 ]]; then + sleep 10 + fi + done + if [[ "$signatures_verified" != "true" ]]; then + echo "Registry signature and provenance verification did not converge." >&2 + exit 1 + fi + npm ls openai --all + if [[ -d node_modules/cometapi/node_modules/openai ]]; then + echo "The registry fixture contains a nested OpenAI installation." >&2 + exit 1 + fi + + node --input-type=module <<'EOF' + import assert from "node:assert/strict"; + import { CometAPI } from "cometapi"; + + const client = new CometAPI({ + apiKey: "mock-registry-key", + maxRetries: 0, + fetch: async () => + new Response(JSON.stringify({ object: "list", data: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + }); + const models = await client.models.list(); + assert.deepEqual(models.data, []); + EOF + + node <<'EOF' + const assert = require("node:assert/strict"); + const { CometAPI } = require("cometapi"); + const { APIError } = require("openai"); + + const client = new CometAPI({ + apiKey: "mock-registry-key", + maxRetries: 0, + fetch: async () => + new Response( + JSON.stringify({ + error: { + message: "mock registry failure", + type: "invalid_request_error", + }, + }), + { + status: 400, + headers: { "content-type": "application/json" }, + }, + ), + }); + + (async () => { + let caught; + try { + await client.models.list(); + } catch (error) { + caught = error; + } + assert.ok(caught instanceof APIError); + })().catch((error) => { + console.error(error); + process.exitCode = 1; + }); + EOF diff --git a/scripts/publish-artifact.sh b/scripts/publish-artifact.sh index 4ce4b75..82c79cc 100644 --- a/scripts/publish-artifact.sh +++ b/scripts/publish-artifact.sh @@ -14,6 +14,7 @@ if [[ "$bootstrap_enabled" == "true" && \ exit 1 fi +artifact_directory="$(cd "$artifact_directory" && pwd -P)" shopt -s nullglob tarballs=("$artifact_directory"/*.tgz) if [[ "${#tarballs[@]}" -ne 1 ]]; then diff --git a/tests/publish-artifact.test.mjs b/tests/publish-artifact.test.mjs index 4003e56..b0168af 100644 --- a/tests/publish-artifact.test.mjs +++ b/tests/publish-artifact.test.mjs @@ -4,6 +4,7 @@ import { mkdirSync, mkdtempSync, readFileSync, + realpathSync, rmSync, writeFileSync, } from "node:fs"; @@ -54,7 +55,7 @@ function fixture() { ].join("\n"), ); chmodSync(npm, 0o755); - return { artifacts, bin, log }; + return { artifacts, bin, log, root }; } function runPublish({ @@ -63,13 +64,14 @@ function runPublish({ token = "", version = "0.1.0-alpha.1", } = {}) { - const { artifacts, bin, log } = fixture(); + const { bin, log, root } = fixture(); const result = spawnSync("bash", [script], { + cwd: root, encoding: "utf8", env: { ...process.env, ALPHA1_BOOTSTRAP_ENABLED: bootstrapEnabled, - ARTIFACT_DIRECTORY: artifacts, + ARTIFACT_DIRECTORY: "artifacts", DIST_TAG: distTag, NODE_AUTH_TOKEN: token, NPM_CALL_LOG: log, @@ -80,6 +82,7 @@ function runPublish({ return { log: existsSync(log) ? readFileSync(log, "utf8") : "", result, + root, }; } @@ -91,7 +94,7 @@ describe("publish artifact authentication", () => { }); it("allows the protected token bootstrap for alpha.1 on next", () => { - const { log, result } = runPublish({ + const { log, result, root } = runPublish({ bootstrapEnabled: "true", token: "opaque", }); @@ -99,6 +102,9 @@ describe("publish artifact authentication", () => { expect(log).toMatch( /^token-present\npublish .* --provenance --tag next\n$/, ); + expect(log).toContain( + `publish ${join(realpathSync(root), "artifacts", "cometapi.tgz")} --access public --provenance --tag next`, + ); }); it.each([ diff --git a/tests/workflow-contract.test.mjs b/tests/workflow-contract.test.mjs index f6cbe1f..5ba24b6 100644 --- a/tests/workflow-contract.test.mjs +++ b/tests/workflow-contract.test.mjs @@ -121,7 +121,28 @@ describe("GitHub Actions workflow contract", () => { expect(publish).toContain("run: bash scripts/publish-artifact.sh"); expect( matches(publishWorkflow, /secrets\.NPM_ALPHA1_BOOTSTRAP_TOKEN/g), - ).toHaveLength(1); + ).toHaveLength(2); + }); + + it("limits manual publication recovery to the failed immutable alpha.1 run", () => { + const publishWorkflow = workflow("publish.yml"); + const recoveryVerify = job(publishWorkflow, "recover-verify"); + const recoveryPublish = job(publishWorkflow, "recover-publish"); + + expect(recoveryVerify).toContain("EXPECTED_TAG: v0.1.0-alpha.1"); + expect(recoveryVerify).toContain( + 'run.path !== ".github/workflows/publish.yml"', + ); + expect(recoveryVerify).toContain("release.immutable !== true"); + expect(recoveryVerify).toContain( + 'conclusions.get("Verify the release tag against CometAPI") !== "success"', + ); + expect(recoveryVerify).toContain( + 'artifact.name === "npm-package-0.1.0-alpha.1"', + ); + expect(recoveryPublish).toContain("environment:\n name: npm"); + expect(recoveryPublish).toContain("id-token: write"); + expect(recoveryPublish).toContain("run: bash scripts/publish-artifact.sh"); }); it("pins third-party actions and disables checkout credential persistence", () => { @@ -164,9 +185,15 @@ describe("GitHub Actions workflow contract", () => { expect(releasePlease).toMatch(/^ {6}pull-requests: write$/m); const publishWorkflow = workflow("publish.yml"); - expect(matches(publishWorkflow, /^\s+id-token: write$/gm)).toHaveLength(1); + expect(matches(publishWorkflow, /^\s+id-token: write$/gm)).toHaveLength(2); expect(job(publishWorkflow, "verify")).not.toContain("id-token: write"); expect(job(publishWorkflow, "live-smoke")).not.toContain("id-token: write"); expect(job(publishWorkflow, "publish")).toContain("id-token: write"); + expect(job(publishWorkflow, "recover-verify")).not.toContain( + "id-token: write", + ); + expect(job(publishWorkflow, "recover-publish")).toContain( + "id-token: write", + ); }); });