mirror of
https://github.com/przeprogramowani/10x-cli.git
synced 2026-09-19 03:30:01 +08:00
fix(release): wait for npm metadata before verifying publish
Direct publish-npm verified immediately after npm accepted 1.22.1, while registry metadata still lacked dist.tarball. Poll until integrity exists, then keep the strict pack/gitHead compare. When the version is already on npm and matches the pack from cli_sha, skip publish and only complete tag/Release.
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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>;
|
||||
@@ -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; });
|
||||
}
|
||||
@@ -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,}/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user