From 9f90360a78591098346cb97b194c517994dcefe4 Mon Sep 17 00:00:00 2001 From: swapnil <78632212+swapnilpaliwal-sd@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:08:38 -0700 Subject: [PATCH] ci: a change that reaches a user has a version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm will not republish a version. Anything inside the tarball can therefore only reach anyone through a new one — and README.md is inside it, named by the `files` allowlist. A pull request that improves the README and leaves the version alone is not a small omission: the improvement is never delivered, and nothing says so. The next person reads the old text on the registry and cannot tell it apart from a README nobody has written. The inverse error costs as much. A bump demanded for a CI tweak or a test fixture teaches people to bump without asking why, and a version that moves for reasons users cannot observe stops carrying information. So the gate asks one question — could this change reach a user? — and answers it from package.json's own `files` declaration rather than a second list kept by hand, because a second list drifts. The negated entries (`!graph/test/`, `!plugins/axiomcode/validate/`, `!plugins/**/__pycache__/`) are read out of the file; `.github/` is added, since npm never packs it and no `files` entry would name it. Everything else is assumed to reach a user, so a new top-level directory is gated by default rather than exempt by default. The engine packages are deliberately untouched. `@axiomcode/engine--` is named by ENGINE_ID, a sha256 over the Soufflé version and the rule text alone, so a README cannot move it and a rule change moves it whether or not anyone bumps anything. That gate is already correct. Runs on pull requests only, where there is a base to compare against; the job's checkout takes fetch-depth 0 for the merge base. `VERSION_GATE=off` skips it, which is what a branch that is not publishing yet wants. Verified on five cases against a fixed base: README alone without a bump fails; `graph/test/` alone, `.github/` alone, and README with a patch bump pass; a change to graph/pipeline/engine.conf without a bump fails. Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/version-gate.sh | 103 ++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 13 ++++ 2 files changed, 116 insertions(+) create mode 100755 .github/scripts/version-gate.sh diff --git a/.github/scripts/version-gate.sh b/.github/scripts/version-gate.sh new file mode 100755 index 000000000..edbdd1528 --- /dev/null +++ b/.github/scripts/version-gate.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# A change that reaches a user needs a version they can ask for. +# +# npm does not let a published version be republished. So anything inside the +# tarball — and that includes README.md, which package.json's `files` allowlist +# names explicitly — can only reach anyone through a new version. A pull request +# that improves the README and leaves the version alone is not a small omission: +# the improvement is simply never delivered, and nothing anywhere says so. The +# next person reads the README on the registry, sees the old text, and has no +# way to tell it apart from a README nobody has written yet. +# +# The inverse error is the one worth avoiding here too. Demanding a bump for a +# CI tweak or a test fixture trains people to bump without asking why, and a +# version that moves for reasons users cannot observe stops meaning anything. +# +# So the gate asks one question: COULD THIS CHANGE REACH A USER? It answers it +# from package.json's own `files` declaration rather than a second list kept by +# hand, because a second list is a thing that drifts — which is the defect this +# repository keeps finding in other forms. +# +# NOT the engine packages. `@axiomcode/engine--` is named by ENGINE_ID, +# a sha256 over the Soufflé version and the rule text and nothing else (see +# graph/pipeline/run-souffle.sh). A README cannot move it, and a rule change +# moves it whether or not anyone bumps anything. That gate is already correct +# and this script must not second-guess it. +# +# Usage: version-gate.sh e.g. version-gate.sh origin/main +# VERSION_GATE=off skips the check (for a branch that is not yet publishing) +# ───────────────────────────────────────────────────────────────────────────── +set -uo pipefail + +[ "${VERSION_GATE:-on}" = off ] && { echo "version gate: off"; exit 0; } + +base="${1:?usage: version-gate.sh }" +root="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$root" || exit 1 + +git rev-parse --verify --quiet "$base" >/dev/null || { + echo "::error::version-gate: no such ref: $base"; exit 1; } + +head_version="$(node -p "require('./package.json').version" 2>/dev/null)" +base_version="$(git show "$base:package.json" 2>/dev/null | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version" 2>/dev/null)" + +[ -n "$head_version" ] && [ -n "$base_version" ] || { + echo "::error::version-gate: could not read the version on both sides"; exit 1; } + +# A bump was made. Whatever the change was, a user can ask for it by name. +if [ "$head_version" != "$base_version" ]; then + echo "version: $base_version -> $head_version" + exit 0 +fi + +# What cannot reach a user: the negations package.json already declares, read +# from the file so the two cannot disagree, plus CI configuration, which npm +# never packs and which no `files` entry would ever name. +# (a while-read loop, not mapfile: bash 3.2 is still what a macOS laptop runs) +excluded=() +while IFS= read -r e; do + [ -n "$e" ] && excluded+=("$e") +done < <(node -e ' + const f = require("./package.json").files || []; + for (const e of f) if (e.startsWith("!")) console.log(e.slice(1)); +') +excluded+=(".github/") + +reaches_a_user() { + local path="$1" + case "$path" in */__pycache__/*) return 1;; esac + for e in "${excluded[@]}"; do + case "$e" in + */) [ "${path##"$e"}" != "$path" ] && return 1 ;; + *) [ "$path" = "$e" ] && return 1 ;; + esac + done + return 0 +} + +shipped=() +while IFS= read -r p; do + [ -n "$p" ] || continue + reaches_a_user "$p" && shipped+=("$p") +done < <(git diff --name-only "$base"...HEAD) + +if [ ${#shipped[@]} -eq 0 ]; then + echo "version $head_version unchanged; nothing in this change reaches a published artefact" + exit 0 +fi + +echo "::error::version-gate: package.json is still $head_version, but ${#shipped[@]} changed file(s) reach a user" +printf ' %s\n' "${shipped[@]:0:20}" +[ ${#shipped[@]} -gt 20 ] && echo " … and $(( ${#shipped[@]} - 20 )) more" +cat <<'WHY' + +npm will not republish a version, so none of the above can be delivered under +0.x.y once 0.x.y is out. A docs-only change is still a delivery: README.md is in +the `files` allowlist and `description` is the registry page, so both reach users +and both want a patch bump — nothing larger. + +If this change genuinely reaches nobody, it belongs under one of the paths +package.json already excludes, and the gate will say so on its own. +WHY +exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc667397f..9017306e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,8 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 # the version gate needs the merge base with the target branch # A fixture input that .gitignore matches passes on the machine that wrote # it and fails on every clone. The suites run this too; running it here as @@ -118,6 +120,17 @@ jobs: done < <(git ls-files '*.sh') exit $fail + # npm will not republish a version, so anything inside the tarball reaches + # nobody unless the version moves — README.md included, since `files` names + # it. The inverse is the error worth avoiding too: a bump demanded for a CI + # tweak or a test fixture teaches people to bump without asking why, and a + # version that moves for reasons users cannot observe stops meaning + # anything. The gate reads package.json's own `files` declaration to tell + # the two apart, and prints the files that decided it. + - name: a change that reaches a user has a version + if: github.event_name == 'pull_request' + run: bash .github/scripts/version-gate.sh "origin/${{ github.base_ref }}" + engine: name: engine (${{ matrix.lang }}) runs-on: ubuntu-24.04