Release machine — happy and sad paths

Terminal mockups for scripts/release.shTier B of docs/design-release-machine.md, drawn so the option stays open at full information. Tier A ships first and changes none of these screens except adding verify.

Conformance is partial and deliberate. Glyph vocabulary, phases, · · · leaders and 92-col width follow desktop/scripts/REPORT-STYLE.md. Three departures the existing renderer cannot produce, which is why §12 item 5 carries a renderer cost the first draft did not budget: detail sits inline in the right cluster rather than on the indent line (rule 5), so a 14-step release reads as 14 lines rather than 28 — which in turn makes rule 8's single right edge impossible; no header/footer panels; and no separate 1…N display number, the [tag] carrying both. Amend REPORT-STYLE.md for list-shaped callers, or conform — but not silently.

Superseded 23 Aug 2026 — the order changed. The pypi required-reviewer hold was removed, so the tag push now publishes and moves LAST, after the soft uploads. The HELD screen below has no equivalent any more. Kept because the sad paths (F1 failure, F3 stranded, F4 log-vs-probe disagreement) are unchanged and were built as drawn. Live output: ./scripts/release.sh plan.

docs/design-release-machine.md · failure modes F1–F10 · 23 Aug 2026

ok info warn fail skip glyph vocabulary from bristlenose/ui_kinds.py — never extended

HAPPYplan

Always first, always free. Note row 4: gate freshness (F5) — the check that would have caught 0.27.0's build failure 1 before eleven minutes of build, by noticing a gate hadn't run since the commits it guards.

$ ./scripts/release.sh plan 0.28.0

Release 0.28.0 · Tier 1 · minor
  from v0.27.0 · 23 commits · bristlenose/ frontend/ desktop/ touched

PRE-FLIGHT 4 steps
   preflight          · · · · · · · · · · · · check-release-ready.sh 0.28.0   [1]
   prose present      · · · · · · · · · · · · CHANGELOG + README carry 0.28.0 [2]
   gate freshness     · · · · · · · · · · · · 1 stale                         [3]
       check-window-surfaces  last green at 4d462e6e · 6 commits behind HEAD
      a gate is only as fresh as the last time something ran it
   skip-worktree      · · · · · · · · · · · · 2 tracked, both match HEAD      [4]

PUBLISH · reversible 3 steps
   bump + commit + tag  · · · · · · · · · · bump-version.py minor           [5]
   push main + tag      · · · · · · · · · · two pushes, never --tags        [6]
   mac artefacts        · · · · · · · · · · build-all · build-dmg  ~65 min  [7]

GATE
   ci green             · · · · · · · · · · both runs · gate:ci                [8]

PUBLISH · irreversible 5 steps
   upload testflight    · · · · · · · · · · soft — spends build 2857     [9]
   upload dmg           · · · · · · · · · · permalink swaps              [10]
   approve publish      · · · · · · · · · · HARD — gate:approval         [11]
   website deploy       · · · · · · · · · · manual · after PyPI 200         [12]
   snap edge            · · · · · · · · · · gh workflow run snap.yml        [13]

  estimate  ~1h55 pipeline  ·  excludes human decision time
  events    .release/0.28.0/events.jsonl

 1 warning. Run anyway with run 0.28.0, or clear it first:
    desktop/scripts/check-window-surfaces.sh

HAPPYrun — to the approval gate

Each step prints the command before running it — the conductor rule, applied to the driver. The run stops at the hard gate and exits 0: a release waiting on a person is behaving correctly, not failing (F6).

$ ./scripts/release.sh run 0.28.0

PRE-FLIGHT
   preflight          · · · · · · · · · · · · 31 checks, 0 fail        [1]    52s
   gate freshness     · · · · · · · · · · · · all green at HEAD        [3]     2s

PUBLISH · reversible
  $ ./scripts/bump-version.py minor
   bump + commit + tag  · · · · · · · · · · v0.28.0 == HEAD          [5]     4s
  $ git push origin main && git push origin v0.28.0
   push main + tag      · · · · · · · · · · 2 runs in flight         [6]    11s
  $ SIGN_IDENTITY="$SIGN_IDENTITY" desktop/scripts/build-all.sh
        builds the sidecar, archives, exports — cold caches after a bump
   build-all            · · · · · · · · · · 4 phases, 15 gates       [7a] 11m02s
   build-dmg            · · · · · · · · · · notarised, stapled       [7b] 28m40s
      art  dmg = Bristlenose-0.28.0.dmg · 651 MB · expires 22 Sep

GATE
   ci green             · · · · · · · · · · CI 37m · Release 39m     [8]  2m18s

PUBLISH · irreversible
  $ desktop/scripts/upload-testflight.sh          spends build 2857 · not reusable
   upload testflight    · · · · · · · · · · build 2857 accepted      [9]  5m41s
      art  delivery-uuid = 8f3c1a04-… · recorded, no longer hand-captured
   upload dmg           · · · · · · · · · · permalink swapped        [10] 12m22s

 HELD · approve publish [11]
    gate:approval · nothing publishes until a human approves
     https://github.com/cassiocassio/bristlenose/actions/runs/4471002
       Review deployments ▸ pypi ▸ Approve and deploy  ← ticking the box is not pressing it
       expires 22 Sep 2026

  resume with  ./scripts/release.sh run 0.28.0
  8 of 13 steps done · nothing unrecoverable has happened

$ echo $?
0

F1a step fails

The 0.27.0 failure made structurally impossible. The driver redirects — never pipes — so $? is the build's own status, and the tail is printed through tr '\r' '\n' so a rsync --progress-style carriage-return log doesn't render as one frozen line (tricky things #1 and #2, fixed in one place).

$ ./scripts/release.sh run 0.28.0

  … 4 steps ok, skipped …

  $ SIGN_IDENTITY="$SIGN_IDENTITY" desktop/scripts/build-all.sh
   build-all            · · · · · · · · · · exit 1 at step 1/11       [7a]   26s

    PRE-FLIGHT · window surfaces
     assertion unsatisfiable: no line matches 'handshakeProjectPath'
          ServeFleet.swift renamed it plural in 037b371e (20 Aug)
    ✗ Build failed

    .release/0.28.0/logs/build-all.1.log · last 20 lines above

 Stopped at build-all. Nothing irreversible has happened.

    the failing check, on its own (<1s):
      desktop/scripts/check-window-surfaces.sh

    the whole step, once it passes:
      SIGN_IDENTITY="$SIGN_IDENTITY" desktop/scripts/build-all.sh

    or fix and resume:
      ./scripts/release.sh run 0.28.0        picks up at build-all

  4 of 14 steps done · tag v0.28.0 exists on origin and publishes nothing
  to abandon:  git push --delete origin v0.28.0 && git tag -d v0.28.0

$ echo $?
1

F3the stranded step

Driver killed mid-upload — laptop slept, Ctrl-C, a self-imposed timeout. The fold sees a running with no terminus and reports unknown. It is never auto-retried: a half-finished upload is exactly where guessing is worst. Note the driver reads the script's own resume design rather than assuming the worst.

$ ./scripts/release.sh run 0.28.0

 Previous attempt did not finish cleanly.

   upload-dmg           · · · · · · · · · · unknown — started 21:04, no terminus
      last event  @bn bar parent=upload-dmg:3 done=118 total=651  (18%)
      probe       HEAD https://bristlenose.app/dmg/… → 302 → 0.27.0
                  the permalink still serves the OLD version — the swap is atomic
            and had not happened, so this transfer was genuinely incomplete
      on disk     .upload-Bristlenose-0.28.0.dmg.part  ·  118 MB  ·  rsync --partial resumes

  This step is not auto-retried. Choose:
    ./scripts/release.sh retry upload-dmg   resumes the partial transfer
    ./scripts/release.sh run 0.28.0 --skip upload-dmg  ship the rest, dmg later

  everything before it is recorded ok · 10 of 14 steps done

$ echo $?
1

F4the log and the world disagree

The case D1 exists for. The log says we uploaded; the channel says otherwise. The probe wins, the disagreement is appended as an event rather than silently resolved, and the run refuses to report complete. This is also what caught 0.27.0's tricky thing #6 — a gate believed cleared that pending_deployments said was still pending.

$ ./scripts/release.sh verify 0.28.0

Channels · 0.28.0

   PyPI               · · · · · · · · 200 · /pypi/bristlenose/0.28.0/json
   GitHub Release     · · · · · · · · v0.28.0 · 2 assets
   Homebrew           · · · · · · · · bristlenose-0.28.0.tar.gz
   TestFlight         · · · · · · · · build 2857 · Ready to Test
   .dmg               · · · · · · · · 302 → Bristlenose-0.28.0.dmg
   Snap               · · · · · · · · edge = 0.27.0 rev 9
      log    step snap-edge status=ok at 22:41 (workflow conclusion: success)
      probe  api.snapcraft.io channel-map → edge 0.27.0
      the workflow uploaded; the store has not released it
      → gh workflow run snap.yml --ref v0.28.0
   Website            · · · · · · · · changelog names 0.27.0
      → cd ../bristlenose-site && ./build.py && ./deploy.sh

  expiry   .dmg 22 Sep (30d from build) · TestFlight 21 Nov (90d from upload)

 5 of 7 channels on 0.28.0 · 2 outstanding

$ echo $?
1

F6status — the default

What you type mid-release to answer "where am I". Folded from the log, costs nothing. Pipeline time sums the measured steps and shows the remainder rather than deriving itself as wall − held, which would reconcile by construction — the same tautology as attempted == succeeded + failed. The unaccounted row is the only one of the four that can ever catch anything. The 21:00 weekday nudge is absent here by design: it prints only when it would change the answer (weekday, before 21:00), and on the 0.27.0 evidence base a release reaches this gate after 21:00 by its own duration.

$ ./scripts/release.sh

0.28.0 · Tier 1 · held · started 19:41 · 3h07 ago · 1 of 2 attempts in .release/

   preflight   prose   gate-freshness   skip-worktree
   bump-version   push   build-all   build-dmg
   ci-green    upload-testflight   upload-dmg
   approve publish   held 1h48 · expires 22 Sep
   website-deploy   snap-edge

  11 done · 1 held · 2 pending   = 14

  pipeline time    1h01   measured · sum of 11 steps
  waiting on you   1h48   1 held interval
  unaccounted        18m  between steps — driver idle, or a gap in the log
  wall clock       3h07

   approve at github.com/…/runs/4471002 then release.sh run 0.28.0

DATAwhat the log holds

Six event kinds, all of them report.sh's existing vocabulary. The sink adds run, seq, attempt and exit; everything else passes through. State is folded from this on every read, never stored — so derived state cannot drift from the record, because it is the record.

.release/0.28.0/events.jsonl

{"ts":"2026-08-23T19:41:02Z","run":"0.28.0","seq":1,"kind":"meta","tier":1,"bump":"minor"}
{"ts":"…19:41:04Z","run":"0.28.0","seq":2,"kind":"step","step":"preflight","attempt":1,"status":"start"}
{"ts":"…19:41:56Z","run":"0.28.0","seq":3,"kind":"step","step":"preflight","attempt":1,
 "status":"ok","exit":0,"elapsed":52,"detail":"31 checks, 0 fail"}
{"ts":"…19:43:11Z","run":"0.28.0","seq":8,"kind":"step","step":"build-all","attempt":1,
 "status":"fail","exit":1,"log":".release/0.28.0/logs/build-all.1.log",
 "detail":"window surfaces: assertion unsatisfiable"}
{"ts":"…20:58:40Z","run":"0.28.0","seq":22,"kind":"art","key":"delivery-uuid",
 "value":"8f3c1a04-2b77-4e19-9c31-6ad0f1e2c845"}
{"ts":"…21:02:14Z","run":"0.28.0","seq":24,"kind":"gate","step":"approve-publish",
 "result":"held","evidence":"pending_deployments: pypi"}
{"ts":"…22:50:31Z","run":"0.28.0","seq":31,"kind":"check","step":"snap-edge",
 "result":"disagree","evidence":"log=ok probe=edge:0.27.0"}
{"ts":"…22:51:02Z","run":"0.28.0","seq":32,"kind":"done","status":"partial"}

gitignored — machinery, not history. docs/release-log.md stays the committed human record, and now gets its numbers from here rather than from recall.