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
7 changes: 4 additions & 3 deletions .claude/wurk/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
187 changes: 187 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -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}"
17 changes: 10 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 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
Expand All @@ -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
Expand All @@ -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.
Expand Down
14 changes: 7 additions & 7 deletions docs/adr/.cite-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
Loading
Loading