44 release :
55 types :
66 - published
7+ workflow_dispatch :
8+ inputs :
9+ source_run_id :
10+ description : Failed v0.1.0-alpha.1 Publish run containing the verified artifact
11+ required : true
12+ type : string
713
814permissions :
915 contents : read
@@ -15,6 +21,7 @@ concurrency:
1521jobs :
1622 verify :
1723 name : Verify the immutable release artifact
24+ if : github.event_name == 'release'
1825 runs-on : ubuntu-latest
1926 timeout-minutes : 30
2027 outputs :
@@ -343,3 +350,277 @@ jobs:
343350 process.exitCode = 1;
344351 });
345352 EOF
353+
354+ recover-verify :
355+ name : Verify the failed alpha.1 publication source
356+ if : github.event_name == 'workflow_dispatch'
357+ runs-on : ubuntu-latest
358+ timeout-minutes : 10
359+ outputs :
360+ dist-tag : next
361+ release-commit : ${{ steps.trust.outputs.release-commit }}
362+ source-run-id : ${{ steps.trust.outputs.source-run-id }}
363+ version : 0.1.0-alpha.1
364+ permissions :
365+ actions : read
366+ contents : read
367+ steps :
368+ - name : Check out the recovery implementation
369+ uses : actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
370+ with :
371+ fetch-depth : 0
372+ persist-credentials : false
373+ - name : Reject an untrusted recovery source
374+ id : trust
375+ env :
376+ EXPECTED_REPOSITORY : cometapi-dev/cometapi-node
377+ EXPECTED_TAG : v0.1.0-alpha.1
378+ GH_TOKEN : ${{ github.token }}
379+ SOURCE_RUN_ID : ${{ inputs.source_run_id }}
380+ shell : bash
381+ run : |
382+ set -euo pipefail
383+ if [[ "$GITHUB_REPOSITORY" != "$EXPECTED_REPOSITORY" || \
384+ "$GITHUB_REF" != "refs/heads/main" ]]; then
385+ echo "Recovery is restricted to the canonical repository's main branch." >&2
386+ exit 1
387+ fi
388+ if [[ ! "$SOURCE_RUN_ID" =~ ^[1-9][0-9]*$ ]]; then
389+ echo "source_run_id must be a positive integer." >&2
390+ exit 1
391+ fi
392+
393+ git fetch --no-tags origin \
394+ "+refs/tags/${EXPECTED_TAG}:refs/tags/${EXPECTED_TAG}" \
395+ "+refs/heads/main:refs/remotes/origin/main"
396+ release_commit="$(git rev-parse --verify "refs/tags/${EXPECTED_TAG}^{commit}")"
397+ if ! git merge-base --is-ancestor "$release_commit" refs/remotes/origin/main; then
398+ echo "The immutable release tag is not reachable from origin/main." >&2
399+ exit 1
400+ fi
401+
402+ release_json="$(gh api \
403+ "repos/${GITHUB_REPOSITORY}/releases/tags/${EXPECTED_TAG}")"
404+ run_json="$(gh api \
405+ "repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RUN_ID}")"
406+ jobs_json="$(gh api \
407+ "repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RUN_ID}/jobs?per_page=100")"
408+ artifacts_json="$(gh api \
409+ "repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RUN_ID}/artifacts?per_page=100")"
410+
411+ RELEASE_JSON="$release_json" RUN_JSON="$run_json" \
412+ JOBS_JSON="$jobs_json" ARTIFACTS_JSON="$artifacts_json" \
413+ RELEASE_COMMIT="$release_commit" EXPECTED_TAG="$EXPECTED_TAG" node <<'EOF'
414+ const release = JSON.parse(process.env.RELEASE_JSON);
415+ const run = JSON.parse(process.env.RUN_JSON);
416+ const jobs = JSON.parse(process.env.JOBS_JSON).jobs;
417+ const artifacts = JSON.parse(process.env.ARTIFACTS_JSON).artifacts;
418+ const expectedCommit = process.env.RELEASE_COMMIT;
419+ const expectedTag = process.env.EXPECTED_TAG;
420+
421+ const reject = (message) => {
422+ throw new Error(message);
423+ };
424+ if (
425+ release.tag_name !== expectedTag ||
426+ release.draft !== false ||
427+ release.prerelease !== true ||
428+ release.immutable !== true
429+ ) {
430+ reject("Recovery requires the published immutable alpha.1 prerelease.");
431+ }
432+ if (
433+ run.event !== "release" ||
434+ run.path !== ".github/workflows/publish.yml" ||
435+ run.head_branch !== expectedTag ||
436+ run.head_sha !== expectedCommit ||
437+ run.status !== "completed" ||
438+ run.conclusion !== "failure"
439+ ) {
440+ reject("The source run does not match the failed alpha.1 release workflow.");
441+ }
442+ const conclusions = new Map(jobs.map((job) => [job.name, job.conclusion]));
443+ if (
444+ conclusions.get("Verify the immutable release artifact") !== "success" ||
445+ conclusions.get("Verify the release tag against CometAPI") !== "success" ||
446+ conclusions.get(
447+ "Publish with npm Trusted Publishing or alpha.1 bootstrap",
448+ ) !== "failure"
449+ ) {
450+ reject("The source run does not have the required verify/live success boundary.");
451+ }
452+ const candidates = artifacts.filter(
453+ (artifact) =>
454+ artifact.name === "npm-package-0.1.0-alpha.1" &&
455+ artifact.expired === false,
456+ );
457+ if (candidates.length !== 1 || !candidates[0].digest) {
458+ reject("The source run must contain one unexpired verified alpha.1 artifact.");
459+ }
460+ EOF
461+
462+ echo "release-commit=${release_commit}" >> "$GITHUB_OUTPUT"
463+ echo "source-run-id=${SOURCE_RUN_ID}" >> "$GITHUB_OUTPUT"
464+
465+ recover-publish :
466+ name : Recover the verified alpha.1 npm publication
467+ needs :
468+ - recover-verify
469+ runs-on : ubuntu-latest
470+ timeout-minutes : 15
471+ environment :
472+ name : npm
473+ url : https://www.npmjs.com/package/cometapi/v/0.1.0-alpha.1
474+ permissions :
475+ actions : read
476+ contents : read
477+ id-token : write
478+ steps :
479+ - name : Check out the reviewed recovery implementation
480+ uses : actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
481+ with :
482+ persist-credentials : false
483+ - name : Set up Node.js 24
484+ uses : actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
485+ with :
486+ node-version : 24.x
487+ registry-url : https://registry.npmjs.org
488+ - name : Use a Trusted Publishing-capable npm CLI
489+ run : npm install --global npm@11.12.1
490+ - name : Download the original verified release artifact
491+ uses : actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
492+ with :
493+ github-token : ${{ github.token }}
494+ name : npm-package-0.1.0-alpha.1
495+ path : release-artifacts
496+ repository : cometapi-dev/cometapi-node
497+ run-id : ${{ needs.recover-verify.outputs.source-run-id }}
498+ - name : Publish the exact recovered artifact with provenance
499+ env :
500+ ALPHA1_BOOTSTRAP_ENABLED : ${{ vars.NPM_ALPHA1_BOOTSTRAP_ENABLED }}
501+ DIST_TAG : ${{ needs.recover-verify.outputs.dist-tag }}
502+ NODE_AUTH_TOKEN : ${{ secrets.NPM_ALPHA1_BOOTSTRAP_TOKEN }}
503+ VERSION : ${{ needs.recover-verify.outputs.version }}
504+ run : bash scripts/publish-artifact.sh
505+ - name : Verify the recovered public registry artifact
506+ env :
507+ DIST_TAG : ${{ needs.recover-verify.outputs.dist-tag }}
508+ VERSION : ${{ needs.recover-verify.outputs.version }}
509+ shell : bash
510+ run : |
511+ set -euo pipefail
512+ mapfile -t tarballs < <(find "$GITHUB_WORKSPACE/release-artifacts" -maxdepth 1 -type f -name '*.tgz' -print)
513+ if [[ "${#tarballs[@]}" -ne 1 ]]; then
514+ echo "Expected exactly one downloaded artifact for registry verification." >&2
515+ exit 1
516+ fi
517+ 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]}")"
518+ registry_ready="false"
519+ for attempt in {1..12}; do
520+ resolved="$(npm view "cometapi@${VERSION}" version 2>/dev/null || true)"
521+ tagged="$(npm view "cometapi@${DIST_TAG}" version 2>/dev/null || true)"
522+ registry_dist="$(npm view "cometapi@${VERSION}" dist --json 2>/dev/null || true)"
523+ if [[ "$resolved" == "$VERSION" && "$tagged" == "$VERSION" && -n "$registry_dist" ]] && \
524+ REGISTRY_DIST="$registry_dist" LOCAL_INTEGRITY="$local_integrity" node <<'EOF'
525+ let ready = false;
526+ try {
527+ const dist = JSON.parse(process.env.REGISTRY_DIST);
528+ ready =
529+ dist.integrity === process.env.LOCAL_INTEGRITY &&
530+ Boolean(dist.attestations?.url) &&
531+ dist.attestations?.provenance?.predicateType ===
532+ "https://slsa.dev/provenance/v1";
533+ } catch {}
534+ process.exitCode = ready ? 0 : 1;
535+ EOF
536+ then
537+ registry_ready="true"
538+ break
539+ fi
540+ if [[ "$attempt" -lt 12 ]]; then
541+ sleep 10
542+ fi
543+ done
544+ if [[ "$registry_ready" != "true" ]]; then
545+ echo "Registry state did not converge for cometapi@${VERSION}, ${DIST_TAG}, integrity, and provenance." >&2
546+ exit 1
547+ fi
548+
549+ verify_dir="$(mktemp -d)"
550+ cd "$verify_dir"
551+ npm init --yes >/dev/null
552+ npm install --ignore-scripts --no-audit --no-fund \
553+ "openai@6.47.0" "cometapi@${VERSION}"
554+ signatures_verified="false"
555+ for attempt in {1..3}; do
556+ if npm audit signatures; then
557+ signatures_verified="true"
558+ break
559+ fi
560+ if [[ "$attempt" -lt 3 ]]; then
561+ sleep 10
562+ fi
563+ done
564+ if [[ "$signatures_verified" != "true" ]]; then
565+ echo "Registry signature and provenance verification did not converge." >&2
566+ exit 1
567+ fi
568+ npm ls openai --all
569+ if [[ -d node_modules/cometapi/node_modules/openai ]]; then
570+ echo "The registry fixture contains a nested OpenAI installation." >&2
571+ exit 1
572+ fi
573+
574+ node --input-type=module <<'EOF'
575+ import assert from "node:assert/strict";
576+ import { CometAPI } from "cometapi";
577+
578+ const client = new CometAPI({
579+ apiKey: "mock-registry-key",
580+ maxRetries: 0,
581+ fetch: async () =>
582+ new Response(JSON.stringify({ object: "list", data: [] }), {
583+ status: 200,
584+ headers: { "content-type": "application/json" },
585+ }),
586+ });
587+ const models = await client.models.list();
588+ assert.deepEqual(models.data, []);
589+ EOF
590+
591+ node <<'EOF'
592+ const assert = require("node:assert/strict");
593+ const { CometAPI } = require("cometapi");
594+ const { APIError } = require("openai");
595+
596+ const client = new CometAPI({
597+ apiKey: "mock-registry-key",
598+ maxRetries: 0,
599+ fetch: async () =>
600+ new Response(
601+ JSON.stringify({
602+ error: {
603+ message: "mock registry failure",
604+ type: "invalid_request_error",
605+ },
606+ }),
607+ {
608+ status: 400,
609+ headers: { "content-type": "application/json" },
610+ },
611+ ),
612+ });
613+
614+ (async () => {
615+ let caught;
616+ try {
617+ await client.models.list();
618+ } catch (error) {
619+ caught = error;
620+ }
621+ assert.ok(caught instanceof APIError);
622+ })().catch((error) => {
623+ console.error(error);
624+ process.exitCode = 1;
625+ });
626+ EOF
0 commit comments