Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .claude/wurk/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
172 changes: 172 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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}"
37 changes: 22 additions & 15 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -68,14 +70,17 @@ it fired should do the work, stop before the irreversible step, and report.
| `bd close <id>` | 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.
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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/<bead-id>-<slug>`, cut from
Expand Down
Loading
Loading