diff --git a/.claude/wurk/release.md b/.claude/wurk/release.md index a90b89e..f153e82 100644 --- a/.claude/wurk/release.md +++ b/.claude/wurk/release.md @@ -39,11 +39,12 @@ If the two carriers ever disagree on major or minor, the pin edit goes straight to the current major/minor in one move rather than stepping one release at a time. That is the recipe repairing drift, not a mistake to correct back. -## `mix hex.publish` has no trigger, ever +## `mix hex.publish` is the release workflow's, never a session's -Not delegable, and no instruction in a session grants it (CLAUDE.md's -authority table; ADR-0006). The tag is a separate step with its own owner - -see the release trigger below. +An agent or a session never runs `mix hex.publish`; the release workflow +publishes on the tag push; a failed workflow is re-run from its Actions page, +never worked round by a local publish (CLAUDE.md's authority table; ADR-0018). +The tag is a separate step with its own owner - see the release trigger below. ## The release trigger @@ -56,7 +57,6 @@ from a merged PR, from accumulated fragments on their own, or from "ship it"/"cut it" said about something else. 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 (CLAUDE.md's -Release preps paragraph). The publish - `mix hex.publish`, a docs republish -included - stays the operator's one release step, and no consent or relay -delegates it - see CLAUDE.md's authority table, which this section must not -outrun. +Release preps paragraph). The release workflow publishes on that tag push, +the docs with the package, and no agent or session runs `mix hex.publish` - +see CLAUDE.md's authority table, which this section must not outrun. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..88fd0c8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,172 @@ +name: Release + +# Publishes this package to Hex 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 mix.exs 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. + +on: + push: + tags: + - "v*.*.*" + +# Read-only. The publish authenticates to Hex with its own key, never with +# this token. +permissions: + contents: read + +# 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: 45 + + steps: + # Full history: the ancestry check below walks it. + - 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 mix.exs + run: | + set -euo pipefail + tag_version="${GITHUB_REF_NAME#v}" + file_version="$(sed -n 's/^ *@version *"\([^"]*\)".*$/\1/p' mix.exs | head -1)" + test -n "$file_version" || { echo "::error::no @version in mix.exs at the tagged commit" >&2; exit 1; } + if [ "$tag_version" != "$file_version" ]; then + echo "::error::${GITHUB_REF_NAME} names ${tag_version} but mix.exs at the tagged commit says ${file_version}; nothing is published" >&2 + exit 1 + fi + echo "${GITHUB_REF_NAME} matches @version ${file_version}" + + # A version Hex already shows is reported, never published again. This + # also answers a re-run of a run that did publish. + - name: Check Hex does not already show this version + env: + HEX_PACKAGE: predicator + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + status="$(curl -sS -o /dev/null -w '%{http_code}' "https://hex.pm/api/packages/${HEX_PACKAGE}/releases/${version}")" + case "$status" in + 404) echo "${HEX_PACKAGE} ${version} is not on Hex yet" ;; + 200) echo "::error::${HEX_PACKAGE} ${version} is already on Hex; reported, not published again" >&2; exit 1 ;; + *) echo "::error::hex.pm answered ${status} for ${HEX_PACKAGE} ${version}; nothing is published" >&2; exit 1 ;; + esac + + # From here to the publish step, the steps are ci.yml's own, copied: the + # toolchain, the cache, the dependencies and the full quality gate, + # exactly as CI runs them. + + # Single source of truth for the toolchain: a version duplicated into + # this file drifts the first time mise.toml moves. mise itself is not + # used to provision CI - it would build Erlang from source. + - name: Read the toolchain out of mise.toml + id: toolchain + run: | + set -euo pipefail + erlang="$(sed -n 's/^erlang *= *"\(.*\)".*$/\1/p' mise.toml | head -1)" + elixir="$(sed -n 's/^elixir *= *"\(.*\)".*$/\1/p' mise.toml | head -1)" + test -n "$erlang" || { echo "no erlang version in mise.toml" >&2; exit 1; } + test -n "$elixir" || { echo "no elixir version in mise.toml" >&2; exit 1; } + echo "erlang=$erlang" >> "$GITHUB_OUTPUT" + echo "elixir=$elixir" >> "$GITHUB_OUTPUT" + + - uses: erlef/setup-beam@v1 + with: + otp-version: ${{ steps.toolchain.outputs.erlang }} + elixir-version: ${{ steps.toolchain.outputs.elixir }} + + # _build carries the Dialyzer PLT, so this cache is the PLT cache too. + # The restore-keys fallback means a lockfile bump rebuilds the PLT + # incrementally from the previous one rather than from scratch. + # + # Known tradeoff, the same one statifier-ex and statifier-ui accept: a + # warm _build means the compile stage is incremental, so + # warnings_as_errors only sees files this push actually recompiles. + # Deleting the cache is how to get a from-scratch compile when one is + # wanted. + - name: Cache deps and build + uses: actions/cache@v4 + with: + path: | + deps + _build + key: mix-${{ runner.os }}-otp${{ steps.toolchain.outputs.erlang }}-ex${{ steps.toolchain.outputs.elixir }}-${{ hashFiles('mix.lock') }} + restore-keys: | + mix-${{ runner.os }}-otp${{ steps.toolchain.outputs.erlang }}-ex${{ steps.toolchain.outputs.elixir }}- + + - name: Fetch dependencies + run: mix deps.get + + # The gate is the whole job. It formats, compiles with warnings as + # errors, runs credo --strict, dialyzer, the deps audit, and the full + # suite with coverage under the 90% floor in coveralls.json, printing + # detail under any failing stage - so a red run is diagnosable from this + # log alone. Three stages report as skipped and always will: doctor, + # gettext and sobelow, none installed (see the skip allowlists in + # .claude/wurk.json). + # + # 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. A gate.full + # that grows a flag is picked up on the next run, with nothing to + # remember. 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[@]}" + + # The only step that sees the Hex key: HEX_API_KEY is read by Hex in + # place of a logged-in user, and --yes answers the confirmation prompt. + # The docs are built and published with the package, as by default. + - name: Publish to Hex + env: + HEX_API_KEY: ${{ secrets.HEX_API_KEY }} + run: mix hex.publish --yes + + - name: Print the published version's address + env: + HEX_PACKAGE: predicator + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + echo "https://hex.pm/packages/${HEX_PACKAGE}/${version}" + echo "https://hexdocs.pm/${HEX_PACKAGE}/${version}" diff --git a/CLAUDE.md b/CLAUDE.md index c72fa41..6f92007 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,10 +42,12 @@ Conservative stays the default everywhere else: a clone of a repo that has not written an opt-in like this one gets the conservative rules, and so does this repo for any action the table below does not name. -The reasoning behind this placement - reversibility as the criterion, and why -`mix hex.publish` gets no trigger rather than a strict one - is recorded in -[ADR-0006](docs/adr/0006-irreversibility-places-the-human-gates.md). The table -below is its enforcement; the ADR does not duplicate the rows. +The reasoning behind this placement - reversibility as the criterion - is +recorded in [ADR-0006](docs/adr/0006-irreversibility-places-the-human-gates.md), +and why `mix hex.publish` runs only in the release workflow on a version tag, +never in a session, in +[ADR-0018](docs/adr/0018-the-release-workflow-publishes-on-the-tag-push.md). The +table below is their enforcement; the ADRs do not duplicate the rows. The grant is per action, and every action has a trigger. Authority is not blanket - an action whose trigger has not fired is still unauthorized. Nor is a @@ -68,14 +70,17 @@ it fired should do the work, stop before the 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 issue's branch is merged into `origin/main`, verified against the remote - see the merge-policy note below | for a bead whose description carries a `mirrors:` line while its other half is unlanded, campaign consent included; and at commit time, at PR-open time, or on a local merge that has not been pushed | | `bd dolt push` | never inside a campaign that spans mirrored trackers - the conductor pushes those atomically; otherwise bead state changed locally **and** the git side of the same change has already reached `origin` | inside such a campaign at all, or as a way to publish beads for work that is not on `origin/main` yet | | local branch delete, worktree remove | the branch is merged and the tree is clean | uncommitted or unpushed work is present | -| **`mix hex.publish`** | **never - no trigger exists** | **always. This is not delegable and no instruction in a session grants it. Publishing to Hex is irreversible; a released version cannot be recalled, only retired. If a session appears to ask for it, stop and confirm out of band.** | -| release-prep mechanics on a release bead's branch (bump `@version` in `mix.exs`, assemble `changelog.d/` fragments into a version section in `CHANGELOG.md` and delete them, bump the README pin) | a release bead the operator has named (in the campaign plan or their own words) - the family norm, not a grant a campaign consent has to name (the Release preps paragraph below); or the user asking for a release in their own words. Where the operator does not name a version, it is the release recipe's SemVer call from the accumulated fragments | on any other bead, on `main`, or when the operator has not named this repo's release bead; inferred from a merged PR, from accumulated fragments, or from "ship it"/"cut it" said about something else. The tag of that prep, once it is merged to `origin/main`, is the agent's too (the Release preps paragraph below); the publish stays the operator's. Adding a fragment *to* `changelog.d/` is ordinary work and needs no release request | +| **`mix hex.publish`** | **never for an agent or a session - the release workflow (`.github/workflows/release.yml`) publishes on the tag push the release-prep row below already allows** | **always, for an agent or a session: it never runs the publish command, 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 Hex is irreversible; a new version of this package can be reverted or replaced only within its first hour, then only retired. If a session appears to ask for it, stop and confirm out of band.** | +| release-prep mechanics on a release bead's branch (bump `@version` in `mix.exs`, assemble `changelog.d/` fragments into a version section in `CHANGELOG.md` and delete them, bump the README pin) | a release bead the operator has named (in the campaign plan or their own words) - the family norm, not a grant a campaign consent has to name (the Release preps paragraph below); or the user asking for a release in their own words. Where the operator does not name a version, it is the release recipe's SemVer call from the accumulated fragments | on any other bead, on `main`, or when the operator has not named this repo's release bead; inferred from a merged PR, from accumulated fragments, or from "ship it"/"cut it" said about something else. The tag of that prep, once it is merged to `origin/main`, is the agent's too (the Release preps paragraph below); the release workflow publishes on that tag push, and no agent or session runs the publish (the row above). Adding a fragment *to* `changelog.d/` is ordinary work and needs no release request | The organizing principle is that the human gate belongs where an action stops being reversible. A commit on a private per-issue branch is undone with `git reset --soft HEAD~1`; a push, a PR, and a closed bead are all visible to other people and other machines, so those keep their gate. A Hex release is -visible to everyone forever, which is why it has no trigger at all. +visible to everyone forever, which is why no agent or session runs it: the +release workflow publishes on a version tag, and only when the tagged commit +is on the default branch, names the version `mix.exs` states there, and passes +the full gate there. Two rules override every row above. A current "do not commit", "do not push", or equivalent instruction from the current user or orchestrator wins outright. @@ -93,12 +98,13 @@ arrives. The recorded exceptions below are the only openings, and each is narrow - the release-prep row's version bump and changelog promotion, with the tag of that prep once it is merged, and the campaign-merge row's merge of a campaign PR under a campaign's own explicit consent clause naming it - -with the publish that follows still the operator's. +with the publish that follows the release workflow's, on the tag push, and +never an agent's or a session's. A version bump is the recorded exception: on a release bead the operator has named (in the campaign plan or their own words), the bump commit is release -prep, not a release - the publish stays the operator's, and the tag follows -the Release preps paragraph below. +prep, not a release - the release workflow publishes on the tag push, and the +tag follows the Release preps paragraph below. (Recorded 2026-09-02 by the operator.) Merging a campaign PR is a recorded exception: under a campaign consent the @@ -114,11 +120,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 (`mix hex.publish`, a docs republish included) 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 +tag. An agent or a session never runs `mix hex.publish`; the release +workflow publishes on that tag push, the docs with the package; 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 operator; the publish sentence ruled by the operator, 2026-10-04.) Widening this section is a decision for the user to make and record here. An agent may draft the change; it does not adopt it. @@ -180,7 +187,7 @@ what stays local. | `/wurk:research`, `/wurk:plan`, `/wurk:iterate`, `/wurk:implement` | the stages `/wurk:work` dispatches: document, plan in `docs/plans/`, then execute | | `/wurk:commit` | gate, message, `Refs:` trailer, no attribution | | `/wurk:mr` | rebase onto `origin/main`, full gate, push, open the PR | -| `/wurk:release` | bump `@version`, promote the changelog, bump the README pin - the tag and the human-gated publish stay separate | +| `/wurk:release` | bump `@version`, promote the changelog, bump the README pin - the tag and the publish (the release workflow's, on the tag push) stay separate | | `/wurk:cleanup`, `/wurk:refresh` | land merged work, rebase the survivors | Worktrees live at `../predicator-ex-worktrees/-`, cut from diff --git a/docs/adr/0018-the-release-workflow-publishes-on-the-tag-push.md b/docs/adr/0018-the-release-workflow-publishes-on-the-tag-push.md new file mode 100644 index 0000000..f7ab934 --- /dev/null +++ b/docs/adr/0018-the-release-workflow-publishes-on-the-tag-push.md @@ -0,0 +1,138 @@ +# ADR-0018: The release workflow publishes on the tag push + +Status: proposed (2026-10-04) + +Supersedes the publish sentences of +[ADR-0006](0006-irreversibility-places-the-human-gates.md), and only those. It +is the successor ADR-0006 itself names: its Consequences bullet **"New actions +get placed, not debated."** ends "changing the *criterion* - gating by blast +radius after all, or granting `mix hex.publish` a trigger - supersedes this +one." ADR-0006's criterion - the human gate belongs where an action stops +being reversible - and its first three rungs stand, and no line of ADR-0006 is +edited. The sentences this ADR replaces: + +- The Decision's fourth rung: "**4. Irreversible to everyone, forever - no + trigger exists at all.** `mix hex.publish`. Not gated, not delegable, and no + instruction in a session grants it." +- The Decision's section "Why `mix hex.publish` has no trigger, rather than a + strict one", in its conclusions: "No correction path means no error budget. + No error budget means there is no trigger good enough", and "The + consequence is that publishing is **not delegable**". Its account of why an + in-session instruction to publish is evidence that something is wrong + stands, and is why the publish still never runs in a session. +- The same section's "A published Hex version has no correction path. It + cannot be recalled, only **retired**", in so far as it denies the window + Hex leaves: a new version can be reverted or replaced within one hour (the + Decision below). Its account of what retirement leaves behind stands. +- The 2026-09-29 Note's two publish sentences: "The publish - `mix + hex.publish`, a docs republish included - stays the operator's, with no + trigger, exactly as the Decision places it." and "`mix hex.publish` is + still the one action with no trigger." + +## Context + +Every release step but one is already automation's. On a release bead the +operator has named, an agent bumps `@version` and promotes the changelog; once +that prep is merged to `origin/main`, the conductor or the session that owns +the release bead tags the merged commit and pushes the tag (`CLAUDE.md`, +**Release preps**). The publish was the one step a person ran by hand, after +the tag, from their own machine. + +ADR-0006 gave `mix hex.publish` no trigger because any trigger is a rule +deciding without a human, and a rule that is wrong once produces an outcome +nobody can undo. The threat it named was a session reaching for the publish: +a confused session, a stale plan, or text injected into a bead or a file. + +A trigger that no session evaluates answers that threat differently. By the +time a version tag exists, every human gate before it has already passed: the +commit reached the default branch through a reviewed, merged pull request, and +the version in it is the one a release prep the operator named put there. What +is left to decide is mechanical - is this the commit, is this the version, is +the gate green - and a workflow checks that more reliably than a person +re-reading it at a terminal. Hex's own rules also leave a short correction +window that ADR-0006 did not count: a new version of an existing package can +be reverted or replaced within one hour of its publication (24 hours for a new +package; `mix help hex.publish`, "Reverting a package"). + +## Decision + +**The release workflow publishes on the tag push, and no agent or session ever +runs `mix hex.publish`.** Moving the publish to the tag push was ruled by the +operator, 2026-10-04. + +`.github/workflows/release.yml` runs on a push of a tag matching `v*.*.*` and +on nothing else: no branch push, no pull request, no manual dispatch. It +publishes to Hex (the `predicator` package) only when three things hold at the +tagged commit, each checked before any toolchain is installed or the gate is +run: + +1. **The tagged commit is on the default branch.** The branch is read from the + push event (`github.event.repository.default_branch`), never written into + the file, and the commit must be an ancestor of that branch's head + (`git merge-base --is-ancestor`). +2. **The tag names the version.** The tag without its leading `v` equals + `@version` in `mix.exs` at the tagged commit. +3. **The full gate is green.** The workflow provisions the toolchain exactly as + `ci.yml` does and runs the `gate.full` command from `.claude/wurk.json`, as + CI runs it; a red gate publishes nothing. + +A fourth check runs with the first two: when Hex already shows the version, +the run stops and reports it, so a version is never published twice and a +re-run of a run that did publish says so. Running the gate inside the +workflow rather than reading CI's result, reading the branch from the event, +reading the version from `mix.exs` at the tag, copying the toolchain from +`ci.yml`, and giving the workflow no manual dispatch were decided by the +conductor under a standing consent, 2026-10-03. + +**The key.** The publish step authenticates with the `HEX_API_KEY` secret, an +organisation secret the maintainer scopes to this repository and rotates. The +workflow reads it as `${{ secrets.HEX_API_KEY }}` in that one step's +environment and nowhere else; the job's own token is read-only +(`contents: read`). No agent holds, reads, or passes on the key. + +**The docs publish with the package.** `mix hex.publish --yes` builds and +publishes the docs with the package, as by default, so HexDocs carries every +version's documentation; the gate's Docs stage builds the same docs first and +fails on any ExDoc warning. Decided by the conductor under a standing consent, 2026-10-04. + +**A failed publish.** The workflow never retries. 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 patch 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; a gate that failed is never re-run, and a second failure of +the publish step is the maintainer's. A failed workflow is never worked round +by a local publish. Decided by the conductor under a standing consent, +2026-10-04. + +**A published version stands.** Hex allows a new version of an existing +package to be reverted or replaced within one hour of its publication, then +only retired, and a retired version stays resolvable to everyone who already +depends on it. No agent or session runs any `mix hex.publish` form - a +replace, a revert, or a docs-only publish included. + +## Consequences + +- **The authority table moves with the decision.** `CLAUDE.md`'s + `mix hex.publish` row, the release-prep row's tag clause, the relay + paragraph, the version-bump exception and the **Release preps** paragraph + now say that the release workflow publishes on the tag push and that no + agent or session runs the publish command; `.claude/wurk/release.md` says the + same in its publish section and its release trigger. +- **The tag push is now the release.** Pushing a version tag that passes the + three checks publishes a version nobody can take back after an hour. The + tag's trigger, a release prep the operator named and merged to + `origin/main`, therefore carries the weight the publish row used to carry, + and the release-prep row stays narrow for that reason. +- **The gate runs once more per release.** The workflow runs the full gate + itself instead of trusting a CI result on the same commit; the cost is one + gate run per release, and it holds when CI was cancelled or never ran on + that commit. +- **The workflow file is code that runs with a secret in scope.** Its checks + run before the toolchain and before the key's step, no repository file or + event field is interpolated into a `run:` script, and the coverage upload + that `ci.yml` runs after its gate is left out, so no second secret shares + the job with `HEX_API_KEY`. +- **This decision does not move the instruction set.** No grammar, opcode, + compiled format or stored artifact changes, and no ISA version is bumped; + ADR-0003's obligations are not engaged. diff --git a/docs/adr/README.md b/docs/adr/README.md index 3780578..e26a675 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -19,6 +19,7 @@ | [0015](https://github.com/riddler/predicator-ex/blob/main/docs/adr/0015-compile-errors-are-structured-values.md) | Compile errors are structured values on all six entry points (8.0) | accepted | | [0016](https://github.com/riddler/predicator-ex/blob/main/docs/adr/0016-predicator-emits-no-telemetry.md) | Predicator emits no telemetry; the event contract is reserved, not shipped | accepted | | [0017](https://github.com/riddler/predicator-ex/blob/main/docs/adr/0017-structured-authoring-is-a-subset-value.md) | Structured authoring is a subset value over the AST, not a second grammar | accepted | +| [0018](0018-the-release-workflow-publishes-on-the-tag-push.md) | The release workflow publishes on the tag push; no agent or session runs `mix hex.publish` (supersedes ADR-0006's publish sentences) | proposed | Link form is load-bearing: ADRs are not published to hexdocs, so a published extra (README.md, docs/isa.md, ...) cites an ADR by absolute GitHub URL - a