diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 842a032..9092ee2 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -67,37 +67,29 @@ jobs: echo "OUT=$OUT" >> "$GITHUB_ENV" env: OUT: ${{ runner.temp }}/release-package - - name: Refuse a version that already exists on the registry + - name: Decide whether to publish or resume a matching registry copy + id: decide + run: node scripts/publish-npm-verify.mjs decide + env: + OUT: ${{ runner.temp }}/release-package + CLI_SHA: ${{ inputs.cli_sha }} + - name: Publish the exact directory once + if: steps.decide.outputs.action == 'publish' run: | + set -eu VERSION=$(node -p 'JSON.parse(require("fs").readFileSync(process.env.OUT + "/candidate.json","utf8")).version') if npm view "@przeprogramowani/10x-cli@$VERSION" version >/dev/null 2>&1; then echo "::error::@przeprogramowani/10x-cli@$VERSION already published; never republish"; exit 1 fi - - name: Publish the exact directory once - run: | - set -eu node -e 'require("fs").writeFileSync(process.env.NPM_CONFIG_USERCONFIG, "registry=https://registry.npmjs.org/\n//registry.npmjs.org/:_authToken=" + process.env.NPM_TOKEN + "\n", { mode: 0o600 })' npm publish . --ignore-scripts --access public env: NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/release-npmrc NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Verify actual registry bytes against the pack - run: | - set -eu - node -e ' - const fs = require("fs"), crypto = require("crypto"); - const c = JSON.parse(fs.readFileSync(process.env.OUT + "/candidate.json", "utf8")); - (async () => { - const meta = await (await fetch(`https://registry.npmjs.org/@przeprogramowani%2f10x-cli/${c.version}`)).json(); - const bytes = Buffer.from(await (await fetch(meta.dist.tarball)).arrayBuffer()); - const integrity = "sha512-" + crypto.createHash("sha512").update(bytes).digest("base64"); - const result = { version: meta.version, gitHead: meta.gitHead, expectedIntegrity: c.integrity, registryIntegrity: meta.dist.integrity, actualIntegrity: integrity, sourceSha: process.env.CLI_SHA }; - console.log(JSON.stringify(result)); - fs.writeFileSync(process.env.OUT + "/published-result.json", JSON.stringify(result)); - if (meta.gitHead !== process.env.CLI_SHA || meta.dist.integrity !== c.integrity || integrity !== c.integrity) throw new Error("Published package differs from the pack; release incomplete, never republish"); - })().catch((e) => { console.error(e.message); process.exit(1); }); - ' + run: node scripts/publish-npm-verify.mjs verify env: + OUT: ${{ runner.temp }}/release-package CLI_SHA: ${{ inputs.cli_sha }} - name: Tag the published source # The tag may already exist (pushed by hand, or a previous attempt). diff --git a/package.json b/package.json index 92d9de6..9bd0a70 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@przeprogramowani/10x-cli", - "version": "1.22.1", + "version": "1.22.2", "description": "Open-source CLI for 10xDevs course content", "repository": { "type": "git", diff --git a/scripts/publish-npm-verify.d.mts b/scripts/publish-npm-verify.d.mts new file mode 100644 index 0000000..e00bc08 --- /dev/null +++ b/scripts/publish-npm-verify.d.mts @@ -0,0 +1,7 @@ +export const DEFAULT_WAIT: { timeoutMs: number; initialDelayMs: number; maxDelayMs: number }; +export function metadataIsComplete(meta: any): boolean; +export function fetchVersionMetadata(version: string, fetchFn?: typeof fetch): Promise<{ status: number; body: any }>; +export function waitForPublishedMetadata(version: string, options?: any): Promise; +export function assertPackMatchesRegistry(input: any): any; +export function downloadTarball(metadata: any, fetchFn?: typeof fetch): Promise; +export function classifyPublishDecision(input: any): Promise; diff --git a/scripts/publish-npm-verify.mjs b/scripts/publish-npm-verify.mjs new file mode 100644 index 0000000..620194a --- /dev/null +++ b/scripts/publish-npm-verify.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync, appendFileSync } from "node:fs"; +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const REGISTRY_VERSION = (version) => `https://registry.npmjs.org/@przeprogramowani%2f10x-cli/${version}`; +export const DEFAULT_WAIT = { timeoutMs: 180_000, initialDelayMs: 1_000, maxDelayMs: 8_000 }; + +const sha = (s) => typeof s === "string" && /^[a-f0-9]{40}$/.test(s); +const integrityOf = (bytes) => "sha512-" + createHash("sha512").update(bytes).digest("base64"); + +export function metadataIsComplete(meta) { + return Boolean(meta && meta.version && meta.dist && typeof meta.dist.tarball === "string" && meta.dist.tarball.startsWith("https://") && typeof meta.dist.integrity === "string" && meta.dist.integrity.startsWith("sha512-")); +} + +export async function fetchVersionMetadata(version, fetchFn = fetch) { + const response = await fetchFn(REGISTRY_VERSION(version), { signal: AbortSignal.timeout(30000) }); + const text = await response.text(); + let body = null; + try { body = JSON.parse(text); } catch { body = null; } + return { status: response.status, body }; +} + +export async function waitForPublishedMetadata(version, { fetchFn = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = () => Date.now(), log = console.error, timeoutMs = DEFAULT_WAIT.timeoutMs, initialDelayMs = DEFAULT_WAIT.initialDelayMs, maxDelayMs = DEFAULT_WAIT.maxDelayMs } = {}) { + const deadline = now() + timeoutMs; + let delay = initialDelayMs, attempt = 0; + while (true) { + attempt += 1; + const { status, body } = await fetchVersionMetadata(version, fetchFn); + if (status === 200 && metadataIsComplete(body)) { + log(`registry metadata ready for ${version} after ${attempt} attempt(s)`); + return body; + } + const remaining = deadline - now(); + if (remaining <= 0) throw new Error(`Registry metadata for ${version} did not become complete within ${timeoutMs}ms after ${attempt} attempt(s); never republish`); + log(`registry wait attempt ${attempt}: status=${status} complete=${metadataIsComplete(body)} next=${Math.min(delay, remaining)}ms`); + await sleep(Math.min(delay, remaining)); + delay = Math.min(delay * 2, maxDelayMs); + } +} + +export function assertPackMatchesRegistry({ metadata, tarballBytes, expectedIntegrity, sourceSha }) { + if (!sha(sourceSha)) throw new Error("Exact candidate SHA required"); + if (!expectedIntegrity || !expectedIntegrity.startsWith("sha512-")) throw new Error("Pack integrity required"); + if (!metadataIsComplete(metadata)) throw new Error("Complete registry metadata required"); + const actualIntegrity = integrityOf(tarballBytes); + if (metadata.gitHead !== sourceSha || metadata.dist.integrity !== expectedIntegrity || actualIntegrity !== expectedIntegrity || metadata.dist.integrity !== actualIntegrity) { + throw new Error("Published package differs from the pack; release incomplete, never republish"); + } + return { version: metadata.version, gitHead: metadata.gitHead, expectedIntegrity, registryIntegrity: metadata.dist.integrity, actualIntegrity, sourceSha }; +} + +export async function downloadTarball(metadata, fetchFn = fetch) { + const response = await fetchFn(metadata.dist.tarball, { signal: AbortSignal.timeout(60000) }); + if (!response.ok) throw new Error("Registry tarball unavailable"); + return Buffer.from(await response.arrayBuffer()); +} + +export async function classifyPublishDecision({ version, expectedIntegrity, sourceSha, fetchFn = fetch, wait }) { + const { status, body } = await fetchVersionMetadata(version, fetchFn); + if (status === 404 || (body && body.error === "Not found")) return { action: "publish" }; + const metadata = metadataIsComplete(body) ? body : await waitForPublishedMetadata(version, { fetchFn, ...wait }); + const bytes = await downloadTarball(metadata, fetchFn); + const result = assertPackMatchesRegistry({ metadata, tarballBytes: bytes, expectedIntegrity, sourceSha }); + return { action: "resume", result }; +} + +function candidate(out) { + return JSON.parse(readFileSync(`${out}/candidate.json`, "utf8")); +} + +function writeGithubOutput(name, value) { + if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`); +} + +async function decide() { + const out = process.env.OUT, sourceSha = process.env.CLI_SHA, c = candidate(out); + const decision = await classifyPublishDecision({ version: c.version, expectedIntegrity: c.integrity, sourceSha }); + writeGithubOutput("action", decision.action); + if (decision.result) writeFileSync(`${out}/published-result.json`, JSON.stringify(decision.result)); + console.log(JSON.stringify({ action: decision.action, version: c.version })); +} + +async function verify() { + const out = process.env.OUT, sourceSha = process.env.CLI_SHA, c = candidate(out); + const metadata = await waitForPublishedMetadata(c.version); + const bytes = await downloadTarball(metadata); + const result = assertPackMatchesRegistry({ metadata, tarballBytes: bytes, expectedIntegrity: c.integrity, sourceSha }); + writeFileSync(`${out}/published-result.json`, JSON.stringify(result)); + console.log(JSON.stringify(result)); +} + +function isEntrypoint() { + try { return Boolean(process.argv[1]) && realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); } + catch { return false; } +} + +if (isEntrypoint()) { + const command = process.argv[2]; + const run = command === "decide" ? decide : command === "verify" ? verify : null; + if (!run) { console.error("publish-npm-verify requires decide or verify"); process.exitCode = 2; } + else run().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; }); +} diff --git a/tests/publish-npm-verify.test.ts b/tests/publish-npm-verify.test.ts new file mode 100644 index 0000000..9ea5c09 --- /dev/null +++ b/tests/publish-npm-verify.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { parse } from "yaml"; +import { assertPackMatchesRegistry, classifyPublishDecision, metadataIsComplete, waitForPublishedMetadata } from "../scripts/publish-npm-verify.mjs"; + +const sha = (c: string) => c.repeat(40); +const integrity = "sha512-UpH51iLC2WQmjhzVj2HbfOu+79ob0EtcQ3XI2epIHrEq3Vl8NxnBoqjCSHCRVdvUNXCgmmCC8kU/06IW/lsGew=="; +const tarball = "https://registry.npmjs.org/@przeprogramowani/10x-cli/-/10x-cli-1.22.1.tgz"; +const complete = { version: "1.22.1", gitHead: sha("d"), dist: { tarball, integrity } }; + +describe("publish-npm registry wait and resume", () => { + it("polls until dist.tarball and integrity exist, then matches pack bytes", async () => { + const log: string[] = []; + let n = 0; + const fetchFn = async () => { + n += 1; + if (n === 1) return { status: 404, text: async () => '{"error":"Not found"}', ok: false }; + if (n === 2) return { status: 200, text: async () => JSON.stringify({ version: "1.22.1" }), ok: true }; + return { status: 200, text: async () => JSON.stringify(complete), ok: true }; + }; + const meta = await waitForPublishedMetadata("1.22.1", { fetchFn: fetchFn as any, sleep: async () => {}, now: (() => { let t = 0; return () => (t += 1); })(), log: (m: string) => log.push(m), timeoutMs: 100, initialDelayMs: 1, maxDelayMs: 1 }); + expect(metadataIsComplete(meta)).toBe(true); + expect(n).toBe(3); + expect(log.some((line) => line.includes("attempt 1"))).toBe(true); + expect(log.at(-1)).toContain("ready"); + }); + + it("fails closed when the wait window expires without complete metadata", async () => { + const fetchFn = async () => ({ status: 200, text: async () => JSON.stringify({ version: "1.22.1" }), ok: true }); + await expect(waitForPublishedMetadata("1.22.1", { fetchFn: fetchFn as any, sleep: async () => {}, now: (() => { let t = 0; return () => (t += 50); })(), log: () => {}, timeoutMs: 100, initialDelayMs: 1, maxDelayMs: 1 })).rejects.toThrow(/never republish/); + }); + + it("rejects a registry copy whose gitHead or integrity differs from the pack", () => { + expect(() => assertPackMatchesRegistry({ metadata: complete, tarballBytes: Buffer.from("nope"), expectedIntegrity: integrity, sourceSha: sha("d") })).toThrow(/never republish/); + expect(() => assertPackMatchesRegistry({ metadata: { ...complete, gitHead: sha("e") }, tarballBytes: Buffer.from("x"), expectedIntegrity: integrity, sourceSha: sha("d") })).toThrow(/never republish/); + }); + + it("publishes when the version is absent and resumes when registry bytes already match", async () => { + const missing = async (url: string) => { + if (String(url).includes("/1.22.1") && !String(url).includes("/-/")) return { status: 404, text: async () => '{"error":"Not found"}', ok: false }; + throw new Error(url); + }; + expect(await classifyPublishDecision({ version: "1.22.1", expectedIntegrity: integrity, sourceSha: sha("d"), fetchFn: missing as any })).toEqual({ action: "publish" }); + + const bytes = Buffer.from("pack-bytes"); + const { createHash } = await import("node:crypto"); + const matching = "sha512-" + createHash("sha512").update(bytes).digest("base64"); + const meta = { version: "1.22.1", gitHead: sha("d"), dist: { tarball, integrity: matching } }; + const fetchFn = async (url: string) => { + if (String(url) === tarball) return { ok: true, arrayBuffer: async () => bytes }; + return { status: 200, text: async () => JSON.stringify(meta), ok: true }; + }; + const decision = await classifyPublishDecision({ version: "1.22.1", expectedIntegrity: matching, sourceSha: sha("d"), fetchFn: fetchFn as any }); + expect(decision.action).toBe("resume"); + expect(decision.result?.gitHead).toBe(sha("d")); + expect(decision.result?.actualIntegrity).toBe(matching); + }); +}); + +describe("publish-npm workflow uses bounded verify and resume", () => { + it("calls the verifier for decide/verify and publishes only when decide says publish", () => { + const workflow = parse(readFileSync(new URL("../.github/workflows/publish-npm.yml", import.meta.url), "utf8")); + const steps = workflow.jobs.publish.steps; + const decide = steps.find((step: any) => step.id === "decide"); + expect(decide.run).toBe("node scripts/publish-npm-verify.mjs decide"); + expect(decide.env.CLI_SHA).toBe("${{ inputs.cli_sha }}"); + const publish = steps.find((step: any) => step.name === "Publish the exact directory once"); + expect(publish.if).toBe("steps.decide.outputs.action == 'publish'"); + expect(publish.run).toContain("npm publish . --ignore-scripts --access public"); + const verify = steps.find((step: any) => step.name === "Verify actual registry bytes against the pack"); + expect(verify.run).toBe("node scripts/publish-npm-verify.mjs verify"); + expect(verify.if).toBeUndefined(); + const source = readFileSync(new URL("../scripts/publish-npm-verify.mjs", import.meta.url), "utf8"); + expect(source).toContain("never republish"); + expect(source).not.toMatch(/timeoutMs:\s*[5-9]\d{5,}/); + }); +});