From 8065544b6940778e06966a2ab7c4dffa1b0bf0aa Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Sun, 4 Oct 2026 18:07:07 -0600 Subject: [PATCH] Publishes to Hex from CI on a tag push A new workflow, .github/workflows/release.yml, publishes this package to Hex when a v*.*.* tag is pushed, and only when the tagged commit is on the default branch (named by the push event, never written in the file), the tag names @version in mix.exs at that commit, Hex does not already show that version, and the full gate (gate.full from .claude/wurk.json) is green there. The toolchain-to-gate steps are copied byte for byte from ci.yml's gate job, Node included; the headless job is not copied. The publish step alone sees the HEX_API_KEY secret; the docs publish with the package. CLAUDE.md's release-prep row, relay paragraph and Release preps paragraph, and the closing section of .claude/wurk/release.md, now say an agent or a session never runs mix hex.publish: the release workflow publishes on the tag push, and a failed workflow is re-run from its Actions page, never worked round by a local publish. ADR-0019 records the decision at proposed; the index row moved cited README lines, so the ADR cites baseline is regenerated. Gate: full mix quality green on this staged tree (3,954 of 3,954 tests, 95.3% coverage, dialyzer clean, ADR cites green). Refs: sb-inp3 --- .claude/wurk/release.md | 7 +- .github/workflows/release.yml | 187 ++++++++++++++++++ CLAUDE.md | 17 +- docs/adr/.cite-baseline.json | 14 +- ...-publishes-through-the-release-workflow.md | 130 ++++++++++++ docs/adr/README.md | 1 + 6 files changed, 339 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 docs/adr/0019-a-tag-push-publishes-through-the-release-workflow.md diff --git a/.claude/wurk/release.md b/.claude/wurk/release.md index a056e2a5..c05b142b 100644 --- a/.claude/wurk/release.md +++ b/.claude/wurk/release.md @@ -210,6 +210,7 @@ lands through the same commit, push and merge rows as any other change. Once that prep is merged to `origin/main`, the conductor or the session that owns the release bead tags the merged commit with the new version and pushes the tag; never before the prep is on `origin/main`, and never on any other bead. -Publishing (`mix hex.publish`, a docs republish included) and the release -itself stay the operator's, in every campaign and outside every campaign, and -no consent delegates them. +An agent or a session never runs `mix hex.publish`, a docs republish +included: `.github/workflows/release.yml` publishes on that tag push, 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 00000000..42bf93d5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,187 @@ +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: statifier_blocks + 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 + + # Copied from ci.yml's gate job: the toolchain, the cache, the + # dependencies and the full quality gate, exactly as CI runs them. The + # headless job is not copied: it proves an optional-dependency tree CI + # already ran on this commit when it reached the default branch, and it + # is not part of the gate. + + # 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)" + node="$(sed -n 's/^node *= *"\(.*\)".*$/\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; } + test -n "$node" || { echo "no node version in mise.toml" >&2; exit 1; } + echo "erlang=$erlang" >> "$GITHUB_OUTPUT" + echo "elixir=$elixir" >> "$GITHUB_OUTPUT" + echo "node=$node" >> "$GITHUB_OUTPUT" + + - uses: erlef/setup-beam@v1 + with: + otp-version: ${{ steps.toolchain.outputs.erlang }} + elixir-version: ${{ steps.toolchain.outputs.elixir }} + + # The Map hook's layout tests run it through the vendored elkjs with + # Node (ADR-0005's 2026-09-28 amendment on decision 1); nothing is + # installed from a registry and nothing Node builds ships. + - uses: actions/setup-node@v4 + with: + node-version: ${{ steps.toolchain.outputs.node }} + + # _build carries the Dialyzer PLT + # (_build/dev/dialyxir_erlang-*_elixir-*_deps-dev.plt), so this cache is + # the PLT cache too. statifier is an ordinary Hex dependency here (the + # family's SHA-pinning contract is retired now that every satellite is + # published), so mix.lock's version pin is what gets built - CI never + # sets STATIFIER_PATH. 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 the rest of the family accepts: 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, 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 + # here (they are the manifest's not_applicable_skips). + # + # 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: statifier_blocks + 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 dd6f85b0..e0a7957f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,7 @@ irreversible step, and report. | merging a campaign PR | a campaign consent the operator adopted verbatim that names automatic merges, with every named condition met (full gate green, CI green, firewall scan clean with a positive control, any named review gate passed) | outside such a consent; any named condition unmet; any PR the consent's carve-outs hold for the operator | | `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 | +| 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: an agent or a session never runs `mix hex.publish` - the release workflow publishes on the tag push this row allows, and a failed workflow is re-run from its Actions page, never worked round by a local publish | 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 @@ -84,7 +84,8 @@ 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. +publish that follows is the release workflow's, on that tag push, and never +an agent's or a session's command. Merging a campaign PR is a recorded exception: under a campaign consent the operator has adopted verbatim that names automatic merges, with every @@ -100,11 +101,13 @@ 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 -else this file reserves for the operator changes. (Recorded 2026-09-25 by -the operator.) +tag. An agent or a session never runs `mix hex.publish`, a docs republish +included: the release workflow, `.github/workflows/release.yml`, publishes +the package and its docs 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, ADR-0019.) 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/.cite-baseline.json b/docs/adr/.cite-baseline.json index 584ff0ab..453da312 100644 --- a/docs/adr/.cite-baseline.json +++ b/docs/adr/.cite-baseline.json @@ -94,13 +94,13 @@ "0005-liveview-editor.md|0014-document-accepts-declaration.md:194-195": {"sha256": "f4b04c12b4641921576867755d3a03efd2f216e33aa3782f068d80ed3e611f58", "text": "6. The editor surface is a row of the declarations panel, written through its own command. The datamodel roots are authored in the declarations"}, "0005-liveview-editor.md|0014-document-accepts-declaration.md:207-208": {"sha256": "d4d4f0ddc02403ee0cf8e78f4d411b54a62102e9c796db3d64db8bd0d5c47a6e", "text": "inverse law holds by construction. It is a command rather than editor state by 2g's own test: the list is in the document and in its hash, and"}, "0005-liveview-editor.md|0014-document-accepts-declaration.md:3-3": {"sha256": "51970cb9c41eb624b392d6277a8b49870cbbc1300f6e1ddd58a4ab3615305a2c", "text": "Status: accepted (2026-09-22, drafted for `sb-mgei` under the operator's"}, - "0005-liveview-editor.md|README.md:40-43": {"sha256": "416ea7e486ec7ccc34ac56e464ac34e29bb50c88e4b0fa26f5279c08ce1f4278", "text": "is statifier-ex's ADR-0052, `sp-ADR-0003` is statifierpersistence's). A citation into `lib/` or `test/` is anchor-qualified and SHA-labelled. `mix adr.cites` ch"}, - "0005-liveview-editor.md|README.md:41-44": {"sha256": "5264aba4ca5e12cba9ea1f40ded69a11df75cb4146f989e2985aef6165db1388", "text": "A citation into `lib/` or `test/` is anchor-qualified and SHA-labelled. `mix adr.cites` checks record-to-record citations only, and a code line moves under a re"}, - "0005-liveview-editor.md|README.md:42-44": {"sha256": "5264aba4ca5e12cba9ea1f40ded69a11df75cb4146f989e2985aef6165db1388", "text": "A citation into `lib/` or `test/` is anchor-qualified and SHA-labelled. `mix adr.cites` checks record-to-record citations only, and a code line moves under a re"}, - "0005-liveview-editor.md|README.md:42-45": {"sha256": "0d7f43a4cacd6426773746f3899ec248592d7bd3be8c8e1531bbd78c8e8ebfe5", "text": "A citation into `lib/` or `test/` is anchor-qualified and SHA-labelled. `mix adr.cites` checks record-to-record citations only, and a code line moves under a re"}, - "0005-liveview-editor.md|README.md:43-43": {"sha256": "5d4812b4a28c0442e18ba7044e53519f2c8b94c6d64e13aaa0ff614523949cde", "text": "`mix adr.cites` checks record-to-record citations only, and a code line"}, - "0005-liveview-editor.md|README.md:43-45": {"sha256": "3c0b0bf6e18bc83bbe597d9b64b1b0e0254d99f374bc1c1f55d1e9059e37db46", "text": "`mix adr.cites` checks record-to-record citations only, and a code line moves under a record within hours while sibling beads land, so a bare number is advisory"}, - "0005-liveview-editor.md|README.md:45-45": {"sha256": "d497a323a255b2ab891cc56d11b769a74d736396f03f44b0627577cfc66bc7de", "text": "number is advisory rather than load-bearing. Write the anchor the reader"}, + "0005-liveview-editor.md|README.md:40-43": {"sha256": "14d7d32effcd877077d6cdaf97ecc97e253e8bd834a9156be086088b91218fe4", "text": "a cross-repo citation carries the owning repo's beads prefix (`st-ADR-0052` is statifier-ex's ADR-0052, `sp-ADR-0003` is statifierpersistence's). A citation int"}, + "0005-liveview-editor.md|README.md:41-44": {"sha256": "416ea7e486ec7ccc34ac56e464ac34e29bb50c88e4b0fa26f5279c08ce1f4278", "text": "is statifier-ex's ADR-0052, `sp-ADR-0003` is statifierpersistence's). A citation into `lib/` or `test/` is anchor-qualified and SHA-labelled. `mix adr.cites` ch"}, + "0005-liveview-editor.md|README.md:42-44": {"sha256": "8a76441c3d600c67a4a3d1b8c3ed9b059ea066d63204c1c8556badbda5f247ce", "text": "A citation into `lib/` or `test/` is anchor-qualified and SHA-labelled. `mix adr.cites` checks record-to-record citations only, and a code line"}, + "0005-liveview-editor.md|README.md:42-45": {"sha256": "5264aba4ca5e12cba9ea1f40ded69a11df75cb4146f989e2985aef6165db1388", "text": "A citation into `lib/` or `test/` is anchor-qualified and SHA-labelled. `mix adr.cites` checks record-to-record citations only, and a code line moves under a re"}, + "0005-liveview-editor.md|README.md:43-43": {"sha256": "63655ce158abfdd267445ee2469e73f2865e620609d1bbb60fb9977d88cde40c", "text": "A citation into `lib/` or `test/` is anchor-qualified and SHA-labelled."}, + "0005-liveview-editor.md|README.md:43-45": {"sha256": "5264aba4ca5e12cba9ea1f40ded69a11df75cb4146f989e2985aef6165db1388", "text": "A citation into `lib/` or `test/` is anchor-qualified and SHA-labelled. `mix adr.cites` checks record-to-record citations only, and a code line moves under a re"}, + "0005-liveview-editor.md|README.md:45-45": {"sha256": "8fb7d5dfd6926d767456f65050e14e599daabfe093635436e40fb42e20d92896", "text": "moves under a record within hours while sibling beads land, so a bare"}, "0006-datamodel-document.md|0001-block-document-schema.md:1013-1013": {"sha256": "db6d2f065313e4caee97d27e3758334e294cfa9c646c7f5a59bb05a64d4a30e0", "text": "proposed (`docs/adr/0006-datamodel-document.md:455`), and accepting it is"}, "0006-datamodel-document.md|0002-block-type-behaviour.md:4010-4010": {"sha256": "d5aa239f51cbac1787fe51a743b3816124f28a903c40d61237d293ef9029aee3", "text": "## Amendment (2026-09-06): `core.onevent` declares its event payload, and a `capture` that reads past it is refused at compile"}, "0006-datamodel-document.md|0005-liveview-editor.md:6254-6254": {"sha256": "e6563062887e0e7def12919bec097eb807871c84eca7e75625738a5d36fe58da", "text": "(`docs/adr/0006-datamodel-document.md:455`), and accepting it is a separate"}, diff --git a/docs/adr/0019-a-tag-push-publishes-through-the-release-workflow.md b/docs/adr/0019-a-tag-push-publishes-through-the-release-workflow.md new file mode 100644 index 00000000..752c8e77 --- /dev/null +++ b/docs/adr/0019-a-tag-push-publishes-through-the-release-workflow.md @@ -0,0 +1,130 @@ +# ADR-0019: A tag push publishes through the release workflow - three checks at the tagged commit, the full gate, then Hex + +Status: proposed (2026-10-04, drafted under the operator's campaign consent; +the move to publishing on a tag push was ruled by the operator, +2026-10-04). It merges at proposed and stays proposed until the workflow it +records has published a version of this package; flipping it to accepted is +a separate request through the same `docs/adr/` gate, with that workflow +run's address as the evidence beside the SHA. + +Cites into `.github/workflows/ci.yml` and `CLAUDE.md` were read at +`9874ead` and carry their anchors; the workflow this record decides, +`.github/workflows/release.yml`, lands in the same request as this record +and is cited by its step names. Re-locate by anchor, not by number. + +## Context + +**The publish was the one release step a person ran by hand.** Before this +record, the version bump and the changelog promotion of a release prep, and +the tag of that prep once it is merged, were already an agent's work under +`CLAUDE.md`'s authority table (the release-prep row) and its Release preps +paragraph; the publish, `mix hex.publish`, was the operator's alone, and the +release-prep row's last cell held it back "always". Every release therefore +waited on the operator being at a machine that held the Hex credentials, +after everything that decides what ships had already happened. + +**What ships is decided before the publish.** The tag names a commit; the +version is `@version` in `mix.exs` at that commit; the code is that commit's +tree; and the full quality gate (`mix quality`, the `gate.full` command in +`.claude/wurk.json`) is what says the commit is fit. CI already runs that +gate on every push to the default branch (`ci.yml:96`, the "Full quality +gate" step of the `gate` job). The publish adds no judgement of its own; it +uploads what the three facts already fix. + +## Decision + +1. **A tag push is the trigger, and nothing else.** + `.github/workflows/release.yml` runs on a push of a tag matching + `v*.*.*` and on no other event: no branch push, no pull request, no + manual dispatch. Its one job, "Verify, gate and publish", holds + `contents: read` and no other token permission, and runs at most once + per tag at a time, never cancelled once started. + +2. **Three conditions hold at the tagged commit, or nothing is published.** + - **On the default branch.** The tagged commit is an ancestor of the + default branch's head (`git merge-base --is-ancestor`), the branch + named by the push event itself + (`github.event.repository.default_branch`) and fetched under that + name, never written into the file (the steps "Fetch the default + branch" and "Check the tagged commit is on the default branch"). + - **The tag names the version.** The tag name without its leading `v` + equals `@version` in `mix.exs` at the tagged commit (the step "Check + the tag names the version in mix.exs"). + - **The full gate is green.** The workflow runs the gate itself, the + `gate.full` command read out of `.claude/wurk.json` exactly as the + `gate` job of `ci.yml` runs it, after the toolchain, cache and + dependency steps copied from that job (`ci.yml:26-102`, from the + comment above "Read the toolchain out of mise.toml" to the end of + "Full quality gate"), Node included. The `headless` job is not copied: + it proves an optional-dependency tree (ADR-0005 decision 1), it is not + part of the gate, and `ci.yml` runs it on every push to the default + branch. + + The first two checks, and a fourth below, run before any toolchain is + installed, so a wrong tag stops in seconds. Running the gate inside the + workflow, rather than reading CI's result for the commit, reading the + default branch from the event, reading the version from `mix.exs` at the + tag, copying the toolchain from `ci.yml` rather than sharing it, and + leaving out a manual dispatch were decided by the conductor under a + standing consent, 2026-10-03. + +3. **The registry is Hex, and the key is named, never held.** The publish + step runs `mix hex.publish --yes`, which builds and publishes the package + and its docs together; HexDocs keeps every version's documentation as it + did when the publish was by hand, and the docs that publish are the docs + the gate built. The step authenticates with the `HEX_API_KEY` secret, + read as `${{ secrets.HEX_API_KEY }}` in that one step's environment and + nowhere else; Hex reads it in place of a logged-in user. The secret is + the operator's, created and rotated outside this repository; no agent + or session reads, writes or holds its value. The last step prints the + published version's addresses on hex.pm and HexDocs. Publishing the + docs with the package was decided by the conductor under a standing + consent, 2026-10-04. + +4. **A failed publish is not 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 patch version is tagged; a tag is never moved or pushed again. + A run whose publish step failed on a registry or network error may be + re-run once by hand from the run's page in the Actions tab; the re-run + repeats the whole job, the checks and the gate included, which is safe + because each is read-only until the publish. A failed + gate is never re-run, and a second failure of the publish is the + operator's. A version Hex already shows is reported and never published + again: the step "Check Hex does not already show this version" stops the + run (or a re-run) before the toolchain when Hex answers that the version + exists, and also stops it when Hex does not answer. This handling was + decided by the conductor under a standing consent, 2026-10-04. + +5. **An agent or a session never runs the publish.** `CLAUDE.md`'s + release-prep row, its relay paragraph and its Release preps paragraph, + and the closing section of `.claude/wurk/release.md`, say so in the + operator's words: the release workflow publishes on the tag push the + release-prep row already allows, and a failed workflow is re-run from + its Actions page, never worked round by a local publish. + +## Consequences + +- A release is the prep, its merge and its tag; the publish follows from + the tag with no one at a credentialed machine. Each release costs one + more run of the full gate, inside the workflow. +- A published version stands. Hex lets a new version of an existing + package be replaced or reverted only within one hour of its publication + (`mix help hex.publish`, "Reverting a package"). The workflow does + neither: its Hex check refuses to publish a version Hex already shows, + and no agent or session replaces or reverts one. After that hour a + version can only be retired (`mix help hex.retire`), which flags it but + keeps it resolvable. +- A tag pushed for a commit off the default branch, or naming a version + `mix.exs` does not state, or on a red gate, is visible in the Actions + log and publishes nothing; it is not undone by deleting or moving the + tag. +- If the publish step lands the package but its docs fail, + the version is on hex.pm without docs; a re-run reports it as already + on Hex, and the docs are the operator's to republish by hand, never an + agent's or a session's. +- The workflow's toolchain block is a copy of `ci.yml`'s, so a change to + how CI provisions the toolchain or runs the gate is made in both files. +- `changelog.d/README.md`'s "At release" section still says the publish + is the operator's; that sentence is outside this record's request and + is corrected separately. diff --git a/docs/adr/README.md b/docs/adr/README.md index ce4b2e0f..36e67716 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -29,6 +29,7 @@ | [0016 Amendment 2026-09-29](0016-document-describes-itself.md#amendment-2026-09-29-decision-2s-edge-lines-name-a-container-by-a-noun) | Decision 2: an edge line names a container by its title, else a short noun keyed by the module its type resolves to; the container edges dispatch on that module, and a module load is decision 4's one exception | accepted | | [0017](0017-block-types-explain-themselves.md) | Block types explain themselves - an optional `explain/0` paragraph, a delayed send that says when, and a `:timer` edge in the describe | accepted | | [0018](0018-the-map-reads-the-view-model.md) | The Map is a reader of the view model - a drawn projection beside a host's list, elkjs vendored whole, draw-only hooks, and one struct in | accepted | +| [0019](0019-a-tag-push-publishes-through-the-release-workflow.md) | A tag push publishes through the release workflow - three checks at the tagged commit, the full gate, then Hex; no agent or session runs the publish | proposed | New ADRs: next number, same three-section format (Context, Decision, Consequences). Pick the number against a freshly fetched remote.