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
40 changes: 40 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -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"
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
17 changes: 17 additions & 0 deletions PROVENANCE.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
},
Expand Down
5 changes: 5 additions & 0 deletions scripts/check-release-postpack.mjs
Original file line number Diff line number Diff line change
@@ -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");
}
82 changes: 82 additions & 0 deletions scripts/check-release.mjs
Original file line number Diff line number Diff line change
@@ -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}`);
24 changes: 24 additions & 0 deletions scripts/publish-result.mjs
Original file line number Diff line number Diff line change
@@ -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];
}
14 changes: 12 additions & 2 deletions scripts/test-packed.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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",
Expand All @@ -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(),
[
Expand Down Expand Up @@ -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 });
Expand Down
83 changes: 83 additions & 0 deletions scripts/test-release-gate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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";
import { publishedCandidate, trailingJson } from "./publish-result.mjs";

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 {
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"),
'{"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 });
}
Loading
Loading