Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 11 additions & 19 deletions .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 7 additions & 0 deletions scripts/publish-npm-verify.d.mts
Original file line number Diff line number Diff line change
@@ -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<any>;
export function assertPackMatchesRegistry(input: any): any;
export function downloadTarball(metadata: any, fetchFn?: typeof fetch): Promise<Buffer>;
export function classifyPublishDecision(input: any): Promise<any>;
104 changes: 104 additions & 0 deletions scripts/publish-npm-verify.mjs
Original file line number Diff line number Diff line change
@@ -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; });
}
77 changes: 77 additions & 0 deletions tests/publish-npm-verify.test.ts
Original file line number Diff line number Diff line change
@@ -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,}/);
});
});
Loading