From f1ba23b1db3b4c80a54d957cbdb677a59196766e Mon Sep 17 00:00:00 2001 From: Boyd Cohen Date: Sat, 8 Aug 2026 19:46:46 -0600 Subject: [PATCH] CI: does each tree reproduce the published artifact it claims? This repo had no CI at all. The publish guard runs only in prepublishOnly, so nothing checked a tree BETWEEN releases - exactly when x402-op-authorize drifted from the 0.4.0 it declared. HEAD-based, not gitHead-based, and that is the whole point. The gitHead version PASSES on the case that went wrong: x402 0.4.0 rebuilds byte-identically from its commit in the archived repo, while this monorepo's tree declared 0.4.0 with four changed files. The question that catches it is about the working tree. Composes with the guard's registry check: a tree either declares an unpublished version (skipped here, publishable there) or a published one (must reproduce it). No third state. Subject is dist/, not the whole tarball. Running the strict version showed why: five of seven failed and four differed only in README.md and package.json, because the provenance notes were added after those versions were published. Whole-tarball comparison makes a README typo a red build. dist/ is what consumers execute and what diverged in the real case. Node pinned to 22.22.3 at the site, with the reason: esbuild and typescript are lockfile-pinned, Node is pinned by nothing in the artifact, and 22.22.3 is the version byte-identical reproduction was measured under. The guard self-tests assert the REASON, not the refusal. Editing package.json makes the tree dirty so the guard refuses either way, and a bare non-zero assertion would stay green with the registry check deleted. A second job breaks registry_state and requires the negative control to fire. Both self-tests first give HEAD an upstream: the guard refuses an untracked branch before reaching anything later, and actions/checkout does not configure tracking. Found by running it. --- .github/workflows/reproduction.yml | 104 +++++++++++++++++++++++++++++ scripts/verify-reproduction.sh | 83 +++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 .github/workflows/reproduction.yml create mode 100755 scripts/verify-reproduction.sh diff --git a/.github/workflows/reproduction.yml b/.github/workflows/reproduction.yml new file mode 100644 index 0000000..99f5e79 --- /dev/null +++ b/.github/workflows/reproduction.yml @@ -0,0 +1,104 @@ +# Does each package's tree reproduce the published artifact it claims? +# +# This repo had NO CI at all until this workflow. The publish guard +# (scripts/refuse-dirty-publish.sh) runs only in prepublishOnly, so nothing checked a tree between +# releases — which is exactly when x402-op-authorize drifted from the 0.4.0 it declared. +name: reproduction + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + reproduce: + runs-on: ubuntu-latest + strategy: + fail-fast: false # one drifted package must not hide the state of the other six + matrix: + package: + - ap2-op-authorize + - fireblocks-op-authorize + - l402-op-authorize + - mppx-op-account + - ows-op-verify + - wdk-op-policy + - x402-op-authorize + steps: + - uses: actions/checkout@v4 + + # ─── NODE IS PINNED HERE BECAUSE NOTHING ELSE PINS IT ──────────────────────────────── + # Reproduction is relative to a toolchain. esbuild and typescript ARE pinned, by each + # package's committed package-lock.json, so they are not the risk. Node is pinned by + # NOTHING in the published artifact: no `engines` floor is a build guarantee, and a + # consumer on a different Node major may legitimately fail to reproduce a tarball we + # consider correct. + # + # 22.22.3 is the version under which byte-identical reproduction was MEASURED on + # 2026-08-08 (x402-op-authorize@0.4.0, all 14 files and the .tgz itself). Changing this + # line changes what "reproduces" means. If you bump it, re-measure against a known-good + # package first and record the result here — a green run under an unmeasured toolchain + # proves less than it appears to. + - uses: actions/setup-node@v4 + with: + node-version: 22.22.3 + + - name: Reproduce ${{ matrix.package }} + run: bash scripts/verify-reproduction.sh packages/${{ matrix.package }} + + # The publish guard is exercised here too, so its own failure modes stay live rather than being + # discovered at publish time by whoever is publishing. + guard-self-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } + - uses: actions/setup-node@v4 + with: + node-version: 22.22.3 + # The guard refuses a branch with no upstream BEFORE it reaches any later check, and + # actions/checkout does not configure upstream tracking. Without this the self-tests below + # would go red for the wrong reason and read as "the guard is broken". + - name: Give HEAD an upstream, so the guard reaches the checks under test + run: | + git fetch origin main + git checkout -B guard-selftest origin/main + git branch --set-upstream-to=origin/main guard-selftest + + - name: The guard must refuse an already-published version FOR THAT REASON + working-directory: packages/x402-op-authorize + run: | + npm ci --silent + node -e ' + const fs=require("fs"); const p=JSON.parse(fs.readFileSync("package.json","utf8")); + p.version="0.4.0"; // a version that IS published + fs.writeFileSync("package.json", JSON.stringify(p,null,2)); + ' + # ASSERT THE REASON, NOT THE REFUSAL. Editing package.json makes the tree dirty, so the + # guard refuses either way — the dirty-tree check alone would satisfy a bare "it exited + # non-zero" assertion, and this job would stay green with the registry check deleted. + # An earlier gate in a fail-fast chain hides every later one; a refusal is evidence about + # whichever check fired, never about the one under test. + OUT="$(bash ../../scripts/refuse-dirty-publish.sh 2>&1 || true)" + echo "$OUT" + if ! printf '%s' "$OUT" | grep -q "IS ALREADY PUBLISHED"; then + echo "::error::the guard did not refuse for the registry reason — it refused for something else, or not at all" + exit 1 + fi + echo "guard refused with the already-published reason, as intended" + git checkout -- package.json + + - name: The guard's negative control must itself be able to fail + working-directory: packages/x402-op-authorize + run: | + # Break registry_state so a missing version reads as present, and require the guard to + # refuse naming its own instrument. Without this, a guard whose registry lookup silently + # stopped working would wave every publish through and this workflow would stay green. + sed 's/ echo free; return/ echo taken; return/' ../../scripts/refuse-dirty-publish.sh > /tmp/broken-guard.sh + OUT="$(bash /tmp/broken-guard.sh 2>&1 || true)" + if ! printf '%s' "$OUT" | grep -q "CONTROL FAILED"; then + echo "::error::the guard's own negative control did not fire when registry_state was broken" + exit 1 + fi + echo "negative control fired, naming the instrument" diff --git a/scripts/verify-reproduction.sh b/scripts/verify-reproduction.sh new file mode 100755 index 0000000..06f1227 --- /dev/null +++ b/scripts/verify-reproduction.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# Does the tree that CLAIMS version V actually reproduce the published V? +# +# WHY THIS IS HEAD-BASED AND NOT gitHead-BASED. The obvious formulation — "check out the commit +# each published version names and confirm it rebuilds" — PASSES on the case that went wrong. +# Measured 2026-08-08: x402-op-authorize@0.4.0 rebuilds BYTE-IDENTICALLY from its gitHead in the +# archived repo. Meanwhile this monorepo's tree declared 0.4.0 while carrying four changed files, +# including a widened exported union. The gitHead-based check would have reported everything fine. +# +# The question that catches it is about the WORKING TREE: if package.json's version is already +# published, this tree must reproduce that published artifact exactly. If it is not published, +# there is nothing to compare against and we skip — which after the publish guard's registry check +# is the normal state of a tree between releases. +# +# Together the two are total: a tree either declares an unpublished version (publishable, skipped +# here) or a published one (must reproduce it). There is no third state. +set -uo pipefail +PKG_DIR="${1:?usage: verify-reproduction.sh }" +cd "$PKG_DIR" || exit 1 +NAME="$(node -p 'require("./package.json").name')" +VER="$(node -p 'require("./package.json").version')" + +# Is this version published? Key on EXIT CODE plus response shape, never on output being non-empty: +# npm prints its E404 body to STDOUT and exits 1, so an emptiness test scores MISSING as PRESENT. +OUT="$(npm view "${NAME}@${VER}" version --json 2>/dev/null)"; RC=$? +if [ $RC -ne 0 ] && printf '%s' "$OUT" | grep -q '"code": *"E404"'; then + echo "SKIP ${NAME}@${VER} — not published, nothing to reproduce" + exit 0 +fi +if [ $RC -ne 0 ] || ! printf '%s' "$OUT" | grep -q "\"${VER}\""; then + echo "FAIL ${NAME}@${VER} — could not establish whether this version is published (fail-closed)" >&2 + exit 1 +fi + +WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT +( cd "$WORK" && npm pack "${NAME}@${VER}" >/dev/null 2>&1 ) || { + echo "FAIL ${NAME}@${VER} — could not fetch the published tarball (fail-closed)" >&2; exit 1; } +PUBLISHED="$(ls "$WORK"/*.tgz | head -1)" + +npm ci --silent >/dev/null 2>&1 || { echo "FAIL ${NAME}@${VER} — npm ci failed" >&2; exit 1; } +npm run build --silent >/dev/null 2>&1 || { echo "FAIL ${NAME}@${VER} — build failed" >&2; exit 1; } +REBUILT="$(npm pack --silent 2>/dev/null | tail -1)" +[ -f "$REBUILT" ] || { echo "FAIL ${NAME}@${VER} — npm pack produced nothing" >&2; exit 1; } + +# WHAT IS COMPARED, AND WHY IT IS NOT THE WHOLE TARBALL. +# +# The first version of this check compared the tarballs byte-for-byte. Running it against all seven +# packages showed why that is the wrong subject: FIVE failed, and four of those differed only in +# README.md and package.json — because the provenance boundary notes were added to the READMEs after +# those versions were published, and package.json's repository field changed when the packages were +# rehomed into this monorepo. Under whole-tarball comparison, fixing a typo in a README would break +# CI until the next release. That is not a defect worth a red build. +# +# THE SUBJECT IS THE SHIPPED CODE. dist/ is what consumers execute, and it is what diverged in the +# case this check exists for: x402-op-authorize declared 0.4.0 while its dist/ differed in four +# files, including a widened exported union. A dist/ difference means the code we ship is not the +# code this tree builds. Everything else is reported and does not fail the build. +mkdir -p "$WORK/a" "$WORK/b" +tar xzf "$PUBLISHED" -C "$WORK/a" 2>/dev/null +tar xzf "$REBUILT" -C "$WORK/b" 2>/dev/null +rm -f "$REBUILT" + +if ! diff -rq "$WORK/a/package/dist" "$WORK/b/package/dist" >/dev/null 2>&1; then + echo "FAIL ${NAME}@${VER} — SHIPPED CODE DIFFERS FROM WHAT THIS TREE BUILDS" >&2 + echo " dist/ is what consumers execute. This tree claims ${VER} and does not produce its" >&2 + echo " published dist/. Either bump the version (npm version prerelease --preid rc) or" >&2 + echo " restore the tree." >&2 + diff -rq "$WORK/a/package/dist" "$WORK/b/package/dist" 2>&1 \ + | sed "s|$WORK/a/package/dist|published:|g; s|$WORK/b/package/dist|rebuilt:|g" | sed 's/^/ /' >&2 + exit 1 +fi + +# Non-dist differences are reported, never fatal. Read them: a changed dependency range in +# package.json is a real signal even though it does not fail this check, whereas an edited README is +# expected between releases. +OTHER="$(diff -rq "$WORK/a/package" "$WORK/b/package" 2>/dev/null | grep -v "/dist" || true)" +if [ -n "$OTHER" ]; then + echo "OK ${NAME}@${VER} — shipped dist/ reproduces exactly; non-code files differ (expected between releases):" + printf '%s\n' "$OTHER" | sed "s|$WORK/a/package/||g; s|$WORK/b/package/||g" | sed 's/^/ /' +else + echo "OK ${NAME}@${VER} — reproduces the published tarball byte-for-byte" +fi +exit 0