Skip to content

Commit 0cb84a4

Browse files
committed
fix: recover alpha.1 registry publication
1 parent 5638f73 commit 0cb84a4

4 files changed

Lines changed: 321 additions & 6 deletions

File tree

.github/workflows/publish.yml

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ on:
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

814
permissions:
915
contents: read
@@ -15,6 +21,7 @@ concurrency:
1521
jobs:
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

scripts/publish-artifact.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ if [[ "$bootstrap_enabled" == "true" && \
1414
exit 1
1515
fi
1616

17+
artifact_directory="$(cd "$artifact_directory" && pwd -P)"
1718
shopt -s nullglob
1819
tarballs=("$artifact_directory"/*.tgz)
1920
if [[ "${#tarballs[@]}" -ne 1 ]]; then

tests/publish-artifact.test.mjs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
mkdirSync,
55
mkdtempSync,
66
readFileSync,
7+
realpathSync,
78
rmSync,
89
writeFileSync,
910
} from "node:fs";
@@ -54,7 +55,7 @@ function fixture() {
5455
].join("\n"),
5556
);
5657
chmodSync(npm, 0o755);
57-
return { artifacts, bin, log };
58+
return { artifacts, bin, log, root };
5859
}
5960

6061
function runPublish({
@@ -63,13 +64,14 @@ function runPublish({
6364
token = "",
6465
version = "0.1.0-alpha.1",
6566
} = {}) {
66-
const { artifacts, bin, log } = fixture();
67+
const { bin, log, root } = fixture();
6768
const result = spawnSync("bash", [script], {
69+
cwd: root,
6870
encoding: "utf8",
6971
env: {
7072
...process.env,
7173
ALPHA1_BOOTSTRAP_ENABLED: bootstrapEnabled,
72-
ARTIFACT_DIRECTORY: artifacts,
74+
ARTIFACT_DIRECTORY: "artifacts",
7375
DIST_TAG: distTag,
7476
NODE_AUTH_TOKEN: token,
7577
NPM_CALL_LOG: log,
@@ -80,6 +82,7 @@ function runPublish({
8082
return {
8183
log: existsSync(log) ? readFileSync(log, "utf8") : "",
8284
result,
85+
root,
8386
};
8487
}
8588

@@ -91,14 +94,17 @@ describe("publish artifact authentication", () => {
9194
});
9295

9396
it("allows the protected token bootstrap for alpha.1 on next", () => {
94-
const { log, result } = runPublish({
97+
const { log, result, root } = runPublish({
9598
bootstrapEnabled: "true",
9699
token: "opaque",
97100
});
98101
expect(result.status, result.stderr).toBe(0);
99102
expect(log).toMatch(
100103
/^token-present\npublish .* --provenance --tag next\n$/,
101104
);
105+
expect(log).toContain(
106+
`publish ${join(realpathSync(root), "artifacts", "cometapi.tgz")} --access public --provenance --tag next`,
107+
);
102108
});
103109

104110
it.each([

0 commit comments

Comments
 (0)