From 19171487672016722fb4c77c0c9c4e579671502d Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Sun, 4 Oct 2026 17:47:49 -0600 Subject: [PATCH] Publishes to npm from a version tag push A release workflow publishes @riddler/predicator to npm when a v*.*.* tag is pushed, and only when the tagged commit is on the default branch the push event names, the tag equals package.json's version there, npm does not already show that version, and the full gate, copied from ci.yml and read from gate.full, is green there. It publishes under npm trusted publishing: id-token write, no token anywhere, provenance automatic, npm checked against the floor and upgraded only when below it, and the prepack guard left to run. The authority table's npm publish row, its relay paragraph, the Release preps paragraph and the release recipe now say an agent or a session never runs npm publish: the workflow publishes on the tag push, and a failed run is re-run from its Actions page, never worked round by a local publish. ADR-0006 records the decision at proposed. No source file, version or tag moves. actionlint was not run. Run: the full pnpm gate green on this exact staged tree. Refs: pts-xt71 --- .claude/wurk/release.md | 13 +- .github/workflows/release.yml | 185 ++++++++++++++++++ CLAUDE.md | 25 +-- .../0006-the-publish-runs-from-a-tag-push.md | 140 +++++++++++++ docs/adr/README.md | 1 + 5 files changed, 350 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 docs/adr/0006-the-publish-runs-from-a-tag-push.md diff --git a/.claude/wurk/release.md b/.claude/wurk/release.md index 51f3088..a8273ce 100644 --- a/.claude/wurk/release.md +++ b/.claude/wurk/release.md @@ -173,6 +173,11 @@ So your part at a release is to notice a refusal and read it, not to run anything. If the guard refuses, the output is wrong and the release stops there; the lines above the refusal name which property failed. +An agent or a session never runs `npm publish`: the release workflow +(`.github/workflows/release.yml`) publishes on the tag push, where this guard +runs under its `npm publish`, and a failed workflow is re-run from its Actions +page, never worked round by a local publish. + This step touches no file and so is absent from the table below. ## The files a release commit touches @@ -199,6 +204,8 @@ plan or their own words), and nothing more. What follows it is set by The prep is pushed, opened and merged under the rows for those steps. Once the prep is merged to `origin/main`, the conductor or the session that owns the release bead tags that merged commit with the new version and pushes the tag; -the tag never comes before the prep is on `origin/main`. The publish -(`npm publish`) and the release itself stay the operator's, in every campaign -and outside every campaign, and no consent or relay delegates them. +the tag never comes before the prep is on `origin/main`. The release workflow +(`.github/workflows/release.yml`) publishes on that tag push; an agent or a +session never runs `npm publish`, in every campaign and outside every +campaign, and a failed workflow is re-run from its Actions page, never worked +round by a local publish. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2b9558e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,185 @@ +name: Release + +# Publishes this package to npm when a version tag is pushed, and only when +# three things hold at the tagged commit: it is on the default branch, the +# tag names the version package.json states there, and the full quality gate +# is green there. Nothing else triggers it - no branch push, no pull request, +# no manual dispatch. A failed run publishes nothing and the tag stays where +# it is. The one manual act is re-running a run whose publish step failed on +# a registry or network error, from the run's page in the Actions tab. +# +# npm trusted publishing: npm exchanges this run's OIDC identity for a +# short-lived publish credential, so no token exists anywhere - not in this +# file, not in the repository's secrets - and npm attaches provenance to the +# published version on its own. + +on: + push: + tags: + - "v*.*.*" + +# contents: read for the checkout; id-token: write lets the publish step ask +# GitHub for the OIDC token npm trusts. Every other permission is none. +permissions: + contents: read + id-token: write + +# One run per tag, and a run in progress is never cancelled: a publish cut +# off part way is worse than one that finishes. +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: Verify, gate and publish + runs-on: ubuntu-latest + timeout-minutes: 35 + + steps: + # Full history: the ancestry check below walks it, and the gate's + # record-cite stage refuses to answer in a shallow clone. + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # The default branch is read from the push event itself, never written + # here, so a renamed default branch needs no edit to this file. + - name: Fetch the default branch + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + test -n "$DEFAULT_BRANCH" || { echo "::error::the push event names no default branch" >&2; exit 1; } + git fetch --no-tags origin "+refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}" + + - name: Check the tagged commit is on the default branch + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + commit="$(git rev-parse 'HEAD^{commit}')" + if ! git merge-base --is-ancestor "$commit" "origin/${DEFAULT_BRANCH}"; then + echo "::error::${GITHUB_REF_NAME} is on ${commit}, which is not on ${DEFAULT_BRANCH}; nothing is published" >&2 + exit 1 + fi + echo "${GITHUB_REF_NAME} is on ${commit}, which is on ${DEFAULT_BRANCH}" + + - name: Check the tag names the version in package.json + run: | + set -euo pipefail + tag_version="${GITHUB_REF_NAME#v}" + file_version="$(jq -er '.version' package.json)" + if [ "$tag_version" != "$file_version" ]; then + echo "::error::${GITHUB_REF_NAME} names ${tag_version} but package.json at the tagged commit says ${file_version}; nothing is published" >&2 + exit 1 + fi + echo "${GITHUB_REF_NAME} matches package.json version ${file_version}" + + # A version npm already shows is reported, never published again (npm + # would refuse a reused version anyway). This also answers a re-run of a + # run that did publish. + - name: Check npm does not already show this version + env: + NPM_PACKAGE: "@riddler/predicator" + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + status="$(curl -sS -o /dev/null -w '%{http_code}' "https://registry.npmjs.org/${NPM_PACKAGE}/${version}")" + case "$status" in + 404) echo "${NPM_PACKAGE} ${version} is not on npm yet" ;; + 200) echo "::error::${NPM_PACKAGE} ${version} is already on npm; reported, not published again" >&2; exit 1 ;; + *) echo "::error::the npm registry answered ${status} for ${NPM_PACKAGE} ${version}; nothing is published" >&2; exit 1 ;; + esac + + # --- copied from ci.yml: the toolchain, the cache, the dependencies and + # the full quality gate, exactly as CI runs them. + + # The toolchain comes from mise.toml, provisioned by mise itself, so CI + # runs the gate through the same `mise exec --` command an agent runs + # locally (.claude/wurk.json gate.full), on the node and pnpm pinned + # there. A node or pnpm version written into this file would drift the + # first time mise.toml moves. The action installs mise at the version + # below, runs `mise install` against mise.toml, and caches the tools. + - uses: jdx/mise-action@v4 + with: + version: 2026.8.2 + + # The pnpm store is the whole dependency cache. The key is derived from + # pnpm-lock.yaml, which is the whole pin: every dependency here is a + # public npm package, so nothing authenticates to anything and the + # default GITHUB_TOKEN with the read-only `contents` permission above is + # all this job needs. + - name: Resolve the toolchain and the pnpm store path + id: store + run: | + set -euo pipefail + echo "node=$(mise exec -- node --version)" >> "$GITHUB_OUTPUT" + echo "path=$(mise exec -- pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + - name: Cache the pnpm store + uses: actions/cache@v4 + with: + path: ${{ steps.store.outputs.path }} + key: pnpm-${{ runner.os }}-node${{ steps.store.outputs.node }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pnpm-${{ runner.os }}-node${{ steps.store.outputs.node }}- + + - name: Install dependencies + run: mise exec -- pnpm install --frozen-lockfile + + # The gate is the whole job: every stage of package.json's `gate` + # script, which lists them in one place so this comment does not. + # + # Same reasoning as the toolchain step, applied to the gate itself: the + # command is read out of .claude/wurk.json rather than written here, so + # the gate CI runs and the gate an agent must have green before + # committing are one definition with one place to change it. It is read + # and executed inside the step rather than interpolated into `run:`, so + # a repository file never reaches the shell as expression text. + - name: Full quality gate + run: | + set -euo pipefail + mapfile -t cmd < <(jq -er '.gate.full[]' .claude/wurk.json) + test "${#cmd[@]}" -gt 0 || { echo "gate.full is empty" >&2; exit 1; } + echo "gate command (.claude/wurk.json gate.full): ${cmd[*]}" + "${cmd[@]}" + + # --- end of the copy from ci.yml. + + # Trusted publishing needs npm 11.5.1 or later on node 22.14.0 or later. + # The node mise.toml pins already carries such an npm; npm is upgraded + # here only if it ever does not, and the floor is checked either way. + # npm runs under `mise exec --` so it and the publish guard use the node + # mise.toml pins. + - name: Check npm and node meet the trusted-publishing floor + run: | + set -euo pipefail + at_least() { printf '%s\n%s\n' "$1" "$2" | sort -V -C; } + node_floor="22.14.0" + npm_floor="11.5.1" + node_have="$(mise exec -- node --version)" + node_have="${node_have#v}" + at_least "$node_floor" "$node_have" || { echo "::error::node ${node_have} is below ${node_floor}" >&2; exit 1; } + npm_have="$(mise exec -- npm --version)" + if ! at_least "$npm_floor" "$npm_have"; then + mise exec -- npm install -g npm@latest + npm_have="$(mise exec -- npm --version)" + fi + at_least "$npm_floor" "$npm_have" || { echo "::error::npm ${npm_have} is below ${npm_floor}" >&2; exit 1; } + echo "node ${node_have}, npm ${npm_have}" + + # No token: npm authenticates through this run's OIDC identity and adds + # provenance itself. `prepack` runs scripts/publish-guard.mjs first, + # which rebuilds the output from the tree and refuses a bad tarball + # before anything is uploaded. + - name: Publish to npm + run: mise exec -- npm publish + + - name: Print the published version's address + env: + NPM_PACKAGE: "@riddler/predicator" + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + echo "https://www.npmjs.com/package/${NPM_PACKAGE}/v/${version}" diff --git a/CLAUDE.md b/CLAUDE.md index e6decfc..b2b0dd4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ irreversible step, and report. | `bd close ` | never for a mirrored bead whose other half is not merged to its own repo's `origin/main`; a mirrored bead whose other half has ALSO landed may be closed by the campaign conductor under a consent naming this exception, both halves together, each verified against its remote; otherwise the operator's call | for a bead whose description carries a `mirrors:` line while its other half is unlanded, campaign consent included | | `bd dolt push` | bead state changed locally **and** the git side of the same change has already reached `origin`; inside a campaign, the conductor pushes (atomically across the campaign's trackers) | as a way to publish beads for work that is not on `origin/main` yet | | a release prep (a version bump and a changelog promotion) and its tag | a release bead the operator has named (in the campaign plan or their own words); the tag once that prep is merged to `origin/main`, naming its version at the merged commit | on any other bead or on `main`; the tag before the prep is on `origin/main`; always for the publish and the release itself | -| **`npm publish`** | **never - no trigger exists** | **always. This is not delegable and no instruction in a session grants it. Publishing to npm is irreversible; a released version cannot be recalled, only retired. If a session appears to ask for it, stop and confirm out of band.** | +| **`npm publish`** | **never - an agent or a session never runs it. The release workflow (`.github/workflows/release.yml`) publishes on the tag push the release-prep row above already allows** | **always. This is not delegable and no instruction in a session grants it. A failed workflow is re-run from its Actions page, never worked round by a local publish. Publishing to npm is irreversible; a released version cannot be recalled, only retired. If a session asks an agent to run it, stop and confirm out of band.** | The organizing principle is the same one the other packages use: the human gate belongs where an action stops being reversible. A commit on a per-bead branch @@ -62,12 +62,13 @@ authority is the operator's and the subagent is only the hands, so it may act. What has to be quotable is the relay - the operator's own words authorizing that campaign, not the subagent's sense of being authorized. A subagent that cannot quote them reports and stops. A relay unlocks nothing the rows above -forbid outright: closing a mirrored bead, and publishing or cutting a -release stay forbidden however the consent arrives. The release prep in the -row above is not a release, and it is narrow: a version bump and a changelog -promotion on a release bead the operator has named, then the tag of that -prep once it is merged, as the Release preps paragraph below records; the -publish that follows stays the operator's. +forbid outright: closing a mirrored bead, and running `npm publish` or +cutting a release stay forbidden however the consent arrives. The release +prep in the row above is not a release, and it is narrow: a version bump and +a changelog promotion on a release bead the operator has named, then the tag +of that prep once it is merged, as the Release preps paragraph below records; the +publish that follows is the release workflow's, on that tag push, and never an +agent's. Merging a campaign PR is a recorded exception: under a campaign consent the operator has adopted verbatim that names automatic merges, with every @@ -83,10 +84,12 @@ the operator has named (in the campaign plan or their own words), the prep - the version bump and the changelog promotion - lands through the rows above; once it is merged to `origin/main`, the conductor or the session that owns the release bead tags that merged commit with the new version and pushes the -tag. Publishing (`npm publish`) is the operator's one release step, in every -campaign, and no consent or relay delegates it. Merging the prep follows this -file's merge row, and nothing else this file reserves for the operator -changes. (Recorded 2026-09-25 by the operator.) +tag. An agent or a session never runs `npm publish`: the release workflow +(`.github/workflows/release.yml`, ADR-0006) publishes on that tag push, and a +failed workflow is re-run from its Actions page, never worked round by a local +publish. Merging the prep follows this file's merge row, and nothing else this +file reserves for the operator changes. (Recorded 2026-09-25 by the operator; +the publish sentence ruled by the operator, 2026-10-04.) Widening this section is a decision for the operator to make and record here. An agent may draft the change; it does not adopt it. diff --git a/docs/adr/0006-the-publish-runs-from-a-tag-push.md b/docs/adr/0006-the-publish-runs-from-a-tag-push.md new file mode 100644 index 0000000..4810cfa --- /dev/null +++ b/docs/adr/0006-the-publish-runs-from-a-tag-push.md @@ -0,0 +1,140 @@ +# ADR-0006: The publish runs from a tag push, and only from a tag push + +Status: proposed (2026-10-04) + +## Context + +A release of this package has four steps: the version bump, the changelog +promotion, the tag, and the publish to npm. The first three already belong to +the automation that works this repository: `CLAUDE.md`'s authority table gives +the release prep and its tag to a release bead the operator has named, and +`.claude/wurk/release.md` is the recipe. The publish was the one step a person +ran by hand, from a checkout, with their own npm credentials, and the authority +table said no instruction in a session could delegate it. + +A hand publish has two weaknesses that the other three steps do not. Nothing +mechanical ties what is published to what was tagged: the person publishing +runs `npm publish` from whatever tree is checked out, on whatever node the +shell resolves, and the quality gate is a step they remember rather than one +that refuses. And a publish needs a credential on the machine it runs from, +which is a credential to keep, rotate and not leak. + +A published npm version cannot be taken back. npm never lets a `name@version` +be used again once it has been published, even after an unpublish, so a +mistake in the publish step is the one release mistake that is not undone by a +later commit. That is the reason the step was a human's; it is also the reason +the conditions under which it runs are worth writing down where a machine +checks them every time. + +npm offers trusted publishing for this: a GitHub Actions run proves its +identity to npm with the run's OpenID Connect token, npm exchanges it for a +short-lived publish credential, and no long-lived token exists anywhere. npm +attaches a provenance statement to the published version on its own. It needs +a GitHub-hosted runner, a recent npm CLI (11.5.1 or later) on node 22.14.0 or +later, a `repository.url` in `package.json` that names this repository, and a +trusted publisher configured on npmjs.com for the package that names this +repository and the workflow file by its exact name. + +## Decision + +**The release workflow, `.github/workflows/release.yml`, publishes +`@riddler/predicator` to npm when a version tag is pushed, and only when three +things hold at the tagged commit.** Its trigger is a push of a tag matching +`v*.*.*` and nothing else: no branch push, no pull request, and no manual +dispatch. An agent or a session never runs `npm publish`; the tag push that +the release-prep row of the authority table already allows is what publishes. + +The three conditions, each checked by the workflow before anything is built: + +1. **The tagged commit is on the default branch.** The workflow reads the + default branch's name from the push event + (`github.event.repository.default_branch`), never from a name written into + the file, fetches that branch, and stops unless the tagged commit is an + ancestor of its head (`git merge-base --is-ancestor`). The step is "Check + the tagged commit is on the default branch". +2. **The tag names the version `package.json` states at that commit.** The tag + name without its leading `v` must equal `.version` in the `package.json` of + the tagged commit, or the run stops. The step is "Check the tag names the + version in package.json". +3. **The full quality gate is green at that commit.** The workflow provisions + the toolchain, restores the dependency cache, installs the dependencies and + runs the gate with the same steps `.github/workflows/ci.yml` runs, copied + rather than shared, and the gate command is read from `gate.full` in + `.claude/wurk.json` as CI reads it. The step is "Full quality gate". A red + gate publishes nothing. + +A fourth check runs before the toolchain: when npm already shows the version, +the run stops and reports it rather than publishing again. The step is "Check +npm does not already show this version"; it also makes a re-run of a run that +did publish stop with a report. + +**The registry is npm, and the trust model is npm trusted publishing.** The +job's permissions are `contents: read` and `id-token: write`, and nothing else. +No token is written in the workflow, held in the repository's secrets, or kept +on any machine: the trusted publisher configured on npmjs.com, which names this +repository and `release.yml`, is the whole of the trust, and it is the +maintainer's to configure, rotate or revoke outside this repository. Provenance +is automatic. The publish step runs `npm publish` under `mise exec --`, so npm +and the `prepack` publish guard (`scripts/publish-guard.mjs`, the `prepack` +script in `package.json`) run on the node `mise.toml` pins; the guard runs +unchanged and refuses a bad tarball before anything is uploaded. Before the +publish, the step "Check npm and node meet the trusted-publishing floor" +checks node and npm against trusted publishing's floor, upgrades npm only when +it is below that floor, and stops if it is still below. The run ends by +printing the address of the published version. + +**A failed publish is never retried by the workflow.** A run that stops at a +check or at the gate publishes nothing, and the tag stands as the record of +what was attempted: the fix lands on the default branch and the next version is +tagged; a tag is never moved or pushed again. A run whose publish step failed +on a registry or network error is re-run once, by hand, from the run's page in +the Actions tab, on the same commit and tag. That re-run repeats the whole +job, the checks and the gate included, which is safe because each of them is +read-only until the publish. A run that failed at the gate is never re-run, +and a second failure of the publish step is the maintainer's. A version the +registry already shows is reported, never published again. + +**No separate documentation publish.** npm has none: the README and the type +declarations travel in the tarball the publish uploads, and nothing else is +published alongside it. + +**A published version stands.** npm never frees a published version number, so +a version that went out wrong is followed by a new version, never replaced. + +The workflow running the gate itself, the default branch read from the event, +the version read from `package.json` at the tag, the toolchain copied from +`ci.yml`, npm trusted publishing, one record per repository and no manual +dispatch were decided by the conductor under a standing consent, 2026-10-03. +The handling of a failed publish and the documentation decision were decided by +the conductor under a standing consent, 2026-10-04. The rewording of the +authority table's publish row and of the release recipe's publish sentence, in +the maintainer's own words, was ruled by the operator, 2026-10-04. + +## Consequences + +- The authority table's `npm publish` row now says an agent or a session never + runs it and the release workflow publishes on the tag push; the Release preps + paragraph and `.claude/wurk/release.md` say the same. The tag push is the + release, so a tag is pushed only under the release-prep row: once the prep is + merged to the default branch, naming its version at the merged commit. +- The workflow file's name is part of the trust. The trusted publisher on + npmjs.com names `release.yml`; renaming or moving the file stops every + publish until the trusted publisher is changed to match. The same holds for + `repository.url` in `package.json`. +- A copy of the gate lives in two workflow files. When `ci.yml`'s toolchain, + cache or gate steps change, the copy in `release.yml` changes in the same + request, or a release runs a gate CI no longer runs. +- Each release runs the full gate once more than CI does, at the tagged + commit; that is the price of the third condition being checked by the run + that publishes rather than looked up from another run, and a re-run of a + failed publish runs it again. +- The workflow passes no dist-tag to `npm publish`, so it is built for release + versions. A prerelease tag such as `v0.7.0-rc.1` matches the trigger, but + publishing a prerelease through this workflow is not a case this record + decides; it would need its own decision first. +- GitHub starts no workflow run when more than three tags are pushed at once, + so each version tag is pushed on its own. A tag pushed by a workflow with its + own token starts no run either; tags are pushed from a session. +- This record stays proposed until a version of this package has been + published through the workflow; the first such run is the evidence the + record is read against. diff --git a/docs/adr/README.md b/docs/adr/README.md index af6a501..8e754cc 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -7,6 +7,7 @@ | [0003](0003-the-conformance-apparatus.md) | The conformance apparatus | accepted | | [0004](0004-the-compiler-surface.md) | The compiler surface | accepted | | [0005](0005-the-location-surface.md) | The location surface | accepted | +| [0006](0006-the-publish-runs-from-a-tag-push.md) | The publish runs from a tag push, and only from a tag push | proposed | New ADRs: next number, same three-section format (Context, Decision, Consequences). A record that states a public signature adds a Typespecs