From 6201c9b226e81fbb5d100067d70b25239c8195fc Mon Sep 17 00:00:00 2001 From: Zk-nd3r Date: Mon, 3 Aug 2026 20:03:54 -0700 Subject: [PATCH 1/2] fix release provenance for 0.2.1 --- .github/workflows/publish.yml | 40 +++++++++++++++ CHANGELOG.md | 16 ++++++ PROVENANCE.md | 17 +++++++ package-lock.json | 4 +- package.json | 6 ++- scripts/check-release-postpack.mjs | 5 ++ scripts/check-release.mjs | 82 ++++++++++++++++++++++++++++++ scripts/test-packed.mjs | 14 ++++- scripts/test-release-gate.mjs | 68 +++++++++++++++++++++++++ scripts/verify-published.mjs | 74 +++++++++++++++++++++++++++ 10 files changed, 320 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 scripts/check-release-postpack.mjs create mode 100644 scripts/check-release.mjs create mode 100644 scripts/test-release-gate.mjs create mode 100644 scripts/verify-published.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..f4e9db5 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,40 @@ +name: publish + +on: + push: + tags: + - "v*" + +permissions: + contents: read + id-token: write + +concurrency: + group: npm-publish-${{ github.ref }} + cancel-in-progress: false + +jobs: + npm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + fetch-depth: 0 + fetch-tags: true + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + package-manager-cache: false + - run: npm ci + - run: npm test + - run: npx --no-install playwright install --with-deps chromium + - run: npm run test:packed + env: + ZAP1_PACK_RESULT: ${{ runner.temp }}/zap1-preflight.json + - name: Publish from exact tagged checkout + shell: bash + run: npm publish --access public --provenance --json > "$RUNNER_TEMP/zap1-publish.raw.json" + - name: Verify registry source and artifact provenance + shell: bash + run: node scripts/verify-published.mjs "$RUNNER_TEMP/zap1-preflight.json" "$RUNNER_TEMP/zap1-publish.raw.json" diff --git a/CHANGELOG.md b/CHANGELOG.md index c07b3dc..cdda690 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.2.1 + +- Repair npm source provenance after `0.2.0` recorded an unrelated parent + repository commit as its `gitHead` even though its artifact bytes matched + the tagged source. +- Add source-directory pre- and post-pack publish gates requiring a clean + checkout at the exact annotated version tag, invoked from the ZAP1 package + repository root. +- Publish through a package-scoped GitHub Actions trusted publisher so npm can + bind the registry artifact to the exact tagged source workflow without a + long-lived token. +- Make the packed-install matrix read the package version instead of embedding + a release-specific constant. + +There is no verifier-semantic change from `0.2.0`. + ## 0.2.0 - Verify current `ZAP1_COUNT_BOUND_V2` bundles with mandatory leaf-count diff --git a/PROVENANCE.md b/PROVENANCE.md index 58a97b3..f966834 100644 --- a/PROVENANCE.md +++ b/PROVENANCE.md @@ -1,5 +1,22 @@ # Release provenance +## 0.2.1 + +The `0.2.0` npm artifact was byte-identical to the GitHub release asset and all +eight packaged files matched tag `v0.2.0`. However, npm recorded the unrelated +parent-workspace commit `27f6a25d7dea225d34b56a0e836dac19446e689c` as +`gitHead` because publication was invoked outside the package repository. + +Version `0.2.1` repairs that source pointer without changing verifier +semantics. Publication is permitted only from a clean checkout whose repository +root is the package root and whose `HEAD` is the target of annotated tag +`v0.2.1`. The source-directory lifecycle gates enforce those conditions +before and after packing. Manual prepacked-tarball publication bypasses npm +lifecycle scripts and is prohibited. The tag workflow clean-install-tests a +preflight tarball, then publishes through the complete npm directory lifecycle +from the exact gated tag using the package-scoped trusted publisher. npm records +the source `gitHead` and registry provenance from that canonical workflow. + ## 0.2.0 The release source is the commit bearing the annotated repository tag `v0.2.0`. diff --git a/package-lock.json b/package-lock.json index c75cfa3..26e91f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@frontiercompute/zap1", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@frontiercompute/zap1", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "devDependencies": { "playwright": "1.61.1" diff --git a/package.json b/package.json index a19d2af..668ac99 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@frontiercompute/zap1", - "version": "0.2.0", + "version": "0.2.1", "description": "Zero-runtime-dependency ZAP1 Merkle proof verification with COUNT_BOUND_V2 and gated legacy support", "type": "module", "main": "dist/index.js", @@ -22,8 +22,10 @@ "scripts": { "build": "node scripts/build.mjs", "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"", + "prepublishOnly": "node scripts/check-release.mjs", + "postpack": "node scripts/check-release-postpack.mjs", "prepack": "npm run clean && npm run build && npm test", - "test": "node test/test.js", + "test": "node test/test.js && node scripts/test-release-gate.mjs", "test:browser": "node test/browser-test.mjs", "test:packed": "node scripts/test-packed.mjs" }, diff --git a/scripts/check-release-postpack.mjs b/scripts/check-release-postpack.mjs new file mode 100644 index 0000000..f67feeb --- /dev/null +++ b/scripts/check-release-postpack.mjs @@ -0,0 +1,5 @@ +if (process.env.npm_command === "pack") { + console.log("release postpack gate skipped for non-publish pack"); +} else { + await import("./check-release.mjs"); +} diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs new file mode 100644 index 0000000..a04cf65 --- /dev/null +++ b/scripts/check-release.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { readFile, realpath } from "node:fs/promises"; +import { join } from "node:path"; + +function git(...args) { + return execFileSync("git", args, { + cwd: process.cwd(), + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }).trim(); +} + +function canonicalRepository(url) { + return url.replace(/\/+$/, "").replace(/\.git$/, ""); +} + +const packageRoot = await realpath(process.cwd()); +const repositoryRoot = await realpath(git("rev-parse", "--show-toplevel")); +assert.ok(process.env.INIT_CWD, "INIT_CWD is required for publication"); +const invocationRoot = await realpath(process.env.INIT_CWD); +assert.equal( + packageRoot, + repositoryRoot, + "publish must run from the zap1-js repository root", +); +assert.equal( + invocationRoot, + packageRoot, + "npm publish must be invoked from the zap1-js repository root", +); + +const packageJson = JSON.parse( + await readFile(join(packageRoot, "package.json"), "utf8"), +); +assert.equal( + canonicalRepository(packageJson.repository?.url ?? ""), + "https://github.com/Frontier-Compute/zap1-js", + "package repository URL must be canonical", +); +assert.equal( + canonicalRepository(git("remote", "get-url", "origin")), + "https://github.com/Frontier-Compute/zap1-js", + "origin must be the canonical HTTPS repository", +); +assert.equal( + Object.hasOwn(packageJson, "gitHead"), + false, + "package.json must not contain an explicit gitHead", +); +const expectedTag = `v${packageJson.version}`; +const head = git("rev-parse", "HEAD"); +const tagType = git("cat-file", "-t", `refs/tags/${expectedTag}`); +assert.equal(tagType, "tag", `${expectedTag} must be an annotated tag`); +const tagTarget = git( + "rev-parse", + `refs/tags/${expectedTag}^{commit}`, +); +assert.equal(head, tagTarget, `${expectedTag} must resolve to HEAD`); + +if (process.env.GITHUB_ACTIONS === "true") { + assert.equal( + process.env.GITHUB_REPOSITORY, + "Frontier-Compute/zap1-js", + "GitHub repository identity mismatch", + ); + assert.equal( + process.env.GITHUB_REF, + `refs/tags/${expectedTag}`, + "GitHub ref must be the release tag", + ); + const workflowCommit = git( + "rev-parse", + `${process.env.GITHUB_SHA}^{commit}`, + ); + assert.equal(workflowCommit, head, "GitHub workflow SHA must resolve to HEAD"); +} + +const status = git("status", "--porcelain=v1", "--untracked-files=all"); +assert.equal(status, "", "publish checkout must be clean"); + +console.log(`release preimage verified: ${expectedTag} -> ${head}`); diff --git a/scripts/test-packed.mjs b/scripts/test-packed.mjs index b90c8e2..b0fcf71 100644 --- a/scripts/test-packed.mjs +++ b/scripts/test-packed.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -10,6 +10,9 @@ if (!npmCli) { throw new Error("npm_execpath is required; run this gate through npm"); } const projectRoot = process.cwd(); +const projectPackage = JSON.parse( + await readFile(join(projectRoot, "package.json"), "utf8"), +); const packOutput = execFileSync(process.execPath, [npmCli, "pack", "--json"], { cwd: projectRoot, encoding: "utf8", @@ -25,7 +28,7 @@ const packs = JSON.parse(packOutput.slice(packMarkers.at(-1).index)); assert.equal(packs.length, 1); const pack = packs[0]; assert.equal(pack.name, "@frontiercompute/zap1"); -assert.equal(pack.version, "0.2.0"); +assert.equal(pack.version, projectPackage.version); assert.deepEqual( pack.files.map(({ path }) => path).sort(), [ @@ -149,6 +152,13 @@ try { if (browser.status !== 0) { throw new Error(`packed browser matrix exited ${browser.status}`); } + if (process.env.ZAP1_PACK_RESULT) { + await writeFile( + resolve(process.env.ZAP1_PACK_RESULT), + `${JSON.stringify(pack, null, 2)}\n`, + "utf8", + ); + } console.log(`packed clean-install matrix passed: ${pack.integrity}`); } finally { await rm(trialRoot, { recursive: true, force: true }); diff --git a/scripts/test-release-gate.mjs b/scripts/test-release-gate.mjs new file mode 100644 index 0000000..dd9f70d --- /dev/null +++ b/scripts/test-release-gate.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { copyFile, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { tmpdir } from "node:os"; + +function git(cwd, ...args) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }).trim(); +} + +function gate(cwd, initCwd) { + return spawnSync(process.execPath, [join(cwd, "check-release.mjs")], { + cwd, + encoding: "utf8", + env: { + ...process.env, + INIT_CWD: initCwd, + GITHUB_ACTIONS: "false", + GITHUB_REPOSITORY: "", + GITHUB_REF: "", + GITHUB_SHA: "", + }, + }); +} + +const fixture = await mkdtemp(join(tmpdir(), "zap1-release-gate-")); +try { + await copyFile(resolve("scripts/check-release.mjs"), join(fixture, "check-release.mjs")); + await writeFile( + join(fixture, "package.json"), + '{"name":"release-gate-fixture","version":"9.9.9","type":"module","repository":{"url":"https://github.com/Frontier-Compute/zap1-js.git"}}\n', + "utf8", + ); + git(fixture, "init", "-q"); + git(fixture, "config", "user.name", "ZAP1 release gate"); + git(fixture, "config", "user.email", "zk-nd3r@users.noreply.github.com"); + git( + fixture, + "remote", + "add", + "origin", + "https://github.com/Frontier-Compute/zap1-js.git", + ); + git(fixture, "add", "check-release.mjs", "package.json"); + git(fixture, "commit", "-q", "-m", "fixture"); + + git(fixture, "tag", "v9.9.9"); + assert.notEqual(gate(fixture, fixture).status, 0, "lightweight tag must fail"); + git(fixture, "tag", "-d", "v9.9.9"); + + git(fixture, "tag", "-a", "v9.9.9", "-m", "fixture release"); + assert.equal(gate(fixture, fixture).status, 0, "annotated exact tag must pass"); + assert.notEqual( + gate(fixture, dirname(fixture)).status, + 0, + "parent invocation must fail", + ); + + await writeFile(join(fixture, "dirty.txt"), "dirty\n", "utf8"); + assert.notEqual(gate(fixture, fixture).status, 0, "dirty checkout must fail"); + console.log("release gate fixtures passed"); +} finally { + await rm(fixture, { recursive: true, force: true }); +} diff --git a/scripts/verify-published.mjs b/scripts/verify-published.mjs new file mode 100644 index 0000000..e5789a0 --- /dev/null +++ b/scripts/verify-published.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { readFile } from "node:fs/promises"; + +function trailingJson(text) { + const value = text.trim(); + for (let index = value.length - 1; index >= 0; index -= 1) { + if (value[index] !== "{" && value[index] !== "[") continue; + try { + return JSON.parse(value.slice(index)); + } catch { + // Keep scanning for the outermost final JSON value. + } + } + throw new Error("npm output did not end with valid JSON"); +} + +const [preflightPath, publishPath] = process.argv.slice(2); +assert.ok(preflightPath, "preflight pack result path is required"); +assert.ok(publishPath, "publish result path is required"); +assert.ok(process.env.GITHUB_SHA, "GITHUB_SHA is required"); +const expectedHead = execFileSync("git", ["rev-parse", "HEAD"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], +}).trim(); + +const preflight = JSON.parse(await readFile(preflightPath, "utf8")); +const publishedRaw = trailingJson(await readFile(publishPath, "utf8")); +const published = Array.isArray(publishedRaw) ? publishedRaw[0] : publishedRaw; + +assert.equal(published.name, "@frontiercompute/zap1"); +assert.equal(published.version, preflight.version); +assert.equal(published.integrity, preflight.integrity); +assert.equal(published.shasum, preflight.shasum); + +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; +const spec = `@frontiercompute/zap1@${published.version}`; +let metadata; +let lastError; +for (let attempt = 1; attempt <= 12; attempt += 1) { + try { + metadata = JSON.parse( + execFileSync(npmCommand, ["view", spec, "--json"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }), + ); + assert.equal(metadata.gitHead, expectedHead); + assert.equal(metadata.dist?.integrity, published.integrity); + assert.equal(metadata.dist?.shasum, published.shasum); + assert.ok( + metadata.dist?.attestations && + Object.keys(metadata.dist.attestations).length > 0, + "registry provenance attestation is missing", + ); + lastError = undefined; + break; + } catch (error) { + lastError = error; + if (attempt < 12) { + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + } +} +if (lastError) throw lastError; + +console.log( + JSON.stringify({ + package: spec, + gitHead: metadata.gitHead, + integrity: metadata.dist.integrity, + provenance: true, + }), +); From 2ee2d41dbc678a86d82e4e335bf867d645a6c91c Mon Sep 17 00:00:00 2001 From: Zk-nd3r Date: Mon, 3 Aug 2026 20:06:30 -0700 Subject: [PATCH 2/2] handle npm publish result variants --- scripts/publish-result.mjs | 24 ++++++++++++++++++++++++ scripts/test-release-gate.mjs | 15 +++++++++++++++ scripts/verify-published.mjs | 27 +++++++++++---------------- 3 files changed, 50 insertions(+), 16 deletions(-) create mode 100644 scripts/publish-result.mjs diff --git a/scripts/publish-result.mjs b/scripts/publish-result.mjs new file mode 100644 index 0000000..e46c1ab --- /dev/null +++ b/scripts/publish-result.mjs @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; + +export function trailingJson(text) { + const value = text.trim(); + for (let index = value.length - 1; index >= 0; index -= 1) { + if (value[index] !== "{" && value[index] !== "[") continue; + try { + return JSON.parse(value.slice(index)); + } catch { + // Keep scanning for the outermost final JSON value. + } + } + throw new Error("npm output did not end with valid JSON"); +} + +export function publishedCandidate(value) { + const candidates = Array.isArray(value) + ? value + : value?.name + ? [value] + : Object.values(value ?? {}); + assert.equal(candidates.length, 1, "expected one published package result"); + return candidates[0]; +} diff --git a/scripts/test-release-gate.mjs b/scripts/test-release-gate.mjs index dd9f70d..354dd85 100644 --- a/scripts/test-release-gate.mjs +++ b/scripts/test-release-gate.mjs @@ -3,6 +3,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import { copyFile, mkdtemp, rm, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { tmpdir } from "node:os"; +import { publishedCandidate, trailingJson } from "./publish-result.mjs"; function git(cwd, ...args) { return execFileSync("git", args, { @@ -29,6 +30,20 @@ function gate(cwd, initCwd) { const fixture = await mkdtemp(join(tmpdir(), "zap1-release-gate-")); try { + const publish = { name: "@frontiercompute/zap1", version: "9.9.9" }; + assert.deepEqual(publishedCandidate(publish), publish); + assert.deepEqual(publishedCandidate([publish]), publish); + assert.deepEqual( + publishedCandidate({ "@frontiercompute/zap1": publish }), + publish, + ); + assert.deepEqual( + publishedCandidate(trailingJson(`lifecycle output\n${JSON.stringify({ + "@frontiercompute/zap1": publish, + })}\n`)), + publish, + ); + await copyFile(resolve("scripts/check-release.mjs"), join(fixture, "check-release.mjs")); await writeFile( join(fixture, "package.json"), diff --git a/scripts/verify-published.mjs b/scripts/verify-published.mjs index e5789a0..6b06886 100644 --- a/scripts/verify-published.mjs +++ b/scripts/verify-published.mjs @@ -1,32 +1,27 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { readFile } from "node:fs/promises"; - -function trailingJson(text) { - const value = text.trim(); - for (let index = value.length - 1; index >= 0; index -= 1) { - if (value[index] !== "{" && value[index] !== "[") continue; - try { - return JSON.parse(value.slice(index)); - } catch { - // Keep scanning for the outermost final JSON value. - } - } - throw new Error("npm output did not end with valid JSON"); -} +import { publishedCandidate, trailingJson } from "./publish-result.mjs"; const [preflightPath, publishPath] = process.argv.slice(2); assert.ok(preflightPath, "preflight pack result path is required"); assert.ok(publishPath, "publish result path is required"); assert.ok(process.env.GITHUB_SHA, "GITHUB_SHA is required"); -const expectedHead = execFileSync("git", ["rev-parse", "HEAD"], { +const git = (...args) => execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"], }).trim(); const preflight = JSON.parse(await readFile(preflightPath, "utf8")); -const publishedRaw = trailingJson(await readFile(publishPath, "utf8")); -const published = Array.isArray(publishedRaw) ? publishedRaw[0] : publishedRaw; +const published = publishedCandidate( + trailingJson(await readFile(publishPath, "utf8")), +); +const expectedHead = git("rev-parse", `${process.env.GITHUB_SHA}^{commit}`); +assert.equal(git("rev-parse", "HEAD"), expectedHead); +assert.equal( + git("rev-parse", `refs/tags/v${preflight.version}^{commit}`), + expectedHead, +); assert.equal(published.name, "@frontiercompute/zap1"); assert.equal(published.version, preflight.version);