Compare commits

...

641 commits
0.1.0 ... main

Author SHA1 Message Date
91aee7f842 Merge pull request 'fix(labels): compare release shape to merge base' (#277) from build/275-merge-base-release-shape into main
All checks were successful
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 9s
CI / action-exercise (push) Successful in 8s
CI / docs-sync-exercise (push) Successful in 8s
release / release (push) Successful in 9s
CI / test (push) Successful in 3m46s
Reviewed-on: #277
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
2026-09-01 05:52:26 +00:00
codex-bot-andresmgsl
0b1fa70d60 fix(changelog): match grouped fragment shape
All checks were successful
CI / action-exercise (pull_request) Successful in 9s
CI / self-guards (pull_request) Successful in 11s
CI / release-exercise (pull_request) Successful in 14s
CI / docs-sync-exercise (pull_request) Successful in 7s
labels / labels (pull_request) Successful in 10s
CI / test (pull_request) Successful in 4m15s
Refs guard / refs-not-closing (pull_request) Successful in 8s
2026-08-31 21:24:50 +00:00
codex-bot-andresmgsl
4bce62e1fa test(labels): prove merge-base fallback
Some checks failed
CI / self-guards (pull_request) Failing after 9s
CI / action-exercise (pull_request) Successful in 9s
CI / release-exercise (pull_request) Successful in 14s
CI / docs-sync-exercise (pull_request) Successful in 7s
labels / labels (pull_request) Successful in 10s
Refs guard / refs-not-closing (pull_request) Successful in 8s
CI / test (pull_request) Successful in 4m18s
2026-08-31 21:20:35 +00:00
codex-bot-andresmgsl
4ce43c4a3b test(labels): document isolated fixture state
Some checks failed
CI / self-guards (pull_request) Failing after 10s
CI / action-exercise (pull_request) Successful in 8s
CI / release-exercise (pull_request) Successful in 13s
CI / docs-sync-exercise (pull_request) Successful in 7s
Refs guard / refs-not-closing (pull_request) Successful in 8s
labels / labels (pull_request) Successful in 9s
CI / test (pull_request) Successful in 3m43s
2026-08-31 21:11:30 +00:00
codex-bot-andresmgsl
dc29fdc84c fix(labels): compare release shape to merge base
Some checks failed
CI / self-guards (pull_request) Failing after 10s
CI / action-exercise (pull_request) Successful in 8s
CI / release-exercise (pull_request) Successful in 15s
CI / docs-sync-exercise (pull_request) Successful in 7s
labels / labels (pull_request) Successful in 10s
Refs guard / refs-not-closing (pull_request) Successful in 7s
CI / test (pull_request) Failing after 42s
2026-08-31 21:06:50 +00:00
codex-bot-andresmgsl
328c8707df test(labels): reproduce moving base-tip warning
Some checks failed
CI / self-guards (pull_request) Successful in 12s
CI / release-exercise (pull_request) Successful in 15s
CI / action-exercise (pull_request) Successful in 8s
CI / docs-sync-exercise (pull_request) Successful in 8s
Refs guard / refs-not-closing (pull_request) Successful in 8s
labels / labels (pull_request) Successful in 10s
CI / test (pull_request) Failing after 45s
2026-08-31 21:04:41 +00:00
85290031b2 Merge pull request 'fix: resume a stranded merge-door release' (#274) from build/273-resume-merge-door into main
All checks were successful
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 13s
CI / action-exercise (push) Successful in 11s
CI / docs-sync-exercise (push) Successful in 11s
release / release (push) Successful in 11s
CI / test (push) Successful in 5m51s
Reviewed-on: #274
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
2026-08-31 16:57:09 +00:00
codex-bot-andresmgsl
d944bddecc docs: repair release workflow anchors after preflight shift
All checks were successful
CI / release-exercise (pull_request) Successful in 15s
CI / self-guards (pull_request) Successful in 16s
CI / action-exercise (pull_request) Successful in 12s
CI / docs-sync-exercise (pull_request) Successful in 12s
labels / labels (pull_request) Successful in 16s
CI / test (pull_request) Successful in 5m17s
Refs guard / refs-not-closing (pull_request) Successful in 7s
2026-08-31 15:55:21 +00:00
codex-bot-andresmgsl
715663cf53 docs: group release recovery changelog entry
All checks were successful
CI / action-exercise (pull_request) Successful in 10s
CI / self-guards (pull_request) Successful in 13s
CI / release-exercise (pull_request) Successful in 16s
CI / docs-sync-exercise (pull_request) Successful in 9s
labels / labels (pull_request) Successful in 11s
CI / test (pull_request) Successful in 4m7s
Refs guard / refs-not-closing (pull_request) Successful in 8s
2026-08-31 14:02:11 +00:00
codex-bot-andresmgsl
e2aa834601 test: derive preflight in release path fixtures
Some checks failed
CI / self-guards (pull_request) Failing after 9s
CI / action-exercise (pull_request) Successful in 8s
CI / release-exercise (pull_request) Successful in 14s
CI / docs-sync-exercise (pull_request) Successful in 8s
labels / labels (pull_request) Successful in 10s
CI / test (pull_request) Successful in 3m47s
Refs guard / refs-not-closing (pull_request) Successful in 9s
2026-08-31 13:55:25 +00:00
codex-bot-andresmgsl
f19658ea82 test: isolate preflight fact environments
Some checks failed
CI / self-guards (pull_request) Failing after 17s
CI / action-exercise (pull_request) Successful in 16s
CI / release-exercise (pull_request) Successful in 24s
CI / docs-sync-exercise (pull_request) Successful in 13s
labels / labels (pull_request) Successful in 14s
CI / test (pull_request) Failing after 1m8s
Refs guard / refs-not-closing (pull_request) Successful in 8s
2026-08-31 11:24:53 +00:00
codex-bot-andresmgsl
79e747b163 docs: describe merge-door resume recovery
Some checks failed
CI / action-exercise (pull_request) Successful in 29s
CI / self-guards (pull_request) Failing after 32s
CI / release-exercise (pull_request) Successful in 39s
CI / docs-sync-exercise (pull_request) Successful in 12s
labels / labels (pull_request) Successful in 14s
Refs guard / refs-not-closing (pull_request) Successful in 11s
CI / test (pull_request) Failing after 1m11s
2026-08-31 11:21:09 +00:00
codex-bot-andresmgsl
7bd331a44d fix: resume stranded merge-door publishes
Some checks failed
CI / action-exercise (pull_request) Successful in 12s
CI / self-guards (pull_request) Successful in 16s
CI / release-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 12s
labels / labels (pull_request) Successful in 13s
Refs guard / refs-not-closing (pull_request) Successful in 10s
CI / test (pull_request) Failing after 58s
2026-08-31 11:17:49 +00:00
codex-bot-andresmgsl
153a408e10 feat: decide when merge-door releases resume
Some checks failed
CI / self-guards (pull_request) Successful in 10s
CI / action-exercise (pull_request) Successful in 8s
CI / release-exercise (pull_request) Successful in 14s
CI / docs-sync-exercise (pull_request) Successful in 8s
Refs guard / refs-not-closing (pull_request) Successful in 8s
labels / labels (pull_request) Successful in 10s
CI / test (pull_request) Failing after 43s
2026-08-31 11:15:01 +00:00
codex-bot-andresmgsl
3104aac6f3 test: specify release preflight contract
Some checks failed
CI / release-exercise (pull_request) Successful in 13s
CI / self-guards (pull_request) Successful in 9s
CI / action-exercise (pull_request) Successful in 8s
CI / docs-sync-exercise (pull_request) Successful in 8s
labels / labels (pull_request) Successful in 10s
Refs guard / refs-not-closing (pull_request) Successful in 8s
CI / test (pull_request) Failing after 44s
2026-08-31 11:12:22 +00:00
58ab50361d Merge pull request 'fix: publish Forgejo releases atomically' (#272) from build/271-atomic-forgejo-release into main
All checks were successful
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 7s
CI / action-exercise (push) Successful in 6s
CI / docs-sync-exercise (push) Successful in 6s
release / release (push) Successful in 7s
CI / test (push) Successful in 3m42s
Reviewed-on: #272
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
2026-08-30 10:47:55 +00:00
codex-bot-andresmgsl
1aa03cad77 docs: describe all tag-door assertions
All checks were successful
CI / self-guards (pull_request) Successful in 10s
CI / release-exercise (pull_request) Successful in 12s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 9s
CI / test (pull_request) Successful in 3m53s
Refs guard / refs-not-closing (pull_request) Successful in 7s
2026-08-30 09:33:12 +00:00
codex-bot-andresmgsl
fe4ec57ff2 test: document deferred fixture expansion
All checks were successful
CI / action-exercise (pull_request) Successful in 6s
CI / self-guards (pull_request) Successful in 9s
CI / release-exercise (pull_request) Successful in 12s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
CI / test (pull_request) Successful in 3m53s
2026-08-30 09:31:16 +00:00
codex-bot-andresmgsl
20cba4583d docs: describe atomic release publication
Some checks failed
CI / self-guards (pull_request) Successful in 9s
CI / release-exercise (pull_request) Successful in 13s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
Refs guard / refs-not-closing (pull_request) Successful in 6s
CI / test (pull_request) Failing after 42s
2026-08-30 09:27:56 +00:00
codex-bot-andresmgsl
734676ab7d fix: guard tag release publication
Some checks failed
CI / test (pull_request) Failing after 41s
CI / release-exercise (pull_request) Successful in 13s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
Refs guard / refs-not-closing (pull_request) Successful in 6s
2026-08-30 09:27:08 +00:00
codex-bot-andresmgsl
c4315c2cfa fix: publish Forgejo releases atomically
All checks were successful
CI / self-guards (pull_request) Successful in 9s
CI / release-exercise (pull_request) Successful in 12s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
Refs guard / refs-not-closing (pull_request) Successful in 6s
CI / test (pull_request) Successful in 3m58s
2026-08-30 09:25:43 +00:00
codex-bot-andresmgsl
2ab9902c38 test: expose non-atomic Forgejo releases
Some checks failed
CI / action-exercise (pull_request) Successful in 7s
CI / self-guards (pull_request) Successful in 9s
CI / release-exercise (pull_request) Successful in 12s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
CI / test (pull_request) Failing after 3m57s
2026-08-30 09:23:48 +00:00
f5c02fee8f Merge pull request 'docs: correct upstream sync campaign record' (#270) from build/269-upstream-sync-record into main
All checks were successful
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 6s
CI / action-exercise (push) Successful in 4s
CI / docs-sync-exercise (push) Successful in 5s
release / release (push) Successful in 5s
CI / test (push) Successful in 3m36s
Reviewed-on: #270
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-08-27 12:45:00 +00:00
codex-bot-andresmgsl
7e96748193 docs: correct upstream sync campaign record
All checks were successful
CI / self-guards (pull_request) Successful in 8s
CI / action-exercise (pull_request) Successful in 6s
CI / release-exercise (pull_request) Successful in 11s
CI / docs-sync-exercise (pull_request) Successful in 4s
labels / labels (pull_request) Successful in 7s
CI / test (pull_request) Successful in 3m40s
Refs guard / refs-not-closing (pull_request) Successful in 5s
2026-08-27 10:19:53 +00:00
github-actions[bot]
bcbcd90047 chore: bump main to 0.6.4-dev — a dev install must not impersonate 0.6.3 2026-08-26 20:20:20 +00:00
8f0ef79620 Merge pull request 'release: forge 0.6.3' (#267) from release-0.6.3 into main
All checks were successful
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 5s
CI / action-exercise (push) Successful in 4s
CI / docs-sync-exercise (push) Successful in 4s
release / release (push) Successful in 10s
CI / test (push) Successful in 4m15s
Reviewed-on: #267
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-08-26 20:18:16 +00:00
claude-lead-andresmgsl
12a7fcb688 docs: record the 0.6.3 doors-unchanged drill ruling
All checks were successful
CI / release-exercise (pull_request) Successful in 13s
CI / self-guards (pull_request) Successful in 8s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 5s
labels / labels (pull_request) Successful in 7s
CI / test (pull_request) Successful in 4m9s
2026-08-26 13:40:37 +00:00
claude-lead-andresmgsl
03cb69deba release: stamp forge 0.6.3 refs and version 2026-08-26 13:40:12 +00:00
d439ff6c08 Merge pull request 'fix: align needs-triage label description' (#266) from build/265-needs-triage-description into main
All checks were successful
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 5s
CI / action-exercise (push) Successful in 3s
CI / docs-sync-exercise (push) Successful in 4s
release / release (push) Successful in 4s
CI / test (push) Successful in 3m46s
Reviewed-on: #266
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
2026-08-25 22:20:00 +00:00
codex-bot-andresmgsl
53b7856012 fix: align needs-triage label description
All checks were successful
CI / test (pull_request) Successful in 3m53s
CI / release-exercise (pull_request) Successful in 25s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
Refs guard / refs-not-closing (pull_request) Successful in 4s
2026-08-25 20:19:58 +00:00
codex-bot-andresmgsl
0ea0cf50af test: guard needs-triage label description
Some checks failed
CI / test (pull_request) Failing after 4m4s
CI / release-exercise (pull_request) Successful in 25s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
Refs guard / refs-not-closing (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
2026-08-25 20:18:20 +00:00
0533766a42 Merge pull request 'docs: consume stranded 0.6.2 changelog entry' (#264) from build/263-consume-stranded-changelog into main
All checks were successful
CI / test (push) Successful in 4m2s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 21s
CI / action-exercise (push) Successful in 20s
CI / docs-sync-exercise (push) Successful in 20s
release / release (push) Successful in 21s
Reviewed-on: #264
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-08-25 18:28:47 +00:00
codex-bot-andresmgsl
f221647fe3 docs: consume stranded 0.6.2 changelog entry
All checks were successful
CI / test (pull_request) Successful in 3m49s
CI / release-exercise (pull_request) Successful in 25s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
Refs guard / refs-not-closing (pull_request) Successful in 20s
2026-08-25 17:09:51 +00:00
aa167fd4ef Merge pull request 'docs: replace discussion intake with proposals' (#262) from build/247-proposal-intake into main
All checks were successful
CI / test (push) Successful in 4m11s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 21s
CI / action-exercise (push) Successful in 20s
CI / docs-sync-exercise (push) Successful in 20s
release / release (push) Successful in 21s
Reviewed-on: #262
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
2026-08-25 15:09:50 +00:00
codex-bot-andresmgsl
13add81d62 docs: clarify proposal intake paths
All checks were successful
CI / test (pull_request) Successful in 3m48s
CI / release-exercise (pull_request) Successful in 25s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
Refs guard / refs-not-closing (pull_request) Successful in 20s
2026-08-25 12:45:14 +00:00
codex-bot-andresmgsl
988d8a2cce docs: distinguish proposals from work issues
All checks were successful
CI / test (pull_request) Successful in 3m49s
CI / release-exercise (pull_request) Successful in 25s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
Refs guard / refs-not-closing (pull_request) Successful in 20s
2026-08-25 12:33:25 +00:00
codex-bot-andresmgsl
bb984de133 docs: add proposal intake
All checks were successful
CI / test (pull_request) Successful in 3m49s
CI / release-exercise (pull_request) Successful in 25s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
Refs guard / refs-not-closing (pull_request) Successful in 20s
2026-08-25 12:15:47 +00:00
484eb79623 Merge pull request 'fix: restore Forgejo workflow names' (#261) from build/243-forgejo-workflow-name into main
All checks were successful
CI / test (push) Successful in 3m50s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 21s
CI / action-exercise (push) Successful in 20s
CI / docs-sync-exercise (push) Successful in 20s
release / release (push) Successful in 20s
Reviewed-on: #261
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
2026-08-25 11:52:50 +00:00
f6f2ec7fff Merge pull request 'docs: make release-path script the sole source' (#260) from build/251-release-path-doc into main
All checks were successful
CI / test (push) Successful in 3m48s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 21s
CI / action-exercise (push) Successful in 20s
CI / docs-sync-exercise (push) Successful in 20s
release / release (push) Successful in 21s
Reviewed-on: #260
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
2026-08-25 08:57:40 +00:00
codex-bot-andresmgsl
54a9334363 test: complete Forgejo workflow-name matrix
All checks were successful
CI / test (pull_request) Successful in 3m48s
CI / release-exercise (pull_request) Successful in 26s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 19s
CI / docs-sync-exercise (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
Refs guard / refs-not-closing (pull_request) Successful in 20s
2026-08-25 08:53:52 +00:00
codex-bot-andresmgsl
4bdb80bf80 docs: record Forgejo workflow-name fix
All checks were successful
CI / test (pull_request) Successful in 3m55s
CI / release-exercise (pull_request) Successful in 25s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
Refs guard / refs-not-closing (pull_request) Successful in 20s
2026-08-25 08:43:15 +00:00
codex-bot-andresmgsl
21871de5ee fix: restore Forgejo workflow names
All checks were successful
CI / test (pull_request) Successful in 3m49s
CI / release-exercise (pull_request) Successful in 24s
CI / self-guards (pull_request) Successful in 20s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
Refs guard / refs-not-closing (pull_request) Successful in 20s
2026-08-25 08:42:53 +00:00
codex-bot-andresmgsl
9f54eb98bf test: expose missing Forgejo workflow names
Some checks failed
CI / test (pull_request) Failing after 3m48s
CI / release-exercise (pull_request) Successful in 25s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
Refs guard / refs-not-closing (pull_request) Successful in 20s
2026-08-25 08:41:27 +00:00
codex-bot-andresmgsl
1cd8a6996f test: reject forge path doctrine copies
All checks were successful
CI / test (pull_request) Successful in 3m47s
CI / release-exercise (pull_request) Successful in 25s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 19s
CI / docs-sync-exercise (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
Refs guard / refs-not-closing (pull_request) Successful in 20s
2026-08-25 07:33:25 +00:00
codex-bot-andresmgsl
a0ffc4bbd8 docs: make release path manifest authoritative
All checks were successful
CI / test (pull_request) Successful in 3m47s
CI / release-exercise (pull_request) Successful in 24s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
Refs guard / refs-not-closing (pull_request) Successful in 20s
2026-08-25 07:32:30 +00:00
codex-bot-andresmgsl
17b99183ca test: guard release path doctrine
Some checks failed
CI / test (pull_request) Failing after 3m49s
CI / release-exercise (pull_request) Successful in 26s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
Refs guard / refs-not-closing (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
2026-08-25 07:31:39 +00:00
6dc8bf6558 Merge pull request 'fix: keep fork-headed label runs green' (#256) from build/241-fork-labels into main
All checks were successful
CI / test (push) Successful in 3m48s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 21s
CI / action-exercise (push) Successful in 20s
CI / docs-sync-exercise (push) Successful in 20s
release / release (push) Successful in 20s
Reviewed-on: #256
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
2026-08-25 06:38:18 +00:00
codex-bot-andresmgsl
7fa202acb5 docs: preserve fork-label rationale
All checks were successful
CI / test (pull_request) Successful in 3m56s
CI / release-exercise (pull_request) Successful in 26s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
Refs guard / refs-not-closing (pull_request) Successful in 21s
2026-08-25 04:37:41 +00:00
codex-bot-andresmgsl
0790745645 fix: qualify fork label coverage
All checks were successful
CI / test (pull_request) Successful in 3m48s
CI / release-exercise (pull_request) Successful in 25s
CI / self-guards (pull_request) Successful in 20s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
Refs guard / refs-not-closing (pull_request) Successful in 20s
2026-08-25 03:56:57 +00:00
codex-bot-andresmgsl
7da89a46aa docs(labels): qualify the instant trigger surface
All checks were successful
CI / test (pull_request) Successful in 3m47s
CI / release-exercise (pull_request) Successful in 25s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
Refs guard / refs-not-closing (pull_request) Successful in 20s
2026-08-25 00:24:05 +00:00
codex-bot-andresmgsl
7690c15e1a docs(labels): qualify fork-head sweep guarantees
All checks were successful
CI / test (pull_request) Successful in 3m47s
CI / release-exercise (pull_request) Successful in 24s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 19s
CI / docs-sync-exercise (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
Refs guard / refs-not-closing (pull_request) Successful in 20s
2026-08-25 00:09:13 +00:00
codex-bot-andresmgsl
e639e67f09 docs(labels): split fork and same-repo wake latency
All checks were successful
CI / test (pull_request) Successful in 3m49s
CI / release-exercise (pull_request) Successful in 24s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
Refs guard / refs-not-closing (pull_request) Successful in 20s
2026-08-24 23:59:17 +00:00
codex-bot-andresmgsl
ffbc1afc3d fix(labels): defer fork-head writes to sweep
All checks were successful
CI / test (pull_request) Successful in 3m47s
CI / release-exercise (pull_request) Successful in 25s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
Refs guard / refs-not-closing (pull_request) Successful in 20s
2026-08-24 23:57:07 +00:00
codex-bot-andresmgsl
311ef304fc test(labels): require fork-safe write gating
Some checks failed
CI / test (pull_request) Failing after 3m47s
CI / release-exercise (pull_request) Successful in 25s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
Refs guard / refs-not-closing (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
2026-08-24 23:56:06 +00:00
e55e99663e Merge pull request 'fix: refuse release PRs that strand target fragments' (#255) from build/253-stranded-fragments into main
All checks were successful
CI / test (push) Successful in 3m47s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 21s
CI / action-exercise (push) Successful in 20s
CI / docs-sync-exercise (push) Successful in 20s
release / release (push) Successful in 21s
Reviewed-on: #255
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
2026-08-24 22:55:26 +00:00
codex-bot-andresmgsl
5823f3d7b7 docs: record stranded-fragment refusal
All checks were successful
CI / test (pull_request) Successful in 3m47s
CI / release-exercise (pull_request) Successful in 25s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
Refs guard / refs-not-closing (pull_request) Successful in 20s
2026-08-24 20:17:36 +00:00
codex-bot-andresmgsl
0f3d3b36eb fix: refuse target-head stranded fragments
All checks were successful
CI / test (pull_request) Successful in 3m47s
CI / release-exercise (pull_request) Successful in 25s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
Refs guard / refs-not-closing (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
2026-08-24 20:15:55 +00:00
codex-bot-andresmgsl
a2b9b30930 test: expose target-head stranded fragments
Some checks failed
CI / test (pull_request) Failing after 3m47s
CI / release-exercise (pull_request) Successful in 24s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
Refs guard / refs-not-closing (pull_request) Successful in 20s
2026-08-24 20:14:09 +00:00
a1bac15a8b Merge pull request 'fix: exhaust Forgejo timeline pagination' (#254) from build/240-exhaustive-timeline into main
All checks were successful
CI / test (push) Successful in 3m46s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 21s
CI / action-exercise (push) Successful in 20s
CI / docs-sync-exercise (push) Successful in 20s
release / release (push) Successful in 20s
Reviewed-on: #254
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
2026-08-24 19:58:11 +00:00
46458ba8cd Merge pull request 'fix: bind Refs parser to one token' (#252) from build/234-bind-refs-token into main
All checks were successful
CI / test (push) Successful in 3m43s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 21s
CI / action-exercise (push) Successful in 20s
CI / docs-sync-exercise (push) Successful in 20s
release / release (push) Successful in 21s
Reviewed-on: #252
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
2026-08-24 18:15:11 +00:00
codex-bot-andresmgsl
1164640a08 test: close exhaustive pagination review gaps
All checks were successful
CI / test (pull_request) Successful in 4m8s
CI / release-exercise (pull_request) Successful in 25s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
Refs guard / refs-not-closing (pull_request) Successful in 20s
2026-08-24 18:01:04 +00:00
codex-bot-andresmgsl
40ebcea462 fix: exhaust Forgejo timeline pagination
All checks were successful
CI / test (pull_request) Successful in 3m46s
CI / release-exercise (pull_request) Successful in 25s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
Refs guard / refs-not-closing (pull_request) Successful in 20s
2026-08-24 17:53:13 +00:00
codex-bot-andresmgsl
8c0f5d53d7 test: expose truncated Forgejo timelines
Some checks failed
CI / test (pull_request) Failing after 3m45s
CI / release-exercise (pull_request) Successful in 24s
CI / self-guards (pull_request) Successful in 20s
CI / action-exercise (pull_request) Successful in 20s
CI / docs-sync-exercise (pull_request) Successful in 20s
Refs guard / refs-not-closing (pull_request) Successful in 20s
labels / labels (pull_request) Successful in 22s
2026-08-24 17:50:50 +00:00
codex-bot-andresmgsl
4fb01e8b9f chore: merge main development bump (#234)
All checks were successful
CI / test (pull_request) Successful in 4m6s
CI / release-exercise (pull_request) Successful in 23s
CI / self-guards (pull_request) Successful in 19s
CI / action-exercise (pull_request) Successful in 19s
CI / docs-sync-exercise (pull_request) Successful in 19s
labels / labels (pull_request) Successful in 21s
Refs guard / refs-not-closing (pull_request) Successful in 20s
2026-08-24 17:04:02 +00:00
codex-bot-andresmgsl
d712f0636f fix: preserve Refs keyword boundaries (#234)
Some checks failed
CI / test (pull_request) Successful in 3m44s
CI / release-exercise (pull_request) Successful in 24s
CI / self-guards (pull_request) Failing after 19s
CI / action-exercise (pull_request) Successful in 19s
CI / docs-sync-exercise (pull_request) Successful in 19s
labels / labels (pull_request) Successful in 21s
Refs guard / refs-not-closing (pull_request) Successful in 19s
2026-08-24 16:13:19 +00:00
github-actions[bot]
ca7ce6e919 chore: bump main to 0.6.3-dev — a dev install must not impersonate 0.6.2 2026-08-24 16:10:57 +00:00
codex-bot-andresmgsl
5232027361 test: mark Refs fixture Markdown literal (#234)
Some checks failed
CI / test (pull_request) Successful in 3m42s
CI / release-exercise (pull_request) Successful in 24s
CI / self-guards (pull_request) Failing after 19s
CI / action-exercise (pull_request) Successful in 19s
CI / docs-sync-exercise (pull_request) Successful in 19s
Refs guard / refs-not-closing (pull_request) Successful in 19s
labels / labels (pull_request) Successful in 21s
2026-08-24 16:06:12 +00:00
codex-bot-andresmgsl
6b2b467b7c docs: record bounded Refs parsing (#234)
Some checks failed
CI / test (pull_request) Failing after 49s
CI / release-exercise (pull_request) Successful in 23s
CI / self-guards (pull_request) Failing after 19s
CI / action-exercise (pull_request) Successful in 19s
CI / docs-sync-exercise (pull_request) Successful in 19s
Refs guard / refs-not-closing (pull_request) Successful in 19s
labels / labels (pull_request) Successful in 21s
2026-08-24 16:04:33 +00:00
codex-bot-andresmgsl
b105939d95 fix: bind Refs declarations to one token (#234)
Some checks failed
CI / test (pull_request) Failing after 49s
CI / release-exercise (pull_request) Successful in 23s
CI / self-guards (pull_request) Failing after 19s
CI / action-exercise (pull_request) Successful in 19s
CI / docs-sync-exercise (pull_request) Successful in 19s
labels / labels (pull_request) Successful in 21s
Refs guard / refs-not-closing (pull_request) Successful in 19s
2026-08-24 16:04:02 +00:00
codex-bot-andresmgsl
b2048f63bd test: pin Refs token boundaries (#234)
Some checks failed
CI / test (pull_request) Failing after 48s
CI / release-exercise (pull_request) Successful in 24s
CI / self-guards (pull_request) Failing after 19s
CI / action-exercise (pull_request) Successful in 19s
CI / docs-sync-exercise (pull_request) Successful in 19s
Refs guard / refs-not-closing (pull_request) Successful in 19s
labels / labels (pull_request) Successful in 21s
2026-08-24 16:02:01 +00:00
5a8fce8375 Merge pull request 'release: forge 0.6.2' (#250) from build/231-release-0-6-2 into main
Some checks failed
release / release (push) Successful in 25s
CI / test (push) Successful in 3m45s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Failing after 20s
CI / action-exercise (push) Successful in 20s
CI / docs-sync-exercise (push) Successful in 20s
Reviewed-on: #250
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-08-24 15:55:13 +00:00
5be223a020 Merge pull request 'fix: read live review requests from each forge' (#249) from build/238-review-requests into main
Some checks are pending
CI / test (push) Waiting to run
CI / release-exercise (push) Waiting to run
CI / self-guards (push) Waiting to run
CI / action-exercise (push) Waiting to run
CI / docs-sync-exercise (push) Waiting to run
release / release (push) Waiting to run
Reviewed-on: #249
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
2026-08-24 15:54:51 +00:00
codex-bot-andresmgsl
f0f3907618 test: exercise GitHub review-request selector
All checks were successful
CI / test (pull_request) Successful in 3m43s
CI / release-exercise (pull_request) Successful in 23s
CI / self-guards (pull_request) Successful in 19s
CI / action-exercise (pull_request) Successful in 19s
CI / docs-sync-exercise (pull_request) Successful in 19s
labels / labels (pull_request) Successful in 21s
Refs guard / refs-not-closing (pull_request) Successful in 19s
2026-08-24 14:42:36 +00:00
codex-bot-andresmgsl
809b7e907a docs: record 0.6.2 doors unchanged
All checks were successful
CI / test (pull_request) Successful in 3m42s
CI / release-exercise (pull_request) Successful in 23s
CI / self-guards (pull_request) Successful in 20s
CI / action-exercise (pull_request) Successful in 19s
CI / docs-sync-exercise (pull_request) Successful in 19s
labels / labels (pull_request) Successful in 21s
Refs guard / refs-not-closing (pull_request) Successful in 19s
2026-08-24 14:32:29 +00:00
codex-bot-andresmgsl
fdb7d7577b docs: record 0.6.2 upstream port provenance
Some checks failed
CI / test (pull_request) Successful in 3m41s
CI / release-exercise (pull_request) Successful in 24s
CI / self-guards (pull_request) Failing after 20s
CI / action-exercise (pull_request) Successful in 19s
CI / docs-sync-exercise (pull_request) Successful in 19s
labels / labels (pull_request) Successful in 21s
Refs guard / refs-not-closing (pull_request) Successful in 19s
2026-08-24 13:23:54 +00:00
codex-bot-andresmgsl
aa818d5a93 release: assemble 0.6.2 changelog
Some checks failed
CI / test (pull_request) Successful in 3m41s
CI / release-exercise (pull_request) Successful in 23s
CI / self-guards (pull_request) Failing after 21s
CI / action-exercise (pull_request) Successful in 19s
CI / docs-sync-exercise (pull_request) Successful in 19s
labels / labels (pull_request) Successful in 21s
Refs guard / refs-not-closing (pull_request) Successful in 19s
2026-08-24 13:22:48 +00:00
codex-bot-andresmgsl
05f182fe29 release: stamp forge 0.6.2 refs and version
Some checks failed
CI / test (pull_request) Successful in 3m43s
CI / release-exercise (pull_request) Successful in 23s
CI / self-guards (pull_request) Failing after 19s
CI / action-exercise (pull_request) Successful in 19s
CI / docs-sync-exercise (pull_request) Successful in 19s
labels / labels (pull_request) Successful in 21s
Refs guard / refs-not-closing (pull_request) Successful in 19s
2026-08-24 13:21:40 +00:00
codex-bot-andresmgsl
22865aba54 test: preserve superseded request question names
All checks were successful
CI / test (pull_request) Successful in 3m42s
CI / release-exercise (pull_request) Successful in 23s
CI / self-guards (pull_request) Successful in 19s
CI / action-exercise (pull_request) Successful in 18s
CI / docs-sync-exercise (pull_request) Successful in 19s
labels / labels (pull_request) Successful in 21s
Refs guard / refs-not-closing (pull_request) Successful in 19s
2026-08-24 12:46:42 +00:00
codex-bot-andresmgsl
f05e83a562 docs: record live review request fix
All checks were successful
CI / test (pull_request) Successful in 3m43s
CI / release-exercise (pull_request) Successful in 23s
CI / self-guards (pull_request) Successful in 20s
CI / action-exercise (pull_request) Successful in 18s
CI / docs-sync-exercise (pull_request) Successful in 19s
labels / labels (pull_request) Successful in 21s
Refs guard / refs-not-closing (pull_request) Successful in 19s
2026-08-24 12:43:33 +00:00
codex-bot-andresmgsl
6f5ce8f994 fix: drive round state from live review requests
All checks were successful
CI / test (pull_request) Successful in 4m8s
CI / release-exercise (pull_request) Successful in 24s
CI / self-guards (pull_request) Successful in 19s
CI / action-exercise (pull_request) Successful in 19s
CI / docs-sync-exercise (pull_request) Successful in 19s
labels / labels (pull_request) Successful in 21s
Refs guard / refs-not-closing (pull_request) Successful in 19s
2026-08-24 12:42:11 +00:00
codex-bot-andresmgsl
0160f6a883 fix: read live review requests from each forge
All checks were successful
CI / test (pull_request) Successful in 3m44s
CI / release-exercise (pull_request) Successful in 23s
CI / self-guards (pull_request) Successful in 20s
CI / action-exercise (pull_request) Successful in 19s
CI / docs-sync-exercise (pull_request) Successful in 19s
labels / labels (pull_request) Successful in 21s
Refs guard / refs-not-closing (pull_request) Successful in 19s
2026-08-24 12:38:03 +00:00
7bdae45c98 Merge pull request 'docs: prepare 0.6.2 upstream release notes' (#248) from build/246-upstream-release-fragment into main
All checks were successful
CI / test (push) Successful in 3m42s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 19s
CI / action-exercise (push) Successful in 19s
CI / docs-sync-exercise (push) Successful in 19s
release / release (push) Successful in 20s
Reviewed-on: #248
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
2026-08-24 12:06:54 +00:00
codex-bot-andresmgsl
d0f5e40fa1 docs: record upstream 0.6.x release notes
All checks were successful
CI / test (pull_request) Successful in 3m42s
CI / release-exercise (pull_request) Successful in 24s
CI / self-guards (pull_request) Successful in 21s
CI / action-exercise (pull_request) Successful in 19s
CI / docs-sync-exercise (pull_request) Successful in 19s
labels / labels (pull_request) Successful in 20s
Refs guard / refs-not-closing (pull_request) Successful in 19s
2026-08-24 10:54:49 +00:00
68b304d713 Merge pull request 'fix(labels): grade Forgejo review states' (#244) from codex-bot-andresmgsl/ceremony:build/235-forgejo-review-vocabulary into main
All checks were successful
CI / test (push) Successful in 3m43s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 19s
CI / action-exercise (push) Successful in 18s
CI / docs-sync-exercise (push) Successful in 18s
release / release (push) Successful in 19s
Reviewed-on: #244
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
2026-08-24 00:16:46 +00:00
codex-bot-andresmgsl
1cd46028ed test(labels): guard Forgejo comment ingestion
Some checks failed
labels / labels (pull_request) Failing after 20s
CI / test (pull_request) Successful in 3m41s
CI / release-exercise (pull_request) Successful in 25s
CI / self-guards (pull_request) Successful in 20s
CI / action-exercise (pull_request) Successful in 18s
CI / docs-sync-exercise (pull_request) Successful in 19s
Refs guard / refs-not-closing (pull_request) Has been cancelled
2026-08-23 23:29:09 +00:00
codex-bot-andresmgsl
c2cca7c1be test(labels): mark indirect filter probe call
Some checks failed
labels / labels (pull_request) Failing after 21s
Refs guard / refs-not-closing (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
2026-08-23 23:17:14 +00:00
codex-bot-andresmgsl
58e58f2ada fix(labels): grade Forgejo review states
Some checks failed
labels / labels (pull_request) Failing after 21s
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
Refs guard / refs-not-closing (pull_request) Has been cancelled
2026-08-23 23:13:05 +00:00
codex-bot-andresmgsl
e5ebbf57fb test(labels): reproduce Forgejo review vocabulary gaps
Some checks failed
labels / labels (pull_request) Failing after 21s
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
Refs guard / refs-not-closing (pull_request) Has been cancelled
2026-08-23 23:11:11 +00:00
17a13685a7 Merge pull request 'fix: distinguish Forgejo mergeability states' (#242) from codex-bot-andresmgsl/ceremony:build/236-forgejo-mergeable into main
All checks were successful
CI / test (push) Successful in 3m50s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 19s
CI / action-exercise (push) Successful in 18s
CI / docs-sync-exercise (push) Successful in 18s
release / release (push) Successful in 19s
Reviewed-on: #242
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
2026-08-23 22:52:09 +00:00
codex-bot-andresmgsl
8f9f7e560f docs: record Forgejo mergeability fix
Some checks failed
CI / test (pull_request) Successful in 3m38s
CI / release-exercise (pull_request) Successful in 24s
CI / self-guards (pull_request) Successful in 20s
CI / action-exercise (pull_request) Successful in 19s
CI / docs-sync-exercise (pull_request) Successful in 19s
labels / labels (pull_request) Failing after 21s
Refs guard / refs-not-closing (pull_request) Has been cancelled
2026-08-23 17:41:41 +00:00
codex-bot-andresmgsl
d3b7984a30 fix: distinguish Forgejo mergeability states
Some checks failed
labels / labels (pull_request) Failing after 20s
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
Refs guard / refs-not-closing (pull_request) Has been cancelled
2026-08-23 17:41:10 +00:00
codex-bot-andresmgsl
2029c9f520 test: pin Forgejo mergeability distinctions
Some checks failed
labels / labels (pull_request) Failing after 21s
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
Refs guard / refs-not-closing (pull_request) Has been cancelled
2026-08-23 17:40:06 +00:00
1f5dd39a98 Merge pull request 'feat: record release window membership separately' (#239) from codex-bot-andresmgsl/ceremony:build/230-membership-record into main
All checks were successful
CI / test (push) Successful in 3m36s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 20s
CI / action-exercise (push) Successful in 18s
CI / docs-sync-exercise (push) Successful in 18s
release / release (push) Successful in 19s
Reviewed-on: #239
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
2026-08-23 16:58:12 +00:00
codex-bot-andresmgsl
ad23842fe2 docs: define release membership records
Some checks failed
CI / test (pull_request) Successful in 3m37s
CI / release-exercise (pull_request) Successful in 23s
CI / self-guards (pull_request) Successful in 19s
CI / action-exercise (pull_request) Successful in 18s
CI / docs-sync-exercise (pull_request) Successful in 18s
labels / labels (pull_request) Failing after 21s
Refs guard / refs-not-closing (pull_request) Has been cancelled
2026-08-23 01:08:35 +00:00
codex-bot-andresmgsl
9a37db4b18 feat: derive release windows from membership records
Some checks failed
labels / labels (pull_request) Failing after 20s
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
Refs guard / refs-not-closing (pull_request) Has been cancelled
2026-08-23 01:06:54 +00:00
codex-bot-andresmgsl
b7a2b31f84 feat: parse release membership records
Some checks failed
labels / labels (pull_request) Failing after 20s
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
Refs guard / refs-not-closing (pull_request) Has been cancelled
2026-08-23 01:02:53 +00:00
codex-bot-andresmgsl
d493c993b7 test: drive release membership records
Some checks failed
labels / labels (pull_request) Failing after 20s
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
Refs guard / refs-not-closing (pull_request) Has been cancelled
2026-08-23 01:01:17 +00:00
f69224cddc Merge pull request 'test: update roster drift fixture' (#237) from codex-bot-andresmgsl/ceremony:build/232-roster-fixture into main
All checks were successful
CI / test (push) Successful in 3m31s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 20s
CI / action-exercise (push) Successful in 18s
CI / docs-sync-exercise (push) Successful in 18s
release / release (push) Successful in 19s
Reviewed-on: #237
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
2026-08-23 00:52:04 +00:00
codex-bot-andresmgsl
832a41b647 test: update roster drift fixture
Some checks failed
labels / labels (pull_request) Failing after 21s
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
Refs guard / refs-not-closing (pull_request) Has been cancelled
2026-08-22 22:22:53 +00:00
4f887a756f Merge pull request 'docs: adopt upstream 0.6.1 and 0.6.2 doctrine' (#233) from codex-bot-andresmgsl/ceremony:build/229-upstream-doctrine into main
Some checks failed
CI / test (push) Failing after 3m31s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 19s
CI / action-exercise (push) Successful in 18s
CI / docs-sync-exercise (push) Successful in 18s
release / release (push) Successful in 19s
Reviewed-on: #233
Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders>
2026-08-22 22:16:23 +00:00
claude-lead-andresmgsl
27f702a018 fix: roster names the renamed triage identity
Some checks failed
CI / test (push) Failing after 3m47s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 17s
CI / action-exercise (push) Successful in 16s
CI / docs-sync-exercise (push) Successful in 16s
release / release (push) Successful in 17s
Second leg of the same debt #232 records. The operator renamed
cluade-bot-andresmgsl to claude-bot-andresmgsl on 2026-08-20, closing the
misspelling; the account kept its id so history followed it, but the
roster compares logins as strings and went on naming the old form.

Both functional carriers move together, as #232's spec requires: the
`panel=`/`triage-actors=` lines and the CONTRIBUTING identity table. The
conf/prose sync guard is why they cannot move apart.

Verified: `test/labels.test.sh` is 43/44 before and after — unchanged.
The one failure is #232 spec item 4 (the `glm-reviewer-andresmgsl`
fixture at :249, now a no-op mutation), which is untouched here and
remains that issue's only outstanding work.

Historical attributions elsewhere stay as written, per triage's
2026-08-17 ruling recorded on #232.

Refs #232
2026-08-20 23:23:30 +00:00
codex-bot-andresmgsl
9f07c91faf docs: group the 229 changelog fragment
Some checks failed
labels / labels (pull_request) Failing after 18s
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
Refs guard / refs-not-closing (pull_request) Has been cancelled
2026-08-20 01:36:32 +00:00
codex-bot-andresmgsl
78532e0777 docs: define post-merge release edge handling
Some checks failed
labels / labels (pull_request) Failing after 17s
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
Refs guard / refs-not-closing (pull_request) Has been cancelled
2026-08-19 03:51:49 +00:00
codex-bot-andresmgsl
df4782ced9 docs: clarify builder waits and handoff ordering
Some checks failed
labels / labels (pull_request) Failing after 17s
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
Refs guard / refs-not-closing (pull_request) Has been cancelled
2026-08-19 03:51:20 +00:00
codex-bot-andresmgsl
d12cc3d84f docs: route vendored doctrine through the manifest
Some checks failed
labels / labels (pull_request) Failing after 17s
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
Refs guard / refs-not-closing (pull_request) Has been cancelled
2026-08-19 03:50:10 +00:00
cluade-bot-andresmgsl
c2ef6a2fc2 fix: roster names the renamed -bot identities
Some checks failed
CI / test (push) Failing after 3m38s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 15s
CI / action-exercise (push) Successful in 14s
CI / docs-sync-exercise (push) Successful in 14s
release / release (push) Successful in 14s
Emergency operator-authorized hotfix, sibling of crew 36c6745: engine and
sweep panel requests fail on the pre-rename -reviewer names. ceremony#232
records the debt and verifies. Authorized by @andres in-session.
2026-08-17 22:50:13 +00:00
dbe58517dc Merge pull request 'docs: record delivered 0.6.1 runner exercise' (#227) from issue-217-runner-consumer into main
All checks were successful
CI / test (push) Successful in 3m18s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 10s
CI / action-exercise (push) Successful in 9s
CI / docs-sync-exercise (push) Successful in 9s
release / release (push) Successful in 10s
Reviewed-on: #227
Reviewed-by: cluade-reviewer-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: glm-reviewer-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
2026-08-09 21:09:36 +00:00
Codex Review
2aafc04018 docs: record delivered 0.6.1 runner exercise
All checks were successful
CI / test (pull_request) Successful in 3m19s
CI / release-exercise (pull_request) Successful in 13s
CI / self-guards (pull_request) Successful in 9s
CI / action-exercise (pull_request) Successful in 9s
CI / docs-sync-exercise (pull_request) Successful in 9s
Refs guard / refs-not-closing (pull_request) Successful in 9s
labels / labels (pull_request) Successful in 11s
2026-08-09 20:35:49 +00:00
github-actions[bot]
5693bee9f3 chore: bump main to 0.6.2-dev — a dev install must not impersonate 0.6.1 2026-08-09 19:47:02 +00:00
338cf5f754 Merge pull request 'release 0.6.1' (#226) from release-0.6.1 into main
All checks were successful
CI / test (push) Successful in 3m18s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 10s
CI / action-exercise (push) Successful in 9s
CI / docs-sync-exercise (push) Successful in 9s
release / release (push) Successful in 14s
Reviewed-on: #226
Reviewed-by: cluade-reviewer-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-reviewer-andresmgsl <andres+5@heavyduty.builders>
2026-08-09 19:43:10 +00:00
Codex Review
b104eca141 docs: record 0.6.1 Forgejo rehearsal
All checks were successful
CI / test (pull_request) Successful in 3m19s
CI / release-exercise (pull_request) Successful in 13s
CI / self-guards (pull_request) Successful in 9s
CI / action-exercise (pull_request) Successful in 8s
CI / docs-sync-exercise (pull_request) Successful in 8s
labels / labels (pull_request) Successful in 10s
Refs guard / refs-not-closing (pull_request) Successful in 8s
2026-08-09 18:20:49 +00:00
Codex Review
ba3b17af88 release: stage 0.6.1 ceremony
Some checks failed
CI / test (pull_request) Successful in 3m18s
CI / release-exercise (pull_request) Successful in 13s
CI / self-guards (pull_request) Failing after 9s
CI / action-exercise (pull_request) Successful in 8s
CI / docs-sync-exercise (pull_request) Successful in 8s
Refs guard / refs-not-closing (pull_request) Successful in 8s
labels / labels (pull_request) Successful in 10s
2026-08-09 16:12:18 +00:00
0371f2cfd6 Merge pull request 'chore: restore kimi to the review panel (#224)' (#225) from issue-224-kimi-panel into main
All checks were successful
CI / test (push) Successful in 3m18s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 9s
CI / action-exercise (push) Successful in 8s
CI / docs-sync-exercise (push) Successful in 8s
release / release (push) Successful in 9s
Reviewed-on: #225
Reviewed-by: cluade-reviewer-andresmgsl <andres+1@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-reviewer-andresmgsl <andres+5@heavyduty.builders>
2026-08-09 15:38:11 +00:00
Codex Review
5487f71ef8 chore: restore kimi to review panel (#224)
All checks were successful
CI / test (pull_request) Successful in 3m18s
CI / release-exercise (pull_request) Successful in 14s
CI / self-guards (pull_request) Successful in 10s
CI / action-exercise (pull_request) Successful in 8s
CI / docs-sync-exercise (pull_request) Successful in 8s
labels / labels (pull_request) Successful in 10s
Refs guard / refs-not-closing (pull_request) Successful in 8s
2026-08-09 14:57:09 +00:00
a953884826 Merge pull request '.github/labels.conf + CONTRIBUTING + labels.test — the panel names an identity that can actually review (#222)' (#223) from build/222-panel-glm into main
All checks were successful
CI / test (push) Successful in 3m15s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 7s
CI / action-exercise (push) Successful in 6s
CI / docs-sync-exercise (push) Successful in 6s
release / release (push) Successful in 7s
Reviewed-on: #223
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-reviewer-andresmgsl <andres+5@heavyduty.builders>
2026-08-06 18:32:33 +00:00
b7a6aedc52 Merge pull request 'changelog.d/220.md — the 0.4.1 → 0.6.1 gap statement (#220)' (#221) from build/220-gap-fragment into main
All checks were successful
CI / test (push) Successful in 3m15s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 7s
CI / action-exercise (push) Successful in 6s
CI / docs-sync-exercise (push) Successful in 6s
release / release (push) Successful in 7s
Reviewed-on: #221
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-reviewer-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
2026-08-06 16:53:16 +00:00
b63637b478 Merge pull request 'labels-sweep — pass bootstrap through the workflow_call boundary it was lost at (#215)' (#218) from build/215-bootstrap-bridge into main
Some checks failed
release / release (push) Waiting to run
CI / test (push) Has been cancelled
CI / release-exercise (push) Has been cancelled
CI / self-guards (push) Has been cancelled
CI / action-exercise (push) Has been cancelled
CI / docs-sync-exercise (push) Has been cancelled
Reviewed-on: #218
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: glm-reviewer-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
2026-08-06 16:53:04 +00:00
2b49d6ef59 fix(labels): the panel names an identity that can actually review (#222)
All checks were successful
CI / test (pull_request) Successful in 3m15s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
kimi-reviewer-andresmgsl is temporarily unavailable, and with a three-identity
panel it sits in the required set for every possible author — so no new PR
could converge while it is down. glm-reviewer-andresmgsl takes the seat.

The roster guard from #195 is bidirectional, so three files move together:
the conf, CONTRIBUTING's table, and the test's table-side mutation, which
must name an identity the table carries or it stops testing anything.

Refs #222
2026-08-06 11:33:42 +00:00
clad2
286403da78 docs(changelog): drop the entry that pre-decided #219 spec 6
All checks were successful
CI / test (pull_request) Successful in 3m15s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 5s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 7s
Refs guard / refs-not-closing (pull_request) Successful in 6s
'By decision rather than omission' published a back-tagging ruling that
belongs to the operator and has not been made. The fragment's authorized
scope is the measured gap, which the first two entries state
(@codex-reviewer-andresmgsl, !221 review).

Refs #220
2026-08-05 23:15:24 +00:00
clad2
b7775c68ea docs(changelog): the 0.4.1 -> 0.6.1 gap statement, as a grouped fragment
All checks were successful
CI / test (pull_request) Successful in 3m15s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 7s
Three Changed entries naming what a reader comparing the two forges' tags
would otherwise reconstruct: 0.5.0/0.6.0 arrived by merge and were never
released here; the carried ## 0.6.0 section is upstream's record; and the
absent 0.6.0 tag is a decision, not an omission. Verified: eleven fragments
assemble cleanly with these entries present, shape/cite guards green.

Refs #220
2026-08-05 23:10:25 +00:00
clad2
960e581f91 test(labels): remove the remaining dead event-name assignments
All checks were successful
CI / test (pull_request) Successful in 3m15s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
All three schedule-simulation probes carried the assignment the script no
longer reads. This time the head was verified clean BEFORE the push, not
after. Refs #215
2026-08-05 21:39:45 +00:00
clad2
7d387dd936 test(labels): drop the now-dead event-name assignment SC2034 flagged
Some checks failed
CI / test (pull_request) Failing after 34s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
The script stopped reading GITHUB_EVENT_NAME with the input gate; the
previous commit pushed with this red because I trusted an && echo that
printed nothing. Refs #215
2026-08-05 21:35:55 +00:00
clad2
6986deede7 fix(labels): the bootstrap keys on the BOOTSTRAP input, never the event name
Some checks failed
CI / test (pull_request) Failing after 33s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 7s
labels / labels (pull_request) Successful in 8s
The venue drill caught what no hermetic test had: with the workflow_call
bridge delivering "no" perfectly, drill runs 16/17 still bootstrapped. The
script gated on GITHUB_EVENT_NAME = workflow_dispatch and never read
$BOOTSTRAP at all; the wrapper's only coupling was exporting the event name
for yes. Correct while an operator's manual dispatch was the only dispatch
there was — inert from #209 on, when the trigger job made every machine wake
a workflow_dispatch event. Runs 459/523 bootstrapped for this reason, not
for the input-delivery defect, which is real but was never the operative
cause of the observed re-upserts.

The script now gates on ${BOOTSTRAP:-no} = yes; the wrapper passes the input
through untouched; the hermetic suite pins the exact regression pair (a
dispatch event with no/unset creates and deletes nothing) alongside the
yes path's full create+delete assertions.

Refs #215
2026-08-05 21:32:21 +00:00
clad2
ceaf66bd13 docs(labels): claim the measured invariant, not an unmeasured Forgejo defect
All checks were successful
CI / test (pull_request) Successful in 3m15s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
The evidence shows the called workflow did not see the caller's event inputs
on THIS instance while the top level received them (runs 459/523 vs probe
6/7); it does not show what GitHub or a declared-and-passed input does, so
every prose site now states declare-and-pass as the measured-reliable channel
rather than attributing a drop to Forgejo (@codex-reviewer-andresmgsl, !218
blocker 2).

Refs #215
2026-08-05 21:18:29 +00:00
clad2
69d674cb67 docs(changelog): split the test entry under the 300-character bound
All checks were successful
CI / test (pull_request) Successful in 3m16s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
Pushed the previous commit with this red — the cite check names the bound and
I read only the first failure it printed. Refs #215
2026-08-05 21:08:51 +00:00
clad2
baa683211e fix(labels): pass bootstrap through the workflow_call boundary it was lost at
A called workflow cannot read the caller's dispatch inputs on this forge:
github.event.inputs is empty inside workflow_call even though the top-level
caller receives the value in both contexts (probe runs 6/7). The sweep's gate
read exactly that, so every dispatch-woken sweep bootstrapped — runs 459 and
523, ~20 label upserts per board event — while the trigger honestly logged
bootstrap=no.

The bridge, per the #6361 contract: labels-sweep.yml declares
workflow_call.inputs.bootstrap (string, default "no"); the dogfood caller and
the published CONSUMERS.md stub pass it via with.bootstrap with empty mapped
to "no" at the caller — kimi's edge: on schedule the top-level context is
empty, and an empty that slipped through would have turned every cron into a
bootstrap. The gate feeds the declared input to labels-reconcile unchanged,
so an invalid value meets the action's own yes|no refusal.

test/labels-bootstrap.test.sh pins every hop: the declared boundary, both
gates as the identity, no expression reading github.event.inputs (scoped to
${{ }} bodies — the file's prose names the context to explain it), the two
pass-throughs byte-exact, and the four value paths driven through the shipped
expressions into the action's real validator. Mutations: dropping the
declaration reds 4, dropping the pass-through reds 3, restoring the old gate
reds 2.

Refs #215
2026-08-05 21:06:29 +00:00
b9a940ae2a Merge pull request 'docs/RUNNER-PROBES.md — record the venue's first delivered drills (#202)' (#216) from build/202-first-drills into main
All checks were successful
CI / test (push) Successful in 3m15s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 7s
CI / action-exercise (push) Successful in 6s
CI / docs-sync-exercise (push) Successful in 6s
release / release (push) Successful in 7s
Reviewed-on: #216
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
2026-08-05 20:52:25 +00:00
clad2
bae6f21b15 docs(runner-probes): the workflow-token record is comment 6262, not 6263
All checks were successful
CI / test (pull_request) Successful in 3m14s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
Off-by-one in the evidence anchor; a record that links the wrong comment is
a record that does not resolve (@codex-reviewer-andresmgsl, !216 round 2).

Refs #202
2026-08-05 19:53:16 +00:00
clad2
07a32c4fa8 docs(runner-probes): restore !207's release notes; per-run links; honest security lesson
All checks were successful
CI / test (pull_request) Successful in 3m15s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
Four corrections from @codex-reviewer-andresmgsl on 9c17a9e:
- changelog.d/202.md keeps !207's merged Added section (my cat > had deleted
  64 lines of unreleased release notes) with the drills appended under Changed;
- run 1 and run 4 link their own probe issues — run 4 is the clean repeat
  after the redaction incident and deserves its own citation;
- the #205 record links the evidence per identity and drops the pseudo-JSON,
  claiming only what the cited runs measured;
- the security lesson states the real invariant: report content must never
  contain a credential expression OR value — variables are not laundering.

Refs #202
2026-08-05 19:48:04 +00:00
clad2
9c17a9e4d8 docs(runner-probes): record the venue's first delivered drills
All checks were successful
CI / test (pull_request) Successful in 3m15s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
The owed-probes list keeps delivered probes with their probe-issue URLs: a
claim like 'the asymmetry reproduces' should carry a link a reader can open.
Also corrects the #205 line's premise (the 500 was a bad-ref/unknown-workflow
diagnostic, not a broken route) and adds the two venue lessons the first
drills taught.

Refs #202
2026-08-05 19:40:56 +00:00
c5e987eb89 Merge pull request '.github/workflows/labels.yml — wake the sweep over REST, so a board event reconciles in seconds (#205)' (#213) from build/205-dispatch-rest into main
All checks were successful
CI / test (push) Successful in 3m15s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 7s
CI / action-exercise (push) Successful in 6s
CI / docs-sync-exercise (push) Successful in 6s
release / release (push) Successful in 7s
Reviewed-on: #213
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
2026-08-05 18:08:32 +00:00
clad2
ed5ce8953a Merge remote-tracking branch 'origin/main' into build/205-dispatch-rest
All checks were successful
CI / test (pull_request) Successful in 3m15s
CI / release-exercise (pull_request) Successful in 10s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
2026-08-05 17:55:07 +00:00
9daeeb756e Merge pull request 'actions/refs-not-closing — gather over REST, so the guard produces verdicts on this forge (#199)' (#214) from build/199-refs-not-closing-rest into main
All checks were successful
CI / test (push) Successful in 3m14s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 6s
CI / action-exercise (push) Successful in 5s
CI / docs-sync-exercise (push) Successful in 6s
release / release (push) Successful in 7s
Reviewed-on: #214
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
2026-08-05 17:53:27 +00:00
clad2
3bde48f24c test(labels): unset GITHUB_API_URL in the no-api case — env preserves it
All checks were successful
CI / test (pull_request) Successful in 3m13s
CI / release-exercise (pull_request) Successful in 10s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
Plain `env` preserves the parent environment, and on the runner every step
arrives with GITHUB_API_URL set — the premise of the fix under test — so the
unset-refusal case inherited it and never exercised the refusal. It passed
only in a dev shell that lacks the variable: the environment distance
UPSTREAM-SYNC.md step 7 warns about, in the test written the same day
(@kimi-reviewer-andresmgsl, run 468).

Refs #205
2026-08-05 17:35:28 +00:00
clad2
0c2db9d85a test(labels): remove the stale never-silenced check, superseded behaviourally
Some checks failed
CI / test (pull_request) Failing after 3m14s
CI / release-exercise (pull_request) Successful in 10s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
The check grepped the gh workflow run line #205 removes, so it passed on
every REST implementation including one that swallows a failed POST — a
green assertion whose name claimed an invariant its implementation could not
observe. Its replacement lives in test/labels-dispatch.test.sh: a transport
failure must fail the extracted step, plus a code-aware no-|| true guard
(@codex-reviewer-andresmgsl, !213 review round 2).

Refs #205
2026-08-05 17:27:17 +00:00
clad2
396744618f Merge remote-tracking branch 'origin/main' into build/199-refs-not-closing-rest
All checks were successful
CI / test (pull_request) Successful in 3m13s
CI / release-exercise (pull_request) Successful in 10s
CI / self-guards (pull_request) Successful in 8s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
2026-08-05 17:19:11 +00:00
clad2
768d54d1db Merge remote-tracking branch 'origin/main' into build/205-dispatch-rest
Some checks failed
CI / test (pull_request) Failing after 3m13s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
2026-08-05 17:19:10 +00:00
94d5b81964 Merge pull request 'drills/README.md — the standing runner-probe venue, and why the drill disposal rule does not apply to it (#202)' (#207) from build/202-runner-probe-venue into main
All checks were successful
CI / test (push) Successful in 3m12s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 7s
CI / action-exercise (push) Successful in 6s
CI / docs-sync-exercise (push) Successful in 6s
release / release (push) Successful in 6s
Reviewed-on: #207
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: glm-reviewer-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
2026-08-05 17:18:58 +00:00
clad2
37e31ffd85 fix(labels): refuse an unset API root, name transport failures, update the docs
Some checks failed
CI / test (pull_request) Failing after 3m13s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
Three corrections from @codex-reviewer-andresmgsl's review of 935a813.

1. `api="${GITHUB_API_URL:-https://api.github.com}"` guessed GitHub when the
   variable was absent — driven with a recording curl, it reported success
   after POSTing to api.github.com from this forge. That is the "Never
   'probably github'" rule, and the same unset-environment refusal #201 just
   established for docs-sync. It now refuses before any request, and the test
   asserts zero calls were made: refusing after a POST is not refusing.

2. The "never silenced with || true" invariant was still asserted by grepping
   the gh line this port removed, so it passed on any REST implementation
   including one that swallows a failed POST. It is rebound behaviourally: a
   curl that dies at the transport must fail the step. Doing that revealed the
   step failed with a bare exit 7 and no sentence, so it now names the failure
   — owning the diagnostic is the whole point of the surrounding code.

3. docs/CONSUMERS.md and both caller comments still described `gh workflow
   run` as the mechanism. They describe the REST dispatch now, and the manual
   bootstrap command carries a forge-neutral curl form beside the gh one: a
   cross-forge runbook that sends this forge to a missing binary is wrong even
   where the prose around it is right.

Refs #205
2026-08-05 17:16:30 +00:00
clad2
4e28d437d6 fix(refs-not-closing): gather over REST, so the guard produces verdicts here
All checks were successful
CI / test (pull_request) Successful in 3m15s
CI / release-exercise (pull_request) Successful in 12s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 5s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
The action's entire gather was one GraphQL query asking GitHub for its own
parse of the closing keywords. Forgejo serves no GraphQL at all — /api/graphql
404s here and a forgejo-runner job arrives with GITHUB_GRAPHQL_URL empty — so
there was nothing to translate it to. It is re-expressed, as #188 re-expressed
its own two GraphQL sites, over two reads both backends serve plus this repo's
own parser.

The graph was called authoritative for including "closing keywords and sidebar
links". Those halves resolve differently here: Forgejo has no sidebar-link
concept, so nothing is lost there, but it DOES honour closing keywords in
commit messages. A body-only port would miss a PR that closes an issue from a
commit subject — exactly the contradiction this action exists to catch — so
the closing set unions the body and every commit message.

The hasNextPage refusal is relocated, not dropped: --paginate carries the
forgejo backend's x-total-count completeness proof, and a short gather refuses
rather than returning a partial verdict.

lib/issue_references.sh extracts the LOCAL/CROSS classifier from
issueflow-reconcile's executable. closes_references.sh's header recorded that
dependency in prose; a composite action cannot source a reconciler to borrow
one function, because sourcing a reconciler runs one.

refs-guard.yml's github-only gate is removed in the same change. A portable
action behind that gate is a guard that passes by never running.

The contract test drives the boundary on BOTH backends with stubs at the
transport. Mutations: body-only parse reds 4 cases, dropping --paginate reds
the partial-gather case, ignoring a failed read reds 9.

Refs #199
2026-08-05 17:11:19 +00:00
clad2
935a813d75 fix(labels): wake the sweep over REST, so board events reconcile in seconds
All checks were successful
CI / test (pull_request) Successful in 3m13s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
`.github/workflows/labels.yml` dispatched the sweep with `gh workflow run`,
the eighth runtime gh call site the 0.6.0 merge reintroduced and the only one
!204 did not port. On this forge the runner carries neither gh nor a GitHub
API, so the step refused and the entire event-driven reconcile path ended
there — every transition waiting up to an hour for the scheduled sweep.

The workflow-dispatch endpoint has the SAME shape on both forges:

  POST {api}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches
  {"ref": "<branch>", "inputs": {...}}  -> 204, empty body

so the step no longer decides a forge at all. The CEREMONY_FORGE_CLIENT=gh
declaration and both inline refusals are removed rather than ported, and
test/no-runtime-gh.test.sh now asserts their ABSENCE — an opt-out with no gh
behind it is a standing permission slip.

The ref is supplied explicitly and taken from the repository, never from
GITHUB_REF_NAME: on a pull_request_target run that is `<n>/merge`, which is
not a branch. A non-204 still fails the job, keeping the misconfiguration
alarm the trigger exists to be, and the diagnostic explains Forgejo's empty
500 rather than passing a bare status to a reader who will go looking for an
outage that is not there.

test/labels-dispatch.test.sh extracts the shipped step and executes it against
a recording stub, asserting the method, endpoint, ref and inputs actually
sent. Dropping the inputs or ignoring a non-204 both red the suite.

Refs #205
2026-08-05 16:58:05 +00:00
clad2
262705d394 Merge remote-tracking branch 'origin/main' into build/202-runner-probe-venue
All checks were successful
CI / test (pull_request) Successful in 3m12s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
2026-08-05 16:29:10 +00:00
a13aa6d4b9 Merge pull request 'fix(docs-sync): the doctrine mirror is fetched from the forge in play, never a built-in one (#201)' (#203) from build/201-docs-sync-forge-source into main
All checks were successful
CI / test (push) Successful in 3m12s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 8s
CI / action-exercise (push) Successful in 6s
CI / docs-sync-exercise (push) Successful in 6s
release / release (push) Successful in 7s
Reviewed-on: #203
Reviewed-by: glm-reviewer-andresmgsl <andres+5@heavyduty.builders>
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
2026-08-05 16:28:23 +00:00
clad2
65cee3fdf9 Merge remote-tracking branch 'origin/main' into build/202-runner-probe-venue
All checks were successful
CI / test (pull_request) Successful in 3m12s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
2026-08-05 15:54:29 +00:00
clad2
1bf7091d39 Merge remote-tracking branch 'origin/main' into build/201-docs-sync-forge-source
All checks were successful
CI / test (pull_request) Successful in 3m12s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
2026-08-05 15:54:29 +00:00
5c924294bf Merge pull request 'docs/UPSTREAM-SYNC.md + .upstream-ref + the delta-inventory guard — the recurring sync, written from having just done one (#200)' (#208) from build/200-upstream-sync-doc into main
All checks were successful
CI / test (push) Successful in 3m13s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 6s
CI / action-exercise (push) Successful in 6s
CI / docs-sync-exercise (push) Successful in 6s
release / release (push) Successful in 7s
Reviewed-on: #208
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
2026-08-05 15:50:35 +00:00
0cb320b807 Merge pull request 'lib/forge-*.sh + labels-reconcile — forge_commit_at, because Forgejo serves a single commit at /git/commits/{sha} (#209)' (#212) from build/209-commit-at into main
Some checks failed
release / release (push) Waiting to run
CI / test (push) Has been cancelled
CI / release-exercise (push) Has been cancelled
CI / self-guards (push) Has been cancelled
CI / action-exercise (push) Has been cancelled
CI / docs-sync-exercise (push) Has been cancelled
Reviewed-on: #212
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
2026-08-05 15:49:58 +00:00
03143ff0ed Merge pull request 'issueflow-reconcile — the board discriminator is .pull_request == null, not has(), or this forge has no issues (#210)' (#211) from build/210-discriminator into main
Some checks failed
release / release (push) Waiting to run
CI / test (push) Has been cancelled
CI / release-exercise (push) Has been cancelled
CI / self-guards (push) Has been cancelled
CI / action-exercise (push) Has been cancelled
CI / docs-sync-exercise (push) Has been cancelled
Reviewed-on: #211
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
2026-08-05 15:49:31 +00:00
clad2
368621dcea docs(runner-probes): bind caller kind to its path class
All checks were successful
CI / test (pull_request) Successful in 3m8s
CI / release-exercise (pull_request) Successful in 10s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
The manifest target-validation added in 6874e76 checked owner and sha per
kind but never bound the kind to the path class, so a consistently swapped
caller layer passed: `<fork>/actions/x@<armed>` declared workflow_caller
satisfies the owner rebuild and the armed-sha test
(@codex-reviewer-andresmgsl, #202 review).

Decompose the coordinate once, then let the kind fix BOTH the path class
and the sha. Driven with manifest and tree mutated together, so
tree-vs-manifest equality cannot hide the swap.

Also finish the rename codex asked for: the generator's first parameter is
the pre-arming candidate checkout, and the variable is now named for it.

Refs #202
2026-08-05 15:33:20 +00:00
6874e76c04 docs(runner-probes): the checker binds the manifest to its target, and the snippets lint clean standalone (#202)
All checks were successful
CI / test (pull_request) Successful in 3m8s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
@codex-reviewer-andresmgsl, and he linted the published snippets DIRECTLY,
which my "parse, lint clean" claim had never meant.

1. THE CHECKER DID NOT CHECK THE TARGET. It accepted <fork> <code-sha>
   <armed-sha> and used none of them — SC2034 on all three, which is the same
   defect the linter and the reviewer found independently. It proved only
   "tree equals manifest", so a manifest generated with the ARMED sha where the
   candidate belonged, against a tree rewritten to that same wrong value,
   passed. Wrong-but-consistent is exactly what this gate exists to reject.

   Each manifest `want` is now validated against the independently supplied
   target before the tree is compared to it.

2. ONE ORDER, NOT TWO. Step 2 said "commit the arming AND write the manifest"
   while the prose below correctly said to generate from the PRE-arming tree.
   The manifest enumerates the carriers that must CHANGE, so it has to see them
   before they do — generating afterwards enumerates rewritten rows and loses
   the canonical internal-checkout ones entirely. The generator's first
   parameter is <candidate-checkout> now, and says so.

3. THE SNIPPETS LINT CLEAN STANDALONE. SC2016 needed a scoped directive — and
   the first placement was itself invalid: SC1124, a directive may precede a
   complete command, not an individual case branch. The checker's mktemp gets
   a trap.

Driven, the new controls:

  correct manifest + tree + target args        passes
  wrong fork, manifest AND tree consistent     refuses
  wrong candidate sha, consistent              refuses
  armed sha where the candidate belongs        refuses

plus every earlier class still red, and both snippets ShellCheck-clean when
extracted as an operator would copy them.

test/run.sh 28/28; repository shellcheck 0.10.0 and changelog-armed clean.

Refs #202
2026-08-05 15:22:01 +00:00
fc24fa4b78 docs(upstream-sync): the inventory names docs-sync, which #201 makes forge-deciding (#200)
All checks were successful
CI / test (pull_request) Successful in 3m9s
CI / release-exercise (pull_request) Successful in 10s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
Found by combining all five open PRs and running the suite on the result —
which is the check this PR's own runbook adds, catching a real break the first
time it was applied at scale.

!203 (#201) makes actions/docs-sync/docs-sync.sh decide the forge from
GITHUB_SERVER_URL, because it was fetching the doctrine mirror from a
hard-coded github.com. This PR's guard requires every forge-deciding file to be
named in the inventory. Both are individually green; together the tree is red:

  forge-specific but not in docs/UPSTREAM-SYNC.md:
    actions/docs-sync/docs-sync.sh

The entry belongs here rather than in !203: the inventory is this PR's artifact,
and !203 is a bug fix that should not have to know about a guard absent from
its base. Adding it early is harmless — the guard checks that deciding files
ARE listed, not that listed files decide — and correct the moment both land.

Five-way combined tree after this: 28 test files 0 failed under the runner's
jq 1.6, shellcheck 0.10.0, actionlint, self-ref, marker, vendored and
changelog-armed all clean.

Refs #200
2026-08-05 15:15:35 +00:00
20f4b287f7 docs(runner-probes): the generator survives a one-layer probe, callers carry full coordinates, one domain (#202)
All checks were successful
CI / test (pull_request) Successful in 3m8s
CI / release-exercise (pull_request) Successful in 10s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
@codex-reviewer-andresmgsl drove the published commands again and found three.

1. THE GENERATOR ABORTED ON AN ABSENT CALLER CLASS — the same `set -e` +
   `git grep` no-match bug I had just fixed in the CHECKER, in the generator I
   wrote in the same commit and did not apply the lesson to. A probe that
   exercises one layer produced no manifest and no diagnostic. `|| true` on
   every extraction, plus an explicit count so ZERO ceremony callers refuses by
   name while workflow-only and action-only probes generate valid manifests.

   That count check was itself broken on its first write: `grep -E '\t…'` reads
   a literal `t`, not a tab, so it counted zero on a perfectly good manifest
   and refused it. Found by running it.

2. CALLERS CARRY THE COMPLETE COORDINATE. The manifest stored only the sha and
   the checker compared owner and suffix separately, so
   `<fork>/actions/WRONG-ONE@<right-sha>` passed. The manifest now records
   `<fork>/<path>@<sha>` and every kind is one exact comparison — which also
   removes the per-kind branch that made the omission possible.

3. GENERATOR AND CHECKER SHARE ONE DOMAIN. `actual` extracted every `uses:`
   while the generator manifested only ceremony patterns, so a legitimate
   `actions/checkout` was always an unrecognised carrier. Both are restricted
   to ceremony callers; a wrong OWNER is still caught because
   `wrong-owner/ceremony/...` is still a ceremony caller.

And the stale fragment wording, which glm flagged and codex re-flagged:
"both CEREMONY_SELF_REF values" -> "every".

DRIVEN, all of it:

  generator: both / workflow-only / action-only  -> valid manifests
  generator: zero ceremony callers               -> refuses by name
  deletion, role swap x2, wrong owner, wrong sha,
  wrong path, deleted caller class, extra carrier -> all refuse
  armed control, third-party actions/checkout present -> passes

test/run.sh 28/28; shellcheck 0.10.0 and changelog-armed clean.

Refs #202
2026-08-05 15:07:11 +00:00
a55fbaef15 fix(forge): forge_commit_at — Forgejo serves a single commit at /git/commits/{sha} (#209)
All checks were successful
CI / test (pull_request) Successful in 3m8s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
Found by the first post-merge sweep after the 0.6.0 merge — #198's own
acceptance probe — not by review. Three PRs in one run:

  labels: #208: could not read the head commit's date:
    forge_api: HTTP 404 from 'GET repos/heavy-duty/ceremony/commits/f3a1336…'
    — blocker:unrequested not judged this pass

Measured against this instance:

  forgejo  repos/{o}/{r}/commits/{sha}       -> 404
  forgejo  repos/{o}/{r}/git/commits/{sha}   -> 200, date under `.created`
  github   repos/{o}/{r}/commits/{sha}       -> 200, date nested

A fourth asymmetry, alongside the three lib/forge-forgejo.sh's header already
records. #198 ported this call site onto the shim with GitHub's path unchanged
— correct against GitHub, and the block it lives in (#236 D2) arrived WITH the
merge, so nothing here had ever executed it.

So it becomes a verb rather than a path at the call site: the caller wants one
timestamp and should not have to know either shape.

Cost while it stood was bounded and loud rather than silent — guarded_read
refused and the sweep said so — but blocker:unrequested could never be judged
on this forge.

The tests pin each backend's PATH and FIELD, because a stubbed forge_api cannot
catch a wrong path; that is exactly how this shipped and why it took a live
sweep to find. Swapping the paths reds the forgejo pair; swapping the fields
reds the github one.

test/run.sh 28/28 under jq 1.7 and jq 1.6; forge-backends 124/124; shellcheck
0.10.0 and actionlint clean.

Refs #209
2026-08-05 15:01:32 +00:00
745944ec8c docs(runner-probes): the arming gate is a manifest comparison, driven against all six failure classes (#202)
All checks were successful
CI / test (pull_request) Successful in 3m8s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
@codex-reviewer-andresmgsl's four holes and @glm-reviewer-andresmgsl's prose
staleness. Every weaker shape I had written has a hole, and each was found in a
published draft of this file:

  "the old literal is absent"            a carrier rewritten to the wrong fork
  "every extracted value equals X"       a carrier that VANISHED
  "each value is one of {fork,dynamic}"  a ROLE SWAP either direction
  "the SHA suffix matches"               wrong-owner/ceremony/actions/foo@right-sha
  "known callers match"                  an unrecognised caller, or none

So the arming step generates a MANIFEST — path, kind, full expected value —
from the tree it is arming, and the gate compares actual carriers against it as
a set. All six become one kind of failure: the sets differ. Generated rather
than written into this document, because the carrier set changes whenever a
workflow is added — which is exactly how "both CEREMONY_SELF_REF values" went
stale while main grew a third.

The prose went stale with the snippet, as glm noted: step 2 said "both", and
said "every workflow carrier -> repository:" without excepting the consumer
checkouts. Both corrected.

DRIVEN, not asserted. I built an armed/probe pair and ran every class:

  deletion, role swap x2, wrong fork, wrong SHA, extra carrier  -> all refuse
  the armed control                                             -> passes

Doing that found two defects the snippets would otherwise have shipped with:

  * the manifest generator's consumer-checkout line used `\$` inside SINGLE
    quotes — an escaped dollar, not the end anchor — so it silently produced a
    manifest row with no kind and no value;
  * `git grep` exits 1 on no-match, and under `set -e` inside the collecting
    group that killed the script BEFORE the comparison. A carrier class that
    vanished entirely produced SILENCE rather than a refusal, which is worse
    than the hole it was meant to close.

test/run.sh 28/28; shellcheck 0.10.0 and changelog-armed clean.

Refs #202
2026-08-05 14:53:30 +00:00
5b78d29201 test(issueflow): isolate the scalar guard — the list row admits, the payload stands down (#210)
All checks were successful
CI / test (pull_request) Successful in 3m10s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
@codex-reviewer-andresmgsl found the subtlety my board fixture could not reach:
BOARD_RECORDS filters an object-valued row out of the LIST before the per-issue
guard ever sees it, so no board fixture alone can prove the scalar stand-down.
My #64 case proved the gather excluded it, not that reconcile_issue_pass did.

The fixture that isolates the site is a deliberate mismatch: the LIST row is
null-valued, so the board gather admits #65 — and the INDIVIDUAL payload the
sweep then fetches is object-valued. Only reconcile_issue_pass's own guard can
stand that down.

Three rows over the same number, so the guard cannot pass by standing
everything down or by admitting everything:

  payload object-valued   -> NOT reconciled
  payload null-valued     -> reconciled
  payload key absent      -> reconciled (the GitHub shape)

Mutating ONLY the scalar predicate now reds three BEHAVIOURAL rows plus the
pin, where before it red only the pin and a neighbour. pass_disc is gone: it
repeated the predicate inside the test helper and never called production —
which is the same isolated-expression trap, one layer down, in the fix for it.

issueflow 512/512; test/run.sh 28/28; shellcheck 0.10.0 clean.

Refs #210
2026-08-05 14:49:01 +00:00
087ea4a24b test(issueflow): each site observable through the real path, not through the expression it contains (#210)
All checks were successful
CI / test (pull_request) Successful in 3m9s
CI / release-exercise (pull_request) Successful in 10s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
@codex-reviewer-andresmgsl's three, and items 1 and 2 were still open after
bada4ff — his review predates that push, but only item 3 (release bodies) was
actually answered by it.

TRAVERSAL, not a non-empty gather. The board case asserted the board was not
read as empty; #60 was `ready` and therefore produced no observable effect, so
nothing proved reconcile_issue_pass had run over it. #60 now carries NO queue
state, so traversal has a deterministic outcome — needs-triage is minted and
logged — and the row asserts that.

THE SCALAR SITE, through the real path. My first attempt asserted the jq
expression the function contains, which is exactly the shape that let this
regression through: the isolated discriminator rows passed the whole time the
gather was blind. A hand-wired probe around reconcile_issue_pass needed so much
internal setup that it would have been testing my scaffolding, so the same
board harness drives it with one row flipped — object-valued must NOT be
reconciled as an issue, and the sweep must then correctly report the board as
empty OF ISSUES.

Per-site mutation, all three now behavioural rather than pin-only:

  revert BOARD_RECORDS        -> 4 red
  revert release_bodies       -> 2 red
  revert reconcile_issue_pass -> 3 red

test/run.sh 28/28; issueflow 510/510; shellcheck 0.10.0 clean.

Refs #210
2026-08-05 14:44:31 +00:00
bada4ffff5 test(issueflow): each of the three sites is caught by behaviour, not only by the pin (#210)
All checks were successful
CI / test (pull_request) Successful in 3m9s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
@codex-reviewer-andresmgsl's two scope items, applied before the first review
round rather than after.

1. THE GUARD IS COMMENT-AWARE, WITH CONTROLS. It already stripped comments —
   it has to, because the #188 warning that explains why has("pull_request") is
   wrong contains the string. Without controls that was an untested property,
   and the pressure it creates is real: a raw grep would push a builder into
   deleting the very warning that prevents recurrence. Two fixtures now prove
   it: the explanatory comment is allowed, an executable jq filter is rejected.

2. ALL THREE SITES ARE DRIVEN BY BEHAVIOUR. The pin makes any revert red, but a
   pin proves a string is absent, not that each replacement means the intended
   thing:

     BOARD_RECORDS        the forgejo-shaped board is not read as empty
     release_bodies       an open `release` issue whose gate holds an open
                          member makes a claimable NON-member draw a window
                          flag — empty carriers, no flag, so the row
                          discriminates the site instead of merely reaching it
     reconcile_issue_pass the scalar payload: key-present-null is an issue,
                          object-valued is a PR, key-absent is still an issue

   The release_bodies row did NOT discriminate on its first write — it asserted
   an issue number that BOARD_RECORDS also produces, so reverting the site left
   it green. Caught by mutating each site separately rather than trusting the
   suite total.

Mutation, per site: BOARD_RECORDS 3 red, release_bodies 2 red,
reconcile_issue_pass 2 red.

test/run.sh 28/28; issueflow 510/510; shellcheck 0.10.0 clean.

Refs #210
2026-08-05 14:37:37 +00:00
877e09e015 fix(issueflow): the board discriminator is .pull_request == null, not has() (#210)
All checks were successful
CI / test (pull_request) Successful in 3m8s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
issueflow-reconcile has been blind on this forge since the 0.6.0 merge landed.
Run 368 — #198's own post-merge acceptance probe — printed:

  issueflow: no open issues.
  issueflow: reconciled.

over a board of nine.

Every Forgejo entry CARRIES the `pull_request` key, valued null on an issue, so
`select(has("pull_request") | not)` selects zero rows. Measured again today:
#209 (an issue) has the key valued null; #208 and #207 (PRs) have it valued as
objects.

This is mine. #188 fixed exactly this and the file's own comment at :1113
states the rule, with :1121 already using it correctly. Resolving hunk 4 of the
merge I took upstream's board block wholesale and carried the wrong
discriminator into three sites — the gather, the release-body gather, and
reconcile_issue_pass — in the PR whose stated purpose was to stop blind sweeps
reporting success.

Cost while it stood: no issue transitions, no claim reclaims, no nudges, no
board flags — and no `post-merge` transitions, which is why #192 and #198 both
still read `claimed` after their PRs merged, and why #198's own closure
criterion could not complete.

Two guards, because a comment did not hold:

  * A GATHER-LEVEL CASE against a Forgejo-shaped fixture — every entry carrying
    the key. The existing discriminator cases assert jq expressions in
    isolation and passed throughout this regression; they never ran the gather
    that uses them, which is precisely how it survived review.
  * A SOURCE PIN forbidding has("pull_request") on this surface, so a future
    sync cannot reintroduce it 40 lines below the comment forbidding it.

Reverting the board gather reds both. Reverting reconcile_issue_pass reds the
pin.

test/run.sh 28/28 under jq 1.7 and jq 1.6; issueflow 503/503; shellcheck 0.10.0
and actionlint clean.

Refs #210
2026-08-05 14:27:40 +00:00
dc87051c69 docs(runner-probes): enumerate the real carriers, spare the consumer checkouts, and make the snippet run (#202)
All checks were successful
CI / test (pull_request) Successful in 3m8s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
@codex-reviewer-andresmgsl's three, all verified against current main before
fixing.

1. THERE ARE THREE SELF-REF CARRIERS, NOT TWO — labels-sweep.yml:52,
   labels.yml:51, release.yml:132. My `[ "$n" -eq 2 ]` came from the
   pre-upstream tree, so it would have REJECTED a correctly armed candidate and
   told the operator to rewrite two of three, leaving one workflow pinned to
   the tag. The gate enumerates from the tree now, with the derivation commands
   beside the table so the list is re-checked rather than trusted.

2. NOT EVERY `repository:` BELONGS TO THE FORK. Three are
   `${{ github.repository }}` — labels-sweep.yml:69, labels.yml:92,
   release-exercise.yml:72 — and they fetch the CALLER's repository. My loop
   required every one to equal the fork, which would have rewritten the
   consumer checkouts and quietly changed what the probe exercises. Internal
   self-checkouts (four) are asserted to be the fork; consumer checkouts are
   asserted to stay dynamic.

3. EACH CHECK IS BOUND TO THE TREE IT IS ABOUT — `git -C "$armed"` for the
   carriers, `git -C "$probe"` for the callers, instead of depending on the
   operator's current directory. And `mapfile` rather than `git grep | while …
   fail`: the loop ran in a pipeline subshell, so `fail` exited the subshell
   and the gate carried on. Collect first, validate after, under a declared
   `set -euo pipefail`.

And the snippet is now executable rather than illustrative: placeholders became
positional parameters, so it parses, is shellcheck-clean, and runs. Driven
against the unarmed tree it refuses with `CEREMONY_SELF_REF=0.6.0` — a tag
rather than the candidate SHA, which is exactly the case it exists to catch.
Publishing a gate that could not run would have been the same defect one level
up.

Branch updated from merged main (e236318). test/run.sh 28/28; shellcheck 0.10.0
and changelog-armed clean.

Refs #202
2026-08-05 14:13:40 +00:00
8c3c37d412 Merge remote-tracking branch 'origin/main' into build/202-runner-probe-venue 2026-08-05 14:11:05 +00:00
a48cc719a4 fix(upstream-delta): discovery is git's index, not the filesystem (#200)
All checks were successful
CI / test (pull_request) Successful in 3m9s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
@codex-reviewer-andresmgsl reproduced it again, with one file: scanned_paths()
said "tracked" and used `find`, which walks the working directory and knows
nothing about the index.

Not pedantry — ci.yml extracts shellcheck.tar.xz, actionlint.tar.gz and their
binaries INTO the checkout before the suite runs, and any developer cache sits
there too. Today none happens to carry a matching marker; that is luck, not a
property, and a false red on a downloaded tarball would be indistinguishable
from a real finding.

`git ls-files -z` makes "tracked" executable rather than prose.

The fixtures become tiny git repositories, because a fixture that is only a
directory is invisible to ls-files and every must-fail below it would have
passed vacuously — the same trap as the earlier teeth that never invoked the
guard. Plus the negative case he asked for: an untracked marker-bearing cache
file is ignored, and the moment it is TRACKED the guard sees it.

Reverting discovery to find reds three.

Branch updated from merged main (e236318, now carrying !206) before verifying:
upstream-delta 28/28, test/run.sh 29/29, shellcheck 0.10.0 clean.

Refs #200
2026-08-05 14:10:56 +00:00
f8f318c634 Merge remote-tracking branch 'origin/main' into build/200-upstream-sync-doc 2026-08-05 14:08:45 +00:00
e236318647 Merge pull request 'lib/forge-forgejo.sh + labels-reconcile — label removal is a full-set PUT, and a write that did not happen fails the sweep (#192)' (#206) from build/192-label-write into main
All checks were successful
CI / test (push) Successful in 3m7s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 7s
CI / action-exercise (push) Successful in 6s
CI / docs-sync-exercise (push) Successful in 6s
release / release (push) Successful in 7s
Reviewed-on: #206
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
2026-08-05 14:07:59 +00:00
7e02344672 docs(runner-probes): the arming gate asserts what each carrier IS, not that a literal is gone (#202)
All checks were successful
CI / test (pull_request) Successful in 3m2s
CI / release-exercise (pull_request) Successful in 10s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
@codex-reviewer-andresmgsl: absence of the canonical coordinate is not proof of
correct arming. The negative grep stays green if CEREMONY_SELF_REF names a tag,
the ARMED sha, or any other commit; if a carrier was rewritten to the wrong
fork; if an executable carrier lives outside .github; or if a carrier simply
disappeared rather than being rewritten.

So the gate is positive now: every `repository:` must equal the recorded fork,
both CEREMONY_SELF_REF values must equal the CANDIDATE CODE sha (not the armed
one — that is the self-reference this two-layer shape exists to avoid), and
callers must match their layer: reusable workflows the armed sha, composite
actions the code sha.

With a COUNT beside the comparison. `n -eq 2` is the part that catches a
carrier which vanished, which a per-value loop cannot see — the same shape as
counting the call sites a pin is guarding rather than only checking the ones
that are there.

The canonical-coordinate grep stays as a cheap extra rather than as the proof.

Wording, same review: steps 1 and 2 advance the tip of ONE fork branch, so
reset removes that branch, not "candidate and armed branches".

test/run.sh 28/28; shellcheck 0.10.0 and changelog-armed clean.

Refs #202
2026-08-05 14:02:08 +00:00
f3a1336d42 fix(upstream-delta): discovery derives from the tree, not from a glob list (#200)
All checks were successful
CI / test (pull_request) Successful in 3m5s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
@codex-reviewer-andresmgsl did not argue this one, he reproduced it: an
`actions/*/action.yml` declaring CEREMONY_FORGE_CLIENT and a workflow written
`.yaml` rather than `.yml`, both invisible to the hand-picked globs, guard
still 21/21 green.

The first is not an edge case — `actions/*/action.yml` is this repository's
normal composite structure and a client declaration there IS a forge decision.
The second shows `*.yml` was never a complete workflow surface.

So discovery walks the tree and EXCLUDES by class rather than enumerating
directories, depths and extensions. Excluding is the safer default: a new file
type arrives scanned rather than invisible. Out of scope are .git/, test/
(whose harness asserts these tokens by design), changelog.d/ and *.md — prose,
including drills/, which stays in the inventory because its records are
forge-specific by CONTENT while a record mentioning a selector verb in prose is
not a decision.

Both of his reproductions are now fixtures driving the real no_unlisted, and
restricting discovery back to *.sh reds four cases.

The documentation claim is aligned with what the guard does rather than what
the table implies: it checks forge DECISIONS in executable and configuration
files; it is not a diff against upstream, so drills/ and labels.conf are listed
by judgement rather than found by scan. Saying otherwise made labels.conf and
drills/ look like evidence of completeness while action.yml was invisible.

upstream-delta 24/24; test/run.sh 29/29; shellcheck 0.10.0, actionlint and
changelog-armed clean.

Refs #200
2026-08-05 13:58:32 +00:00
e27acd8ab9 docs(runner-probes): arming is two layers, because a commit cannot contain its own SHA (#202)
All checks were successful
CI / test (pull_request) Successful in 3m2s
CI / release-exercise (pull_request) Successful in 10s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
@codex-reviewer-andresmgsl found that the procedure was not executable as
written, and the reason is structural rather than a wording slip.

The candidate's workflows carry `repository: heavy-duty/ceremony` beside
`ref: ${{ env.CEREMONY_SELF_REF }}`, so arming must rewrite them. But
rewriting CREATES A NEW COMMIT, and a commit cannot embed its own object ID. So
a single-layer arming is self-referential: pin the callers to the pre-rewrite
SHA and they load the UNARMED workflows; pin them to the post-rewrite SHA and
you are asking that commit to contain itself. My step 3 asked for exactly that.

Two layers, stated as a table because the distinction is the whole thing:

  candidate code SHA   the immutable tree under test — actions/, lib/
  armed workflow SHA   a child commit whose workflows point at the fork and
                       whose CEREMONY_SELF_REF is the candidate code SHA

And callers pin by layer, because they are not the same thing: composite
actions to the candidate code SHA, reusable workflows to the armed SHA, which
is the only revision whose inner checkout is rewritten.

The completeness check becomes a mechanical non-zero gate — `git grep` for
executable `uses:`/`repository:` carriers over the ARMED tree, exiting non-zero
on any hit — rather than "every remaining hit must be prose". A partial rewrite
does not announce itself: it silently tests canonical main.

The result issue records both SHAs, not one, or a later reader cannot tell
which tree answered.

test/run.sh 28/28; shellcheck 0.10.0 and changelog-armed clean.

Refs #202
2026-08-05 13:54:44 +00:00
634e7a3528 fix(upstream-delta): the object is mandatory, the scan covers every governed surface, and the teeth drive the real guard (#200)
All checks were successful
CI / test (pull_request) Successful in 3m4s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
@codex-reviewer-andresmgsl's five points. Three were correctness, and one of
them found that my must-fail cases could not fail.

1. THE OBJECT IS MANDATORY. UNVERIFIABLE-HERE is gone: a missing ref, an absent
   object and a non-ancestor are three distinct refusals. The ref is now the
   FULL 40-character SHA, and ci.yml fetches exactly that object before the
   suite. "Runs offline" means the TEST reads local evidence; it never meant CI
   may omit the evidence and pass.

2. THE SCAN COVERS WHAT THE INVENTORY CLAIMS. It walked shell under four globs
   and never looked at workflows, .github/labels.conf or drills/ — three
   categories the inventory governs. Widened, and it immediately found four
   real blind spots on merged main: refs-not-closing's declaration,
   labels.yml's inline forge decision, refs-guard.yml's GitHub-only scheduling
   and release-exercise.yml's pinned CEREMONY_FORGE. All four are now inventory
   entries with the issue that removes them, because a delta location with no
   exit is indistinguishable from one nobody noticed. A file that DECLARES a
   client is no longer exempt as a "consumer" — only files that merely CALL the
   shim are.

3. THE TEETH NOW DRIVE THE GUARD. They asserted the predicates separately and
   never invoked no_unlisted, so the guard could have been `return 0` and both
   must-fail rows would still have passed. SCAN_ROOT is a parameter now and the
   cases build a tree, add an unlisted decider — shell AND workflow, so
   coverage cannot regress to the old glob — and assert the real top-level
   check fails naming it. Replacing no_unlisted with `return 0` reds five.

4. PATH MATCHING, NOT PREFIX MATCHING. `drills/` accepted `drills-old/x` and
   `lib/forge.sh` accepted `lib/forge.sh.backup`. Exact for files, `dir/` for
   directories, with both negative boundaries covered.

5. THE IMMUTABLE SHA IS CAPTURED AT FETCH. The runbook now takes
   upstream_sha=$(git rev-parse gh/main) once and merges and records that
   value. This is not hypothetical: while this PR was in review upstream moved
   from 8c3a4d1 to 08e2912, and re-reading gh/main at recording time would have
   written a commit this tree does not contain. I caught that by walking into
   it.

test/run.sh 29/29; upstream-delta 21/21; shellcheck 0.10.0, actionlint,
changelog-armed clean.

Refs #200
2026-08-05 13:51:21 +00:00
b80767e36c docs(runner-probes): rewrite coordinates not only refs, keep result issues, and stop asserting what was not measured (#202)
All checks were successful
CI / test (pull_request) Successful in 3m2s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
@codex-reviewer-andresmgsl's three operational corrections.

1. ARMING REWRITES THE COORDINATE. The candidate SHA exists only in the
   identity fork, so a stub still saying heavy-duty/ceremony/...@<sha> cannot
   resolve it — and the candidate's own self-checkout hardcodes
   `repository: heavy-duty/ceremony` beside the ref, so rewriting only
   CEREMONY_SELF_REF makes it fetch the candidate SHA from the canonical
   repository, where it does not exist. Both halves are now explicit, plus a
   grep that enumerates every remaining heavy-duty/ceremony carrier so a
   PARTIAL rewrite refuses instead of silently testing canonical main.

2. RESULT ISSUES ARE NOT RESET SCOPE. I had step 6 keep them as durable
   evidence and the reset section delete them as stale — contradictory, and
   the deleting half would recreate the expiring-log problem the venue exists
   to avoid. Reset removes candidate-specific EXECUTABLE state only; result
   issues may be closed or relabelled, never deleted.

3. NO UNMEASURED CLAIMS. I wrote that a personal namespace is where "the org's
   runner and secrets do not reach". That was not measured — the probe repo was
   deleted immediately and established only 403-on-org / 201-on-personal. The
   no-workaround rule now rests on what was actually ruled: @andres chose an
   ORG-OWNED standing venue, so a personally-owned repo is a different thing
   from the one decided on and cannot satisfy #202's acceptance target. If
   runner reach matters, it gets measured once the venue exists.

test/run.sh 28/28; shellcheck 0.10.0, changelog-armed clean.

Refs #202
2026-08-05 13:46:03 +00:00
f3f7538d15 docs(upstream-sync): stale in-flight branches, and auditing post-merge runs by executed steps (#200)
All checks were successful
CI / test (pull_request) Successful in 3m2s
CI / release-exercise (pull_request) Successful in 10s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
@codex-reviewer-andresmgsl's two additions (#5697), both measured in the #198
sync rather than anticipated.

Every branch open across a sync is stale afterwards: Forgejo never re-tests an
open PR when main moves under it, so #206 and #207's green 22-file suites were
about a tree that no longer existed once the 28-file one landed — and #206's
fragment was individually green while making the combined tree red under a rule
the sync itself introduces. The runbook now says to update each in-flight
branch from the newly synced main, or check them in a scratch merge, and that a
prior approval is evidence about the tree it was given on.

And post-merge runs are audited by executed steps, never by colour: inventory
what the sync changed about triggers and jobs, read which job actually ran, and
treat a green refusal path as evidence for that path only. Run 326 was green
and had reconciled nothing.

Both failures happened with the no-runtime-gh guard green and CI green, so the
runbook says that too.

Refs #200
2026-08-05 13:42:21 +00:00
e61bb91476 docs(runner-probes): its own document, an arming procedure, and the evidence boundary made consistent (#202)
All checks were successful
CI / test (pull_request) Successful in 3m2s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 8s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
@codex-reviewer-andresmgsl's four gaps.

1. BRANCH UPDATED TO CURRENT MAIN. The commit's parent was pre-#204 dad99dd, so
   its green 22-file run did not contain the six test files and rules that
   landed with the sync. Merged main in — no rewrite — and re-verified against
   the 28-file suite the operator would actually receive.

2. WHO MAY RESET IT is now a section, and it says operator-owned until ruled
   otherwise, with content reset separated from archive/delete/admin. The
   access policy is set when the repo is created, which is the operator's step,
   so the two belong together. Flagged for @andres rather than assumed.

3. AN EXECUTABLE ARMING PROCEDURE replaces "install whatever the probe needs":
   fork ref and canonical SHA, caller stubs pinned to it, BOTH
   CEREMONY_SELF_REF carriers rewritten, the event invoked recorded by name,
   and what reset removes afterwards. It reuses the drill rehearsal's fork-ref
   pattern rather than inventing a floating pin, including its rule against
   ever creating a tag-shaped branch on heavy-duty/ceremony.

4. THE EVIDENCE CONTRADICTION IS RESOLVED. "Write results into an issue in this
   repo" and "no probe touches ceremony's board" could not both be followed in
   a file where "this repo" reads as ceremony. The job now writes raw results
   into the PROBE repo, and a human carries the issue URL and run number to the
   ceremony issue. The probe workflow holds no credential and no code path that
   can write to ceremony, which is what makes the two rules compatible.

Placement: the operational contract moves to docs/RUNNER-PROBES.md, with a
short cross-link in drills/README.md beside the disposal rule it excepts — the
exception stays visible where the dangerous habit lives, and neither document
grows a second top-level heading.

test/run.sh 28/28 on the updated branch; shellcheck 0.10.0, actionlint,
self-ref, marker, vendored and changelog-armed clean.

Refs #202
2026-08-05 13:40:28 +00:00
2037275a9c Merge remote-tracking branch 'origin/main' into build/202-runner-probe-venue 2026-08-05 13:38:13 +00:00
e965b15cbf docs: the recurring upstream sync, its standing resolutions, and a guard on where the delta lives (#200)
All checks were successful
CI / test (pull_request) Successful in 3m3s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 8s
The third child of #197, written immediately after performing the sync it
describes, while the findings are still first-hand.

docs/UPSTREAM-SYNC.md carries the procedure and the six standing resolutions,
each with the issue that decided it, so they are not re-argued every sync. The
parts that are not obvious from the outside, and that the 0.6.0 sync paid to
learn:

  * THE AUDIT STEP. `git merge` takes upstream's side wherever only upstream
    moved a region, so a function upstream ADDED to a file this tree owns
    arrives with no conflict and no question. Reviewing the hunks cannot find
    it — four reviewers read the same diff and each found a different subset.
    That was eight runtime `gh` call sites in three files and two file types.

  * THE SAME MECHANIC APPLIES TO STATE. A resolved region can remove a producer
    whose consumers auto-merged, and those consumers degrade to empty rather
    than erroring, so nothing goes red. Three such seams in one sync.

  * VERIFY WHERE IT RUNS. "Green locally" was wrong three times, for three
    different reasons: shellcheck-all lints TRACKED files so a new file's first
    lint is meaningless; CI pins shellcheck 0.10.0; and the runner's jq 1.6
    exits 0 where 1.7 exits 4 on `jq -e` with empty input — which was not a
    test problem but a guard accepting an unreadable read.

  * TEST THE MERGE RESULT. Forgejo tests heads, never what two branches produce
    together, and two green PRs did produce a red tree in this sync.

  * AFTER MERGING, CHECK THE SWEEP RECONCILED SOMETHING. The first post-merge
    run was green and had done nothing.

.upstream-ref records the carried commit in machine-readable form beside the
CHANGELOG's prose. test/upstream-delta.test.sh asserts every forge-DECIDING
file is named in the inventory — offline, comment-aware, and refusing rather
than skipping when the ref is missing. Shim CONSUMERS are allowed by name, so
a seventh consumer is silent and a seventh decider is not.

docs/CONSUMERS.md now states that two ceremonies answer to the same version
number and how a consumer says which one it pinned.

Must-fail, both from the issue's test plan: scattering a forge_detect branch
into an unlisted file reds the guard; blanking .upstream-ref reds it too.

test/run.sh 29 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0,
actionlint, self-ref, marker, vendored and changelog-armed all clean.

Refs #200
2026-08-05 13:36:17 +00:00
08714530b3 docs(drills): the standing runner-probe venue, and why it is not a drill (#202)
All checks were successful
CI / test (pull_request) Successful in 1m30s
CI / release-exercise (pull_request) Successful in 10s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
@andres ruled option A (#5631): one standing never-archived repo. This is the
runbook half.

The distinction the document exists to make: a drill is disposable by design
and ends with the builder archiving it. This venue is the opposite — it exists
so that runner-only facts can be measured on demand, and archiving it defeats
the purpose. That is not hypothetical: all three drill repos were archived
correctly, by the rule, and each then had to be un-archived or replaced. The
request came three times in two days across #192 and #198 and never became
anything.

What the runbook pins, all of it measured rather than asserted:

  * a probe MUST run as an Actions job under ${{ github.token }} — the same
    DELETE answers 500 there and 204 under a PAT, so a probe run any other way
    produces a confident wrong answer;
  * probe results are written into the forge, not left in a job log, because
    logs age out and #192's run 701 survived only because it wrote into an
    issue;
  * no probe touches ceremony's own board — the venue exists so the live board
    is not the fixture;
  * the three probes it already owes (#192's live label lift, #205's dispatch
    measurement, a 0.6.0 consumer exercise after #198).

STANDING THE REPO UP IS THE OPERATOR'S STEP, and this is the part I could not
do rather than the part I chose not to. Measured today with this identity:

  POST /api/v1/orgs/heavy-duty/repos  ->  403  not allowed in organization
  POST /api/v1/user/repos             ->  201  personal namespace only

Same shape as the drill delete: a deliberate boundary, not a misconfiguration.
The runbook says so, says not to retry it, and says not to work around it by
using a personal namespace where the org's runner and secrets do not reach.

test/run.sh 22/22, shellcheck 0.10.0, actionlint, self-ref all clean.

Refs #202
2026-08-05 13:16:20 +00:00
a35a77f752 fix(forgejo): a read failure names its verb too, and the tests assert the whole diagnostic (#192)
All checks were successful
CI / test (pull_request) Successful in 1m35s
CI / release-exercise (pull_request) Successful in 10s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 8s
@codex-reviewer-andresmgsl caught a test that describes evidence it does not
collect — mine, and it is the class this PR is about.

Two cases were titled "naming the verb, path and status" and asserted only the
substring "500". The PUT boundary happened to satisfy the contract because
forgejo_write already passes "PUT $endpoint" to forgejo_http_ok. The GET
boundary did not: the diagnostic was `HTTP 500 from 'repos/o/r/issues/5'`, with
no verb at all — so a caller could not tell a failed READ from a failed WRITE
of the same path, and #192's acceptance criterion asks for exactly that
distinction.

Reads now pass "GET $endpoint" on both non-paginated and paginated paths, and
the two tests assert the complete expected diagnostic as one substring rather
than a status code that any failure would contain. Reverting the verb reds the
GET case.

Also, per the same review: the failed GET is asserted to write nothing, and the
failed PUT to have attempted exactly one write.

forge-backends 117/117 (was 115), test/run.sh 22/22 under jq 1.7 and jq 1.6,
shellcheck 0.10.0 and actionlint clean.

Refs #192
2026-08-05 13:11:33 +00:00
790c4d226f Merge pull request 'actions/* + lib/* + CHANGELOG — merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)' (#204) from build/198-upstream-0.6.0 into main
All checks were successful
CI / test (push) Successful in 3m2s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 7s
CI / action-exercise (push) Successful in 6s
CI / docs-sync-exercise (push) Successful in 6s
release / release (push) Successful in 7s
Reviewed-on: #204
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
2026-08-05 13:10:54 +00:00
062e016a42 fix(labels): all four review gaps — preserved ids, zero-write no-op, every mutation counted, no success token (#192)
All checks were successful
CI / test (pull_request) Successful in 1m35s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 56s
@codex-reviewer-andresmgsl's four gaps, all real, all taken.

1. PRESERVED IDS COME FROM THE ISSUE. The removal path read only .labels[].name
   and then re-resolved every preserved label through the repository-wide list
   — so preservation depended on a paginated read with nothing to do with this
   issue, and an incomplete one would drop a bystander. It now keeps
   name<TAB>id from the issue payload, subtracts removals by name, and resolves
   ONLY added names. Fixture: a bystander on the issue with id 14 that is
   absent from the repo-list fixture entirely must still survive the PUT.

2. AN ABSENT REMOVAL WRITES NOTHING. I had it PUT the unchanged set, arguing
   the write proved the sweep reached the forge. The GET already proves that,
   and replacing a set with itself opens ceremony#128's window for no state
   change — most calls here are exactly this case, since the reconcilers call
   --remove-label unconditionally. Short-circuits when the wanted set equals
   the current one. This was the policy-shaped choice flagged for @andres; the
   reviewer's reasoning is better than mine was.

3. EVERY LABEL MUTATION REACHES THE TALLY. The marker was only on the primary
   state edit, so clearing `merge-next` and both `stale` edits could fail into
   the generic per-PR branch and still finish `reconciled.` and exit 0. All
   four sites go through one `label_write` helper, so a future call site cannot
   reopen it by forgetting to mark itself. Probe: a failed NON-primary write
   (unstale on a blocked PR) must fail the sweep.

4. NO SUCCESS TOKEN IN A FAILURE TAIL. "NOT reconciled." still contains
   "reconciled.", which a log-tail consumer greps for. The line is now
   "sweep incomplete", and the test asserts the whole output is free of the
   token rather than only of the success prefix.

Also added the two fault boundaries the acceptance plan named and the fixtures
never proved: a failed current-label GET and a failed replacement PUT, each
non-zero with the backend's verb/path/status diagnostic.

Mutation-tested, each gap separately: bypassing the tally reds 3, re-resolving
preserved ids reds 7, writing the unchanged set reds 1.

forge-backends 115/115 (was 110), labels-reconcile 175/175 (was 172),
test/run.sh 22/22 under jq 1.7 and jq 1.6, shellcheck 0.10.0 and actionlint
clean.

Refs #192
2026-08-05 13:03:25 +00:00
018489ac4d chore(changelog): the 192 fragment's citation is terminal (#192)
All checks were successful
CI / test (pull_request) Successful in 1m34s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 5s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 56s
Found by merging this branch onto !204 and running the suite there — not by
anything visible on this base. The terminal-citation rule (#262) ARRIVES with
the 0.6.0 merge, so a fragment written against main satisfies every guard here
and reds the tree the moment both land.

'(ceremony#128) (#192)' is two groups; exactly one must end the entry. The
reference moves into prose.

Refs #192
2026-08-05 12:54:29 +00:00
0f20f4b6ef fix(labels): a label removal that cannot happen fails the sweep, and removal itself now works (#192)
All checks were successful
CI / test (pull_request) Successful in 1m35s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 5s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 54s
Two defects, one cause, and the second is why the first survived a week.

THE WRITE. Removal was a per-label `DELETE .../labels/{id}` loop. On this
instance that call returns HTTP 500 for every removal under the token the
sweep actually holds — measured inside Actions, probe run 701, where the same
`PUT .../labels` with the desired full set returns 200 including the empty set
for a full clear. A PAT gets 204 on the same DELETE, which is exactly why it
went unseen: it fails only for `${{ github.token }}`.

Net effect before this: on Forgejo the state machine could only ever ADD
labels. Every `state:*` transition needing the previous state cleared and every
`blocker:*` that should lift was inert. Both PRs open right now carry stale
`blocker:*` labels that are false and that nothing can remove.

So the removal path is read-current, compute-wanted, one PUT — the same shape
the assignee branch beside it already used. An ADD-ONLY call keeps its additive
POST: ceremony#128 lost a `release` label to a read-modify-write that clobbered
a concurrent set, and forge_labels_add stays pinned against ever doing that.
The window is accepted here and only here, where the caller asked to REMOVE
and no additive verb can say that. An unresolvable --add-label refuses before
any write, so a replacement PUT can never drop a label nobody asked to remove.

THE REPORTING. `labels-reconcile` logged `WARNING: label edit failed`, fell
through, and `main` printed `reconciled.` and exited 0 — while
`issueflow-reconcile` treated the identical 500 as fatal. One cause, two
contradictory policies, and the wrong one hid the write defect.

A failed write is fatal now, and the tally reaches main's exit code. That
second half is load-bearing: making reconcile_pr fatal alone is not enough,
because the loop swallows a per-PR non-zero into a log line and finishes. The
per-PR tolerance is right and stays — one bad PR must not blind the board — but
it now applies to READS. A sweep that could not write exits non-zero and never
prints `reconciled.`

The diagnostic says what was attempted and that it did not happen. The old text
blamed a missing label and told the operator to bootstrap, when the label was
present and the call returned 500 — #101's rule is report, do not diagnose.

Mutation-tested, all three ways: restoring the warn-and-continue reds 5 cases,
removing the tally reds 2, restoring the DELETE loop reds 7.

test/run.sh 22 files 0 failed under jq 1.7 and jq 1.6; shellcheck 0.10.0 and
actionlint clean.

Refs #192
2026-08-05 12:48:09 +00:00
adf907c963 fix(198): the action fails closed, the caller decides scheduling, the guard decides the forge (#198)
All checks were successful
CI / test (pull_request) Successful in 3m2s
CI / release-exercise (pull_request) Successful in 10s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Has been skipped
labels / labels (pull_request) Successful in 46s
@codex-reviewer-andresmgsl's second review, both points taken.

The refs action goes back to `forge_preflight || exit 1`. 97e63ac had it exit 0
with a notice so the PR check would not be red, and that conflated two
different questions: "this action cannot produce a verdict" is the ACTION's
contract and must stay a refusal, while "this check should not block the
board" is the CALLER's decision. The caller now carries it —
refs-guard.yml skips unless github.server_url is github.com, mirroring
forge_detect positively. A skipped check is a green head; an action that
reports success it did not earn is not. The leaked preflight_err temp file
goes with the revert.

The workflow guard asked the wrong question. `command -v gh` alone passes the
moment a Forgejo runner image happens to ship gh, and then dispatches against
a forge that cannot serve it — the client/forge mismatch forge_preflight
exists to prevent. It decides the FORGE first now, mirroring forge_detect
positively, and the binary second. The source guard splits to match: a
declaration guarded only by binary presence is reported, with a fixture that
fails on exactly that shape.

The warning text was also wrong on the facts, as noted: issue-event sweeps ARE
this caller's event-driven wakes, so they are precisely what is lost. It now
says the hourly scheduled sweep survives and every event-driven wake through
this caller does not, until #205.

Point 1 of that review — jq 1.6 accepting an empty payload — was already fixed
in 728102a, pushed before the review landed.

Verified under the runner's jq 1.6 as well as 1.7: 28 test files, 0 failed
both ways. shellcheck 0.10.0 (CI's pin), actionlint, self-ref, marker,
vendored, changelog-armed all clean with every file tracked.

Refs #198
2026-08-05 12:30:28 +00:00
728102a3ba fix(issueflow): issue_payload_valid refuses an empty payload on jq 1.6 too (#198)
All checks were successful
CI / test (pull_request) Successful in 3m3s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 5s
labels / labels (pull_request) Successful in 45s
`CI / test` was red at 97e63ac on a case that passes on this box: upstream's
own "an empty payload is refused". The cause is not the test.

`jq -e` disagrees with itself across versions on EMPTY input. jq 1.7 exits 4 —
no valid result was ever produced. jq 1.6 exits 0. Measured both ways today
against the same filter. This instance's runner image
(ghcr.io/catthehacker/ubuntu:act-22.04) carries jq 1.6.

So on this forge the guard #247 D3 added specifically to refuse an unreadable
read was ACCEPTING one: an empty body read as a valid issue payload, and the
sweep would have reconciled an issue from a payload it never received. The
test is upstream's, it is correct, and it passes on a GitHub runner — which is
why upstream never saw this.

The fix does not depend on jq's exit code for an input it never receives: the
payload is read, emptiness is decided in the shell, and jq judges only a
non-empty body.

Verified under BOTH jq versions, not just the one on this box: empty refused
and healthy accepted on 1.6 and 1.7, and the whole suite green under jq 1.6 —
28 test files, 0 failed — as well as under 1.7.

Refs #198
2026-08-05 12:22:20 +00:00
06f05aebec fix(198): the workflow declares and refuses instead of being exempted by name (#198)
Some checks failed
CI / test (pull_request) Failing after 3m3s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 5s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 5s
labels / labels (pull_request) Successful in 46s
@codex-reviewer-andresmgsl's blocker 1 is right and the filename exemption was
the wrong shape. It exempted the whole FILE — any later `gh` call anywhere in
labels.yml would have ridden in free — and it let the merge ship a step that
dies with `command not found` on every sweep on this forge, which #197's bar
does not permit.

The declaration mechanism already existed; a workflow simply could not reach
it. It can: `CEREMONY_FORGE_CLIENT: gh` in the step's env is the same
declaration actions/refs-not-closing carries, and the refusal that a script
gets from forge_preflight is inline here because a workflow has no shell to
call it from. The dispatch now warns by name, cites #205, and exits 0 rather
than reddening every sweep for a known gap.

So the guard needs no exemption list at all. It now requires the pair —
declared AND refusing — and reports a declaration that carries no refusal,
which is a permission slip for `command not found`.

That predicate was wrong on its first write, and its mutation test caught it:
`refuses_when_unavailable` matched the word `forge_preflight` inside
labels.yml's own comment explaining that it has NO forge_preflight to call. A
guard reading prose as evidence is the blind sweep again, in the guard written
to forbid it. Comments are stripped now, as gh_calls already stripped them.

Blocker 4: the nudge strips a trailing slash from the server URL. Reverting the
strip reds two cases.

Blockers 2 and 3 were already fixed in 97e63ac, before either review landed.

test/run.sh 28 files 0 failed under CI's env; shellcheck 0.10.0 (CI's pin),
actionlint, self-ref, marker, vendored and changelog-armed all clean, with
every file tracked this time.

Refs #198
2026-08-05 12:16:53 +00:00
97e63acef0 fix(refs-not-closing): report and skip on a forge it cannot speak, rather than reddening every PR (#198)
Some checks failed
CI / test (pull_request) Failing after 3m2s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Successful in 5s
labels / labels (pull_request) Successful in 45s
The first head's `Refs guard` failed on this PR, correctly: spec 4's
CEREMONY_FORGE_CLIENT=gh declaration made forge_preflight refuse by name on
this forge. But that workflow runs on every pull request here, so the
declaration as first written turns every future PR red until #199 lands —
blocking the board for a gap that already has its own issue.

Refusing and scheduling are different questions. This action must never
produce a verdict from a graph it did not read, and it does not: on a forge it
cannot speak it now says so by name, cites #199, states that no verdict was
produced, and reaches the forge zero times. A preflight failure for any other
reason stays fatal, and on a forge it CAN speak nothing changes.

Also: five SC2016 findings in test/no-runtime-gh.test.sh. They were invisible
locally because shellcheck-all.sh lints TRACKED files and the guard was still
untracked when I ran it — a new file is exactly the case that check cannot
see. Verified this time against CI's pinned shellcheck 0.10.0 with the file
committed.

test/run.sh: 28 test files, 0 failed, under CI's CEREMONY_REQUIRE_* env.
shellcheck, actionlint, self-ref, marker and vendored guards all clean.

Refs #198
2026-08-05 12:09:05 +00:00
2900529533 test(no-runtime-gh): the workflow exemption names the issue that removes it (#205)
Some checks failed
CI / test (pull_request) Failing after 32s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 7s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Failing after 5s
labels / labels (pull_request) Successful in 45s
An exemption without a work item is just a hole with a comment on it.

Refs #198
2026-08-05 11:58:28 +00:00
e035130f65 merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
Some checks failed
CI / test (pull_request) Failing after 33s
CI / release-exercise (pull_request) Successful in 12s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
Refs guard / refs-not-closing (pull_request) Failing after 5s
labels / labels (pull_request) Successful in 43s
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.

The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.

The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.

test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.

Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.

The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.

actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.

test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.

Refs #198
2026-08-05 11:56:23 +00:00
e0cd0cb7a3 fix(docs-sync): the mirror is fetched from the forge in play, never a built-in one (#201)
All checks were successful
CI / test (pull_request) Successful in 1m31s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 6s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 24s
heavy-duty/ceremony exists on two forges and the same ref names a different
tree on each — this forge's 0.4.1 carries lib/forge*.sh, GitHub's carries none
of it. The fetch URL was hard-coded to github.com, so a consumer's `.ceremony/`
mirror was verified against a tree it never pinned, and the fetch returned
HTTP 200 while doing it: --check reported drift the consumer could not fix,
and --fix would have rewritten a correct mirror into the wrong one.

The host now comes from GITHUB_SERVER_URL, which Actions injects on both
forges and which lib/forge.sh already selects the whole backend on. Unset,
with no --source, is a refusal naming the variable rather than a guess —
the same rule the pin itself has always followed.

The fetch path had no test coverage at all: every existing row passes
--source, which overrides the fetch entirely. It is now driven against a
PATH-stubbed curl that records the URL and serves a tarball, so the real tar
pipeline still runs and which forge a pin resolves against is asserted.

Refs #201
2026-08-05 11:16:23 +00:00
github-actions[bot]
8c3a4d1dee chore: bump main to 0.6.1-dev — a dev install must not impersonate 0.6.0 2026-08-05 09:35:51 +00:00
Daniel Marin
0ce6cb961a
Merge pull request #323 from cndgrr/build/249-release-0-6-0
release: cut 0.6.0
2026-08-05 10:35:37 +01:00
cndgrr
f832334abe drill 0.6.0: probes 5 and 6, the setup corrections, and the disposal state 2026-08-05 09:14:55 +00:00
cndgrr
6f30989e2a drill 0.6.0: probes 1, 3 and 4 recorded from their runs 2026-08-05 09:11:46 +00:00
cndgrr
24b69aea8e drill 0.6.0: the scratch repo, the candidate ref, and probe 2 2026-08-05 09:05:20 +00:00
cndgrr
fb8f8282a9 release: cut 0.6.0
Thirty-five fragments assembled into '## 0.6.0 — 2026-08-05'; VERSION to
bare 0.6.0; the three CEREMONY_SELF_REF carriers stamped "0.6.0" in this
one commit; the three docs/CONSUMERS.md availability markers cleared to
name 0.6.0 — refs-not-closing (#218), the RELEASES.md mirror entry (#248)
and the vendored-manifest completeness guarantee (#251).

drills/0.6.0.md opens with the measurement that decides its shape: the
doors-unchanged conditions do NOT all hold at this candidate, because
lib/changelog.sh moved on the release path since the last rehearsed tag
0.4.0. A full disposable-repo rehearsal is owed and is in progress; the
record is committed early and filled from the runs as they happen.

Refs #249.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 00:01:03 +00:00
Daniel Marin
d272964dac
Merge pull request #315 from cndgrr/build/311-readme-rewrite
README.md — rewritten whole from the current tree
2026-08-05 00:50:16 +01:00
cndgrr
263bb74652 README: three steps run past the tag, and the publish is one of them
The overview's fail-loudly paragraph said "Only two steps run past the tag"
and attributed the tag-standing/no-release state to the artifact hook alone.
Past `tag the merge commit` (release.yml:224) come three steps: the artifact
hook (:236), `publish the release` (:246) and the -dev re-arm (:267). The
publish is `gh release create --verify-tag`, so it can fail on the API call
or the assets with the tag already standing — the same state, from a second
cause.

State the count as three and sort them by what a failure leaves behind: two
fail before the release exists (hook, publish), both recovered by the tag
door; the third is the re-arm, still the one failure in the file that leaves
a real release behind. The nothing-exists recovery text names the publish
among the causes of a tag with no release too; its remedy is unchanged.
2026-08-04 23:29:12 +00:00
cndgrr
1410c01caf README: the forward pointer cites #317, never a version number
Triage's D6 (#311), added mid-round: the file's single forward-looking
sentence is sanctioned, and bounded. Two corrections to what round 4
landed, both of them the bound rather than the claim -- the claim itself
was re-measured by triage and holds.

- it named 'the 0.7.0 window'. Which release carries that work is a
  scheduling fact owned by the epic and RELEASES.md, where release-init
  may fold an empty window into a later release or skip the version
  outright, so the number can move with no diff under this file while
  every guard stays green. The issue number does not move: #317 is the
  stable name of the work.
- it was present indicative -- 'makes' -- one paragraph after banana
  rides row 6 today. It now reads as work that has not landed, on its
  own, without the reader chasing the link.

Still one sentence, still only in this section, and it weakens no
present-tense claim around it: the -dev half stays unreachable, the
malformed half stays live, and the manual bump stays the remedy today.

Refs #311

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 22:57:43 +00:00
cndgrr
a663c631bf README: two tag-door asserts, the artifact hook past the tag, doctrine is mirrored
codex's round-4 blockers, both reproduced against the tree:

- the opening introduced machinery and doctrine together as 'never
  copied', which the doctrine paragraph then contradicts by design — the
  .ceremony/ mirror is a copy, kept honest by a guard rather than by
  absence. The clause now says of each half what is true of it.
- the tag door has two refusing asserts, not one: tag/tree identity
  (release.yml L328-L339) and a publishable version section
  (L340-L352, changelog_section_problem), the second already quoted in
  this page's own troubleshooting catalog.

claude's N1, taken: the zero-artifact boundary is the tag, not the
publish — the consumer's artifact hook runs between them and its
non-zero exit aborts with a tag standing. The re-arm remains the single
failure that leaves a real release behind.

Per triage's steer, one line pointing the rc half of the re-arm refusal
at the 0.7.0 window (#317); the rc recovery prose is not widened.

Refs #311
2026-08-04 22:44:02 +00:00
cndgrr
ca44ee0f84 README: the overview's zero-artifact promise is the pre-publish one
The re-arm section corrected last round said the bump runs after publish and
its refusal leaves a release standing; the overview still promised an
unconditional -dev re-arm and zero artifacts on any failed assert. Scope the
guarantee to the asserts before the publish, name the rc exception where the
reader meets it first, and stop calling a malformed version's refusal
unreachable - only the -dev half is.
2026-08-04 22:12:29 +00:00
cndgrr
6df42797d2 README: reflow the npm entry and drop an overstated clause
The rc path is how this refusal is reached, not 'the one door an
operator actually walks through' -- most releases are bare and never
see it. Also rewrap the npm paragraph, left ragged by the previous
commit.
2026-08-04 21:42:23 +00:00
cndgrr
05e977814f README: point the two new anchors at the lines that carry the claim
version_is_dev's range started one line in, past the signature comment
that states the -dev-only rule; and the npm range stopped at L112,
before the npm pkg set / npm install --package-lock-only lines it was
cited for. L68-L76 and L114-L115.
2026-08-04 21:40:41 +00:00
cndgrr
052c8734b9 README: the re-arm refusal is the rc path, not an unreachable guard
The section called version_next_dev's refusal unreachable by reading
decide's sense of 'bare' (not -dev) into version_next_dev's regex
(^X.Y.Z$). An rc lives between the two: row 6 admits a labeled rc
transition as a shippable ceremony, the bump step gates only on
ceremony=yes, and its VER is the tree's version verbatim -- so an rc
release tags, notes, publishes, then refuses here. State that path and
its remedy (version.sh L78-L82: an rc's next version is a human
decision), keep the guard reading for the -dev/garbage half that really
is unreachable, and close the same conflation in row 6.

Also: version_write runs npm pkg set + a lockfile-only install, not
npm version (which would tag); and drop 'unarmed main' so the section
uses release.yml's one sense of armed.

Reported by claude-bot-andresmgsl on #315.
2026-08-04 21:38:27 +00:00
cndgrr
dac4946e43 README: the re-arm is the merge door's, so state its reach correctly
Self-caught while re-reading the section added in 87f300c. It said
version_next_dev's refusal was "the tag door's edge" — wrong twice. The tag
door does not bump at all (release.yml:303-307, cast's precedent: the
fallback does not rewrite main), and the merge door's bump runs only on
ceremony=yes, which decide rows 5-6 reach only on a transition TO bare. The
message is therefore unreachable through either door as they stand, and the
honest description is a guard against a future decide change, not an edge an
operator can hit today.

Writing a troubleshooting entry that sends an operator looking down the tag
door for a message the tag door cannot emit is the same defect this round is
fixing one paragraph up, so it does not get to ship in the fix.
2026-08-04 21:04:10 +00:00
cndgrr
87f300c453 README: re-measure the incubator drill and close the refusal catalog
The drill paragraph called incubator's drill "TBD". It has been defined in
heavy-duty/incubator since 2026-07-23 (f7851cb, refined 7c20a4e on 07-24):
the pre-release verify of the canonical candidate deployed to staging, its
smoke probe run inside the container on deployed credentials, the record
pinning commit SHA and image digest. Carried from main unmeasured, which is
the one thing D1 forbids. With incubator named the paragraph enumerates five
meanings, not three, so its own tally closes now too.

The refusal catalog claimed to be generated from the sources, but its
regeneration grep never read lib/version.sh — the four version_read messages
it quotes all live there. Adding version.sh to the documented command turned
up four more refusals the catalog was missing: unknown backend on the read
side, and version_next_dev / version_write's two on the post-release re-arm,
which release.yml:275,283 really can emit. The re-arm ones get their own
section because their remedy is unlike every other entry here — the tag and
the publish already happened, so the fix is a manual bump, not a re-run.

One refusal stays outside the grep by construction: "no version field" is a
console.error inside the node one-liner, with no >&2 and no "refuse ". The
section now says so rather than shipping a command that silently
under-produces the catalog it claims to generate.

Answers codex-bot-andresmgsl and kimi-bot-andresmgsl (blocking, both the
incubator claim) and claude-bot-andresmgsl nit 2, at head 57a7b15.
2026-08-04 20:59:30 +00:00
cndgrr
57a7b156ac README.md rewritten whole from the current tree 2026-08-04 20:22:13 +00:00
cndgrr
8080618a70 changelog.d: the README is rewritten whole from the current tree 2026-08-04 20:17:30 +00:00
Daniel Marin
d24a3142a6
Merge pull request #314 from cndgrr/build/307-issueflow-ruling-preread-pin
test/issueflow-reconcile.test.sh — pin the claimed-branch ruling pre-read (#307)
2026-08-04 21:11:57 +01:00
cndgrr
3af132edf7 changelog.d: the claimed-branch ruling pre-read is pinned
Grouped shape, terminal cite, 287 characters.

Refs #307
2026-08-04 19:44:42 +00:00
dad99ddfb9 Merge pull request 'labels.conf + CONTRIBUTING — the panel and triage rosters name identities that exist on this forge (#195)' (#196) from build/195-roster-mapping into main
All checks were successful
CI / test (push) Successful in 1m29s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 5s
CI / action-exercise (push) Successful in 5s
CI / docs-sync-exercise (push) Successful in 5s
release / release (push) Successful in 6s
Reviewed-on: #196
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
2026-08-04 19:40:59 +00:00
cndgrr
3bba47bacf test: pin the claimed-branch ruling pre-read against its own diagnostic
`claimed` + `needs-ruling` with no assignee posts `claimed-unassigned`
before the tail, so the top read is the only reason the ruling nudge
below still sees the real quiet. Probe 110 asserts both outputs of one
sweep and reds when that read drifts below the post.

Recovered from ed588a2 by sha — the branch ref was deleted and the push
crossed the merge — then re-measured at fd22bd2, behind #293. 486 -> 488
on this file.

Refs #307
2026-08-04 19:39:05 +00:00
Daniel Marin
fd22bd2fe7
Merge pull request #312 from cndgrr/build/293-issueflow-flags
issueflow-reconcile — the sweep flags what the window and collision rules forbid: unblocked twins, and an unblocked non-member during a standing window
2026-08-04 20:29:42 +01:00
c74f31829d fix(labels): panel and triage name identities that exist on this forge (#195)
All checks were successful
CI / test (pull_request) Successful in 1m29s
CI / release-exercise (pull_request) Successful in 10s
CI / self-guards (pull_request) Successful in 5s
CI / action-exercise (pull_request) Successful in 5s
CI / docs-sync-exercise (pull_request) Successful in 5s
labels / labels (pull_request) Successful in 1m20s
`.github/labels.conf` named five identities and every one of them 404s on
this instance — a GitHub-shaped team that was never minted here. Both
consumers of that roster were inoperable in consequence: `panel=` becomes
the required-verdict set, so a review round could never converge, and
`triage-actors=` is the arrival author gate, so every issue was a stray
mint owing `needs-triage` that nobody the conf recognized could clear.

Measured on #191: stamped `needs-triage` four seconds after mint by the
arrival path working correctly, then unclosable for the rest of the day.

The mapping is @andres's ruling, recorded on #191. `cluade` holds triage
and reviews but does not build; `codex` builds; `kimi` reviews; `grok`
comes off the bench; the human row is `andres`.

CONTRIBUTING's roster table moves with the conf, including the approval
count: panel-minus-author resolves to two on this roster, not three,
because the only builder is itself on the panel. The rule is unchanged and
still stated as panel-minus-author — only the number it currently comes to
is named honestly.

test/labels.test.sh now holds the conf and the table to the same set in
both directions. It cannot reach the half that actually broke — two files
agreeing with each other and neither with the forge — but it does catch a
roster edit that touches one file and not the other, which is how a
deliberate swap becomes a silent one.

Not touched, deliberately: drills/*.md, which record runs that really
happened under the old names; REVIEWER.md, whose old-name hits are
citations and a past-event anecdote rather than roster definition.

Refs #195

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 19:09:16 +00:00
cndgrr
20e35b15da the release note says unblocked, not ready
D3b corrected the window flag to fire on `claimed` too, PR in flight or
not; the fragment still described it as a `ready` non-member, which is
the reading the ruling struck.
2026-08-04 19:03:24 +00:00
cndgrr
13605cf4b6 pin family scoping in the direction that carries it, and D3b on the window side
The family-scoping fixture asserted that a foreign marker does not
SILENCE the flag, which a family-blind grep satisfies too. The property
that is load-bearing is the other one: a foreign family late on the
thread must not make the flag re-post. Made family-blind, the sweep now
reds.

D3b says a claimed non-member is flagged whether or not it has an open
PR, and the fixtures covered that for the collision flag only — the
window side, which is the flag the 18:11Z correction was about, had no
case at all.
2026-08-04 19:00:48 +00:00
cndgrr
4f852ee152 the normalization fixtures fail for the reasons they are named for
check() matches its expectation as a substring, so a bare
`issueflow-reconcile` row was satisfied by `issueflow-reconcile.test`
too: the multi-extension rule stayed green under a normalization that
strips only the last extension. keys_of brackets each key, and the
no-em-dash row asserts its emptiness through grep rather than through an
expectation check() cannot make.

Beside them, the cases the two fixes are named for: a self-folding + title
answers its key once and never chains an issue to its own number, and
`needs-triage` and a label-less issue are outside both flags.
2026-08-04 18:58:21 +00:00
cndgrr
919135db12 the two flags mean one thing by unblocked, and a key set is a set
B1: deliverable_keys answered a multiset, so a `+` title whose segments
normalize to one key made collision_flags find the issue adjacent to
itself and chain it to its own number — the comment asked an issue to
declare `Blocked by` itself, and two such carriers corrupted the chain
between them. The keys are deduped where the set property belongs.

B2: window_in_scope excluded only blocked/epic/post-merge, admitting
`needs-triage` and label-less issues, so the sweep could add
`needs-triage` to an issue and then tell it about a mint-time membership
call in the same pass. Both flags now call one unblocked_claimable
predicate — #293 D2 corrected gives one gloss on `unblocked` and D3b
says D3 uses it.

The window log line says "unblocked", not "ready": D3b corrected exactly
that wording, and the flag fires on `claimed` too.
2026-08-04 18:54:52 +00:00
cndgrr
5d13573c53 fixtures take the corpus correction and the three ruled cases
Triage ruled all three questions (18:11Z) and corrected the corpus: at the
10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed`
with NO open PR — #285 was not created until 10:49:16Z. The morning board
takes that shape, which also makes the six `ready` non-members come out as
six, and puts the blocked twin on the board rather than only in a decision
probe.

D2's parenthetical is struck: unblocked = open and not blocked, both flags,
one definition. D3b is corrected in the same direction — a non-member
`claimed` WITH an open PR is flagged too, because a non-member holding a
builder and a review round is #292's competition realized, not a mild case
of it. Nothing in the implementation moves: neither flag ever consulted PR
liveness.

Three cases added, so the fixtures pin the rulings and not the prose:

- both carriers `claimed` with their own PRs open -> still flags. The
  ninety-three minutes from #285's creation to its merge are exactly when
  the struck parenthetical went silent on a live collision, which made the
  flag's firing a property of someone's workflow rather than of the board.
- a fifteen-member declaration with every member closed -> D3 dormant, and
  the release issue never flagged as its own non-member. A gate declaration
  never empties; the precondition is its OPEN members, read off the board.
- flagged -> resolved -> recreated unchanged -> silent, asserted as D4's
  stated boundary rather than left accidental.

Plus today's board — the `blocked` sink, a `claimed` gate member, two
`blocked` issues — which draws nothing. Verified live as well as in
fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real
board draws zero flags of either kind.
2026-08-04 18:21:56 +00:00
cndgrr
bdcc211a0b the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.

Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.

The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.

The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.

A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.

Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.

Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.

Closes #293.
2026-08-04 18:15:00 +00:00
cndgrr
90a35008a1 wip: the collision and window board flags, decisions and gather
Both checks ride the existing sweep walk and write nothing but comments
(#293 D1). The board read now answers the whole payload, because both
decisions are over the WHOLE board — every open issue's labels and title,
and every open release issue's body — and a second pagination for the same
rows would be a second board free to disagree with this one mid-sweep.

Fixtures still owed.
2026-08-04 18:06:03 +00:00
Daniel Marin
2807d34c74
Merge pull request #308 from andriujoseba/build/292-window-graph
docs: make standing release windows explicit graphs
2026-08-04 18:52:48 +01:00
Daniel Marin
b32d8253e7
Merge pull request #310 from dan-claude-bot/build/302-labeler-map-rows
fix: the labeler map learns lib/attention.sh — and the surfaces it never knew
2026-08-04 18:45:46 +01:00
Andriujose
d81b04148e docs: scope window insertion to immediate successors 2026-08-04 17:39:46 +00:00
dan-claude-bot
f5bac7c7ee fix: the labeler map learns lib/attention.sh and the surfaces it never knew (#302)
lib/attention.sh had #267 D4's premise exactly — both reconcilers source
it, nothing release-side does — and [scope:release-flow] alone was a
wrong answer of the class that decision exists to correct. The sweep
workflow pair joins beside its trigger pair (detached in #209), the
shared-lib tests take scope:labels alone (a test inherits no lib/**
glob), and D4's seven enumerated rows land one each. No catch-all, by
decision: both directories span all four scopes. Each of the 13 new map
rows is protected by its own assertion — deleted alone, each reds
exactly its case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 17:28:37 +00:00
Andriujose
ad5d988563 Merge remote-tracking branch 'origin/main' into build/292-window-graph 2026-08-04 17:28:26 +00:00
Daniel Marin
0c7d7e758c
Merge pull request #306 from cndgrr/build/304-reconcile-fixture-roster
fix(test): the labels-reconcile fixtures stop reading the live roster (#304)
2026-08-04 18:25:30 +01:00
Andriujose
630fd116c2 docs: distinguish window fan-out from collision chains 2026-08-04 17:23:03 +00:00
Andriujose
830a643a4b docs: make standing release windows explicit graphs
Add the mint-time membership call, the sink/source/subset invariants, and the release note while the contradictory successor-count criterion awaits triage clarification.\n\nCloses #292.
2026-08-04 17:12:48 +00:00
cndgrr
66a0eb5d65 test(labels-reconcile): fixture roster replaces the live panel by slot
The state-machine fixtures bound BOT1/BOT2/BOT3 to REQUIRED_BOTS by index off
the shipped .github/labels.conf, so three fixtures silently required a
four-member panel=. Shrinking it to three left the third slot unbound and
set -u aborted the file before assertion 1: 217 assertions became 0, on main
and on every branch cut from it.

The fixtures now write their own conf, in test/labels.test.sh's shape, at all
three load sites (top of file, the #205 re-drafted-round block, and the
mutant_blockers subshell). One live-file case survives as a property — the
shipped conf parses and recuses each member from its own panel — with no
index and no expected size, and a copy whose panel= names nobody proves it
still has teeth.

Refs #304
2026-08-04 17:04:09 +00:00
Daniel Marin
97cc00e268
Merge pull request #303 from dan-claude-bot/build/284-ruling-clock-comments-only
fix: the issue-side ruling clock reads comments only — an assignment is the claim's fact
2026-08-04 17:49:57 +01:00
Daniel Marin
404c4f6099
Update labels.conf 2026-08-04 17:39:45 +01:00
Daniel Marin
7e52e35596
Merge pull request #301 from andriujoseba/build/282-triage-slim
docs: slim TRIAGE.md incident narratives
2026-08-04 17:37:33 +01:00
Daniel Marin
b2a3e43ae4
Merge pull request #300 from cndgrr/build/267-labeler-scope
fix(labels): the scope map locates again — changelog.d/** is every PR
2026-08-04 17:37:20 +01:00
dan-claude-bot
48547d5eb1 fix: the issue-side ruling clock reads comments only (#284)
Claiming a needs-ruling issue dated it through the assigned timeline
event, silencing the 7-day escalation nudge at exactly the moment a
builder started working through it. The ruling block now reads
last_issue_comment_activity (D1); the reclaim clock keeps the assignment
(D2) because there the assignment IS the claim; post-merge hands its
evidence read to the ruling block instead of reading again (D6, D7); the
claimed branch reads both clocks at its top, before anything it posts.
LABELS.md and lib/ruling.sh now say what each surface's clock reads
(D4, D5). The #257-era order compositions move to the comments read —
the timeline is no longer an input the issue clocks take, and its
unreadability no longer holds unrelated writes hostage; that narrowing
is pinned rather than implied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 16:16:19 +00:00
github-actions[bot]
80d4b9cbca chore: bump main to 0.4.2-dev — a dev install must not impersonate 0.4.1 2026-08-04 16:09:41 +00:00
0003fa1d1c Merge pull request 'release: 0.4.1' (#190) from release/0.4.1 into main
All checks were successful
CI / test (push) Successful in 1m29s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 6s
CI / action-exercise (push) Successful in 5s
CI / docs-sync-exercise (push) Successful in 5s
release / release (push) Successful in 11s
Reviewed-on: #190
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
Reviewed-by: grok-reviewer-andresmgsl <andres+3@heavyduty.builders>
2026-08-04 16:07:39 +00:00
9d816293ee Merge main into release/0.4.1 — carry #191's doors and #194's drill record
All checks were successful
CI / test (pull_request) Successful in 1m29s
CI / release-exercise (pull_request) Successful in 10s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 5s
CI / docs-sync-exercise (pull_request) Successful in 6s
labels / labels (pull_request) Successful in 1m28s
The section is re-assembled: main's merge base now carries two fragments
(188.md and 191.md), so 0.4.1 publishes with the door port it actually
ships, and neither fragment survives its own release.

drills/0.4.1.md takes main's two-run record, with run 1's disclosures
restored — the scratch repo it used, its candidate ref, and the ~8 minutes
it spent public to read job logs. A later success does not retire a
disclosed deviation.
2026-08-04 16:00:42 +00:00
1ddefe79d9 Merge pull request 'drills/0.4.1.md — the post-merge rehearsal passed; record both runs (#191)' (#194) from build/191-drill-record into main
All checks were successful
CI / test (push) Successful in 1m30s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 5s
CI / action-exercise (push) Successful in 5s
CI / docs-sync-exercise (push) Successful in 5s
release / release (push) Successful in 6s
Reviewed-on: #194
Reviewed-by: grok-reviewer-andresmgsl <andres+3@heavyduty.builders>
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
2026-08-04 15:58:46 +00:00
cndgrr
abe31a6d4a test(labels): assert each guard row alone, not bundled with its sibling
The D3 guard loop passed the action path and its test path to derives in
one call. derive_labels emits scope:guards when either matches, so any one
of the six rows could be deleted with the case still green — three
assertions standing in for six rows. Split into one assertion per path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:56:55 +00:00
Andriujose
f3125631b9 docs: keep answer outcome explicit 2026-08-04 15:51:16 +00:00
Andriujose
de3ab517d7 docs: slim triage doctrine 2026-08-04 15:48:27 +00:00
Daniel Marin
fe45180616
Merge pull request #299 from andriujoseba/build/257-board-read
fix: abort on unreadable issue board
2026-08-04 16:43:32 +01:00
cndgrr
5356702817 docs(changelog): fragment for #267 2026-08-04 15:34:17 +00:00
cndgrr
0fe015da69 fix(labels): the scope map locates again
changelog.d/** matched every PR that changes behavior, so scope:release-flow
was a constant, not a locator (#267).
2026-08-04 15:33:39 +00:00
d089ab57b3 drill(0.4.1): probes 2 and 4 ran — all six probes now have live results (#191)
All checks were successful
CI / test (pull_request) Successful in 1m29s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 5s
CI / docs-sync-exercise (pull_request) Successful in 5s
labels / labels (pull_request) Successful in 1m35s
Codex (!194 #1588) is right on the text: #191's criterion is a drill that
runs all six probes with 1 and 5 passing, not two probes passing and four
recorded. Probe 2 (mislabeled ordinary PR) and probe 4 (a re-run of the
completed ceremony) were run on the same consumer, un-archived for them and
archived again after.

Probe 4 diverges in mechanism because Forgejo 8.0.3 has no run-rerun API:
the ceremony was re-run by reproducing its input rather than replaying the
run. The record says so, and says which assert refused.
2026-08-04 15:30:55 +00:00
Daniel Marin
5fd1c1b014
Merge pull request #295 from cndgrr/build/281-builder-slim
docs(builder): BUILDER.md slims to the rules — narratives become bare local cites
2026-08-04 16:27:06 +01:00
4057c59354 drill(0.4.1): the post-merge rehearsal passed — record both runs
All checks were successful
CI / test (pull_request) Successful in 1m29s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 5s
CI / docs-sync-exercise (pull_request) Successful in 5s
labels / labels (pull_request) Successful in 1m33s
#191's last acceptance criterion was a live drill against the MERGED tree,
not the candidate. Run against `fda5657`:

  probe 1  merge door   one release 0.4.1, changelog body, main re-armed
                        to 0.4.2-dev, both assets uploaded
  probe 3  no label     refused, nothing created
  probe 5  tag door     0.5.0 published, main VERSION untouched
  probe 6  bad tag      refused, nothing created

The fixture carried an artifact hook this time, dropping `drill asset.tgz`
and `a&b.tgz`. Both survived under those exact names — the encoding fix
proven end to end, in the place it would have failed: after the tag exists,
mid-publish.

The record keeps run 1 (the failure at 9a229ee) beside run 2, because the
failure is why #191 exists and a record that quietly replaced it would be
the kind of tidy history this repo refuses.

Refs #191

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:06:53 +00:00
Andriujose
353fa54ae1 fix: abort on unreadable issue board 2026-08-04 15:03:25 +00:00
fda5657285 Merge pull request 'lib/facts.sh + release.yml — the release doors speak the shim, and an unread fact refuses (#191)' (#193) from build/191-release-door-facts into main
All checks were successful
CI / test (push) Successful in 1m29s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 5s
CI / action-exercise (push) Successful in 5s
CI / docs-sync-exercise (push) Successful in 5s
release / release (push) Successful in 6s
Reviewed-on: #193
Reviewed-by: grok-reviewer-andresmgsl <andres+3@heavyduty.builders>
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
2026-08-04 14:58:29 +00:00
Daniel Marin
b7005ee9cd
Merge pull request #298 from andriujoseba/build/288-collision-edge
docs: declare collision-edge chains
2026-08-04 15:56:19 +01:00
cndgrr
b44f7ed6ee Merge branch 'main' into build/281-builder-slim 2026-08-04 14:47:38 +00:00
Andriujose
90961cad51 docs: declare collision-edge chains 2026-08-04 14:27:49 +00:00
Daniel Marin
1a9fdde8fc
Merge pull request #297 from andriujoseba/build/266-task-list-heading
docs: name the epic task-list heading
2026-08-04 15:20:56 +01:00
cndgrr
64e3af682f Merge branch 'main' into build/281-builder-slim 2026-08-04 14:13:38 +00:00
cndgrr
92c0e7c6f0 docs(builder): bind the red-head trigger to the round's ruled terms
The red-head rule in Picking carried its own trigger definition at the
pre-slim head; the squeeze took it, leaving 'failing check' and 'red
check' classified only forward in the review round. At an all-cancelled
head that let parked shape 2 read satisfied on its face, parking a claim
the rule says is never parked (#163, #276).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 14:13:34 +00:00
Andriujose
08b817b814 docs: name the epic task-list heading 2026-08-04 13:42:24 +00:00
cndgrr
49e1fe2e6e docs(builder): give step 1 its paragraph breaks back; fix two doubled connectives 2026-08-04 13:40:51 +00:00
cndgrr
900963d653 docs(builder): name the subject in the staleness-sweep clause 2026-08-04 13:40:08 +00:00
Daniel Marin
f899bb5690
Merge pull request #296 from andriujoseba/build/264-flagging-unassigned
docs(triage): scope no-assignee bug to flagging
2026-08-04 14:39:59 +01:00
cndgrr
43d845575a docs(builder): state #276's collapse rule in full, not by allusion 2026-08-04 13:39:06 +00:00
cndgrr
4800aef363 docs(builder): restore the re-request deferral and three clauses the audit found 2026-08-04 13:36:41 +00:00
cndgrr
b4779bbc18 docs(builder): keep code spans unbroken across the wrap 2026-08-04 13:32:36 +00:00
cndgrr
46aeb02078 docs(builder): restore five rules the squeeze had compressed away 2026-08-04 13:31:48 +00:00
cndgrr
4100121c7a docs(builder): final squeeze — 272 lines, every D2 rule intact 2026-08-04 13:30:12 +00:00
cndgrr
cef7ea206b docs(builder): WIP — proposal biography and list scaffolding out 2026-08-04 13:27:52 +00:00
cndgrr
e1c3e3e9af docs(builder): WIP — terse register throughout 2026-08-04 13:23:56 +00:00
cndgrr
1b6143f4aa docs(builder): WIP — drop optional whys, classes as a list 2026-08-04 13:21:26 +00:00
cndgrr
92ff82c1c6 docs(builder): WIP — minimal-statement register 2026-08-04 13:18:31 +00:00
Andriujose
5ea59780b6 docs(triage): scope no-assignee bug to flagging 2026-08-04 13:16:58 +00:00
cndgrr
d25cbb04b7 docs(builder): WIP — further compression, ruling ladder tightened 2026-08-04 13:16:37 +00:00
cndgrr
e3ec95bb37 docs(builder): WIP — tighter register across every section 2026-08-04 13:14:35 +00:00
Daniel Marin
4e0c5c2568
Merge pull request #294 from andriujoseba/build/291-terminal-fragment-cites
docs: add terminal citations to 0.6.0 fragments
2026-08-04 14:14:23 +01:00
cndgrr
6ebdd2efd9 docs(builder): WIP — slim BUILDER.md to the rules, bare local cites 2026-08-04 13:11:32 +00:00
Daniel Marin
acf4125fd6
Merge branch 'main' into build/291-terminal-fragment-cites 2026-08-04 14:11:20 +01:00
Daniel Marin
2e11855839
Merge pull request #289 from cndgrr/build/262-fragment-cite
feat(changelog): the terminal issue cite joins the fragment guard
2026-08-04 13:58:32 +01:00
Andriujose
7af13eae73 docs: add terminal citations to release fragments 2026-08-04 12:53:38 +00:00
cndgrr
9f1f88de72 fix(changelog): #285's fragment carries a terminal cite
changelog.d/253.md landed on main after this branch's point with the
cite trailing the period — the crew#309 shape, the fifth fragment to
arrive with it since #262 measured two. The criterion is that
changelog.d/ is clean at the head, and the head CI reviews is the merge
with main, which is where this one surfaced.

Refs #262.
2026-08-04 12:40:55 +00:00
cndgrr
d13f61b6c5 merge: origin/main — #285's fragment lands under the rule
The self-guards job checks out the merge of this branch with main, so
changelog.d/253.md from #285 reaches changelog_fragment_problem there and
nowhere else; the branch alone is green. Merged rather than rebased
because the round's reviewers track head SHAs.

Refs #262.
2026-08-04 12:39:55 +00:00
cndgrr
75a5b68c8a fix(changelog): one fragment, one diagnosis, wherever the long entry sits
awk runs END on the way out of an exit from a main rule, so the length
row printed mid-file was followed by the citation row it outranks — the
internal protocol line landing inside the human-facing excerpt. Found by
claude-bot and kimi-bot in #262's first round, independently and with the
same reproduction.

The guard is the reported flag the empty-heading walk in this same
predicate already uses. The fixtures are the axis 57.md could not reach:
its over-bound entry is last, so only END's flush can print. 58.md puts
one before another bullet, 59.md before a heading and after a misplaced
cite. With lib/changelog.sh alone reverted they red, which is what the
green suite was hiding.

Refs #262.
2026-08-04 12:38:13 +00:00
Daniel Marin
d30b091872
Merge pull request #285 from andriujoseba/build/253-release-init-announce
feat: announce release initialization
2026-08-04 13:22:21 +01:00
cndgrr
84c73fe22e test(changelog): the precedence case runs in the order that can fail
Refs #262
2026-08-04 12:16:49 +00:00
cndgrr
175e082c1b test(release-exercise): the replay's fragment fixture carries a cite
The step-replay job builds a consumer tree and runs the REAL assembler
over it, so its changelog.d/42.md is a fragment fixture like every one in
test/ — and the only one living outside it. #262's diff-surface criterion
says no workflow file; the criterion and a green head cannot both hold
here, and the fixture is the smaller thing to move.

Refs #262
2026-08-04 12:15:04 +00:00
cndgrr
8aeea67d8d docs(changelog): the citation is guard-enforced, not house style
Closes #262
2026-08-04 12:12:52 +00:00
cndgrr
f4cb970097 test(changelog): the cite rule's own cases, both callers asserted
Refs #262
2026-08-04 12:11:09 +00:00
ca99182e80 fix(forge): percent-encode asset names, and stop the docs naming a client
All checks were successful
CI / test (pull_request) Successful in 1m29s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 5s
CI / action-exercise (pull_request) Successful in 5s
CI / docs-sync-exercise (pull_request) Successful in 5s
labels / labels (pull_request) Successful in 1m33s
Both findings are @codex's on !193 (#1583), and both are real.

The asset name travels as a QUERY VALUE, and the artifact-hook contract
permits any file the consumer drops in RELEASE_ASSETS_DIR. Raw
interpolation meant `release asset.tgz` made curl reject the URL outright
(exit 3), and '&', '#', '+', '%' silently changed the name or the query's
shape. `gh release create` handled all of those, so a 1:1 port had to.

Encoded through one boundary — jq's @uri, since jq is already a hard
dependency of this backend and a hand-rolled sed class is how the next
unescaped character gets through. Six backend cases cover it: the encoder
on a space and on the delimiters, uploads under both names, the created
release id in the path, and the multipart attachment. Mutation-checked:
dropping the encoder fails exactly the two name assertions.

docs/CONSUMERS.md's artifact-hook recovery still told operators to "run
`gh release create` by hand" and described the hook as running "before
`gh release create`" — on a Forgejo runner that is precisely the failure
this PR fixes. It now names the forge-neutral tag-door recovery first and
shows both clients for the manual path, without regressing the GitHub
guidance.

1035 assertions, 22 suites, shellcheck-all and actionlint clean.

Refs #191

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 12:11:06 +00:00
cndgrr
ef818001d0 test(changelog): every fragment fixture carries a terminal cite
Scoped to the fixtures the new rule actually binds: a fragment whose
predicate complaint is already its name, a smuggled heading, a dangling
heading or the 300-character bound is left alone, so the diagnosis it
tests is still the one it draws. The section-predicate fixtures are
untouched (D4).

The computed-length fixtures keep their measured lengths: the cite is
seven characters, so an entry that must measure exactly 300 builds 293
of the run and lets the cite carry the rest. 33.md's cite lands on the
last continuation line, which is the wrapped-citation case.

Refs #262
2026-08-04 12:08:54 +00:00
cndgrr
c40d9412f6 wip(test): fixture sweep, first pass — over-reaches onto section fixtures
'- Fixed entry.' is shared between the dangling-heading fragment fixture
and the section-predicate fixture, so the global replace crossed D4's
line. Next commit filters to fragments that actually red on the cite rule.

Refs #262
2026-08-04 12:06:29 +00:00
cndgrr
46b80fb6b2 fix(changelog): the four drifted fragments carry a terminal cite
Refs #262
2026-08-04 12:01:45 +00:00
cndgrr
72fa3e0b4d feat(changelog): the terminal issue cite joins the fragment guard
Refs #262
2026-08-04 12:00:48 +00:00
Daniel Marin
101f7bd309
Merge pull request #286 from cndgrr/build/276-checks-collapse
docs(builder): a displaced predecessor is not the check's verdict
2026-08-04 12:53:51 +01:00
21c70e06a4 fix(forge): an empty REPO cannot become a fact, and the backend verbs are tested
All checks were successful
CI / test (pull_request) Successful in 1m28s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 5s
CI / docs-sync-exercise (pull_request) Successful in 5s
labels / labels (pull_request) Successful in 1m31s
Both panel blockers on c63a550.

@kimi found the one that mattered: facts.sh got the REPO fix, release.yml's
own four call sites did not. A workflow `run:` shell carries no `set -u`, so
an unset REPO expands empty and the verb addresses `repos//…` — which 404s,
and the 404 is then read as an ANSWER. Reproduced read-only against this
instance before fixing:

  forge_release_exists 0.4.1   -> "no", rc 0
  forge_commit_pulls 7fc9afe4  -> "[]", rc 0    (the !189 merge, which HAS a
                                                 merged PR behind it)

The first would have let the nothing-exists assert proceed to CREATE; the
second is the drill's original fabricated `labeled=no`, one step after the
fix meant to kill it.

Fixed once rather than at four call sites, as kimi suggested: forge_select
defaults REPO from GITHUB_REPOSITORY, and forgejo_api_base — which every
verb reaches the network through — refuses an empty REPO outright. No fifth
call site can forget it.

@grok and @kimi both blocked on the same AC gap: the backend suite did not
cover the five new verbs, so the two measured asymmetries had no offline
coverage. test/forge-backends.test.sh now has 15 cases for them — singular
/pull wrapped to an array, 404 as an empty array, 500 refusing, release
present/absent/unreadable, POST /tags vs /git/refs, the publish body, and
the REPO-empty must-fail. Mutation-checked: reading the plural path fails
one case, dropping the REPO guard fails the two must-fails.

1029 assertions, 22 suites, shellcheck-all and actionlint clean.

Refs #191

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:53:28 +00:00
Andriujose
994aeb58aa fix: cite release doctrine in both layouts 2026-08-04 11:43:44 +00:00
c63a55067e fix(exercise): pin the rehearsal to the backend its stub speaks
All checks were successful
CI / test (pull_request) Successful in 1m27s
CI / release-exercise (pull_request) Successful in 9s
CI / self-guards (pull_request) Successful in 5s
CI / action-exercise (pull_request) Successful in 4s
CI / docs-sync-exercise (pull_request) Successful in 5s
labels / labels (pull_request) Successful in 1m26s
release-exercise stubs `gh` to answer the one API fact the ceremony path
consults. Since #191 facts.sh selects a backend, and on this Forgejo runner
it selected the forgejo one — which speaks curl and walked straight past the
stub to the live instance, read the fixture's SHA against the real
repository, found no merged release-labeled PR behind it and refused.

The exercise rehearses the WIRING — facts → decide → notes through the real
$GITHUB_OUTPUT plumbing. Which backend answers is lib/forge.sh's own
contract and is covered in test/forge*.test.sh. So the facts step now pins
CEREMONY_FORGE=github, the backend its stub is shaped for, and the stub
returns the array shape the new label read expects.

Refs #191

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:42:28 +00:00
87cc7d5aa5 fix(forge): facts.sh must set REPO, and the fragment must fit the bound
Some checks failed
CI / test (pull_request) Successful in 1m29s
CI / release-exercise (pull_request) Failing after 10s
CI / self-guards (pull_request) Successful in 6s
CI / action-exercise (pull_request) Successful in 5s
CI / docs-sync-exercise (pull_request) Successful in 5s
labels / labels (pull_request) Successful in 1m24s
Two failures on !193's first run, both real and both caught by the guards
that exist for them.

release-exercise: `lib/forge-forgejo.sh: line 550: REPO: unbound variable`.
The forgejo backend addresses the repository through REPO, which each
reconciler sets for itself; the github backend reads GITHUB_REPOSITORY
directly. facts.sh set neither, so every forgejo read refused — correctly,
and with the new #191 diagnostic, which is how it was legible at all. The
github-path suites could not have caught this: they never touch that
backend.

self-guards: changelog-armed measured a 404-character entry against the
300-character bound (#167). Split into three shorter entries in the same
fragment, which is what the rule asks for.

Refs #191

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:37:00 +00:00
957f72739d fix(forge): the release doors speak the shim, and an unread fact refuses (#191)
Some checks failed
CI / test (pull_request) Successful in 1m27s
CI / release-exercise (pull_request) Failing after 10s
CI / self-guards (pull_request) Failing after 5s
CI / action-exercise (pull_request) Successful in 5s
CI / docs-sync-exercise (pull_request) Successful in 5s
labels / labels (pull_request) Successful in 1m24s
The 0.4.1 drill measured both doors dead on Forgejo. lib/facts.sh gathered
`released` with `gh release view` and `labeled` with `gh api .../pulls`, and
release.yml tagged and published with `gh` — none of which exist on the
runner image. The merge door therefore read labeled=no for a correctly
labeled, correctly merged ceremony PR and refused it as "a bare push";
the tag door cleared every gate and died at `gh release create`.

Both are ported onto lib/forge.sh. Two asymmetries were measured against
the live instance and its swagger rather than assumed:

  * GitHub serves an ARRAY of PRs at /commits/{sha}/pulls; Forgejo serves a
    single OBJECT at /commits/{sha}/pull and 404s on the plural. Both verbs
    emit the array shape, so facts.sh carries one jq expression.
  * GitHub creates a tag by POSTing to /git/refs; Forgejo serves that path
    GET-only and creates tags at /tags. A 1:1 port of the gh call would
    have 404'd forever.

The behaviour change is the second half of the bug. Any failure used to
become a definite `no`, which is safe for row 4 and catastrophic for row 5:
it is how a missing binary became "this was not a release ceremony". Now a
completed read that finds nothing is still `no` and still fail-closed, and a
read that did not complete refuses and emits no fact at all.

Four new cases in test/facts.test.sh cover exactly that, and a mutation back
to the old fail-closed-on-error behaviour kills all four and nothing else.
1014 assertions, 22 suites, shellcheck and actionlint clean.

Refs #191

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:31:06 +00:00
cndgrr
4c8398a15d docs(builder): the collapse rule, without the incident
Round 1 on #286: codex, claude and kimi each blocked on the same thing —
the amended paragraph carried incident narrative that CONTRIBUTING.md's
doctrine conventions keep in the cited record.

Drop #275's head sha and both start stamps, the cancel-in-progress
episode sentence, and the four clauses of 2026-07-27 gloss provenance.
Carry checks_state's carve-out as one clause with a bare (#139, #276).

The rule is unchanged and still decides #275's head unaided: two entries
for one check, newest by start time wins, and the cancelled one is not
the check's word while a non-cancelled sibling stands.
2026-08-04 11:29:13 +00:00
Andriujose
0bbdb2c6e2 test: mark portable citation literal 2026-08-04 11:19:59 +00:00
Andriujose
e7df33b277 fix: make release init citation portable 2026-08-04 11:17:51 +00:00
cndgrr
b2313beae8 docs(builder): a displaced predecessor is not the check's verdict
Step 1 ruled what a rollup entry means and never said which entry to
read. A job in a cancel-in-progress group displaces itself, so a head
routinely carries a CANCELLED node beside the SUCCESS that replaced it,
and read by class alone that head is not green while checks_state calls
it SUCCESS.

State the collapse ahead of the classes it feeds: newest entry by start
time, and a CANCELLED entry is not the check's word while a non-cancelled
sibling stands at the same head. All-cancelled and pending are untouched.

Refs #276
2026-08-04 10:58:59 +00:00
Daniel Marin
017c571438
Merge pull request #277 from cndgrr/build/251-vendored-manifest
feat(guards): the tag declares its vendorable set
2026-08-04 11:52:15 +01:00
Andriujose
f300f60d6e test: keep release init probes shellcheck clean 2026-08-04 10:50:55 +00:00
Andriujose
e3b4b2cdfd feat: announce release initialization 2026-08-04 10:48:55 +00:00
Daniel Marin
80b9eaf6e7
Merge pull request #283 from andriujoseba/build/280-doctrine-conventions
docs: define vendored doctrine conventions
2026-08-04 11:44:29 +01:00
abb1c818a3 drill(0.4.1): record the rehearsal — both doors are inoperable here
All checks were successful
CI / test (pull_request) Successful in 1m26s
CI / release-exercise (pull_request) Successful in 11s
CI / self-guards (pull_request) Successful in 5s
CI / action-exercise (pull_request) Successful in 5s
CI / docs-sync-exercise (pull_request) Successful in 5s
labels / labels (pull_request) Successful in 1m45s
The drill ran and FAILED, and the record says so. Merge door: lib/facts.sh
reads the release label with `gh api`, the runner image has no gh, the fact
comes back "no", and decide fail-closes on a wrong fact — reproduced twice.
Tag door: clears every gate the merge door fails, then dies at
`gh release create`.

Release count in the scratch repo at the end: 0. Every refusal created
nothing, which is the property the drill exists to check.

drill-recorded wants a record, not a passing result — this is the honest
one, and it says 0.4.1 cannot publish from this instance until facts.sh and
the publish call sites are ported off gh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 10:44:02 +00:00
Daniel Marin
57ff445341
Update labels.conf 2026-08-04 11:38:54 +01:00
Daniel Marin
0b5185aa28
Update labels.conf 2026-08-04 11:33:16 +01:00
cndgrr
42b8fb6850 fix(consumers): the manifest is readable at 0.1.0, not 0.5.0
docs/VENDORED.txt and actions/docs-sync/docs-sync.sh entered the tree in
the same commit and are byte-identical at every tag — blobs 10c20a3c and
ba426479 at 0.1.0 through 0.5.0 — and 0.1.0's copy of the tool is already
manifest-driven (MANIFEST="docs/VENDORED.txt", L75). Citing 0.5.0 told the
0.1.0-0.4.1 tail, which is exactly the population this section is written
for, that the manifest was unavailable at its pin, so it would keep the
hardcoded list: #251's failure mode reproduced by the document that exists
to abolish it. The same file already said 0.1.0 at L131-L133.

Also make the guard's tracked-ness skip announce itself. It degrades to
"not asserted" wherever the tree is not a git work tree root, and doing
that in silence is the shape this script's own header argues against, so
the skip now prints on both output paths, green and red, with a test row
each way.

Round 1: claude blocking point, and claude nit 3.
2026-08-04 10:29:44 +00:00
cndgrr
b9eedd8973 docs(consumers): cite the manifest availability to the tag that shipped it
0.5.0 availability is actions/docs-sync own arrival (#19); #251 is the
guidance and the guarantee, not the file date.

Refs #251
2026-08-04 10:18:17 +00:00
cndgrr
7909383ca0 docs(consumers): read the pin manifest, never a copy of it
Re-vendor tooling and docs-sync equivalents read the pin docs/VENDORED.txt
(available at 0.5.0 and later) instead of naming the doc set themselves, so
a new doctrine file reaches every consumer at its next ordinary pin bump
with zero list edits. A hardcoded list propagates nothing and its
staleness is silent: docs-sync --check asserts byte-identity for the files
the list names and says nothing about one it omits.

What makes reading the manifest sufficient rather than merely better is
the self-guard this PR adds, tagged unreleased until the first tag carries
it, per the RELEASES.md paragraph above it.

Refs #251
2026-08-04 10:18:17 +00:00
Andriujose
6949f8cbdc docs: define doctrine conventions 2026-08-04 10:17:30 +00:00
cndgrr
5677710b2c test(guards): the manifest guard drives the whole test plan
One CI step beside the self-ref pin, and test/vendored.test.sh covering
both directions: the manifest -> tree scan (missing, symlink, directory,
empty, ../ escape, absolute, untracked) and the closed-world root rule
(neither list, vendored, exempted, prose is not an input, no recursion
below the root), plus the real tree unmodified and the RELEASES.md
regression both ways.

The one-off `grep -Fx RELEASES.md` row at test/docs-sync.test.sh is
deleted (#251 D4): two spellings of "the manifest is right" is the drift
the manifest exists to prevent. Its intent is now a guard case, which the
next doctrine file inherits for free.

Refs #251
2026-08-04 10:16:27 +00:00
cndgrr
0760d0c21f feat(guards): the vendored manifest guards ceremony own tree
docs/VENDORED.txt is already machine-authoritative on the consumer side —
actions/docs-sync reads it as the sole declaration and enforces
"manifest union .ceremony/, nothing else". Nothing enforced the other end:
a doctrine file landing at ceremony every root with nobody adding it to
the manifest is a SILENT miss, because docs-sync only asserts
byte-identity for the files the manifest names.

Two directions, two mechanisms (#251 D2): manifest -> tree is a scan
(regular, non-empty, tracked, no symlink, no directory, no .. escape);
tree -> manifest is a closed-world rule over root *.md with a short
in-script exemption list, because nothing in the tree answers "which
files are vendorable".

Refs #251
2026-08-04 10:16:16 +00:00
Daniel Marin
05738ff0dd
Merge pull request #273 from andriujoseba/build/238-marker-check
feat: guard unreleased documentation markers
2026-08-04 11:14:01 +01:00
9a229ee643 release: stamp 0.4.1
Some checks failed
CI / test (pull_request) Successful in 1m25s
CI / release-exercise (pull_request) Successful in 9s
CI / self-guards (pull_request) Failing after 5s
CI / action-exercise (pull_request) Successful in 4s
CI / docs-sync-exercise (pull_request) Successful in 4s
labels / labels (pull_request) Successful in 1m15s
The three stamps, in one commit as the ceremony requires: VERSION goes
bare, the self-ref pin follows it, and the changelog section is the
assembler's output rather than a hand edit.

0.4.1 is the forge release. Everything in the section comes from #188's
single fragment: ceremony stops being gh-only. `lib/forge.sh` selects a
backend from the runner's own environment, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` implement one call surface twice, and the
reconcilers preflight before they sweep — so a GitHub-shaped client
pointed at a Forgejo instance is a named refusal instead of a sweep that
reads nothing and reports success.

Verified on this instance before stamping: the first post-merge `labels`
run on main (7fc9afe, task 467) came back SUCCESS — the reconciler's
first green run on Forgejo, and the evidence the section's claims are
not merely asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 10:01:21 +00:00
Andriujose
e98020b432 test: document marker fixture incidents 2026-08-04 09:57:51 +00:00
7fc9afe45f Merge pull request 'actions/* + lib/* — one forge abstraction, two backends (#188)' (#189) from build/188-forge-preflight into main
All checks were successful
CI / test (push) Successful in 1m25s
CI / release-exercise (push) Has been skipped
CI / self-guards (push) Successful in 5s
CI / action-exercise (push) Successful in 4s
CI / docs-sync-exercise (push) Successful in 4s
release / release (push) Successful in 5s
Reviewed-on: #189
Reviewed-by: grok-reviewer-andresmgsl <andres+3@heavyduty.builders>
Reviewed-by: codex-reviewer-andresmgsl <andres+2@heavyduty.builders>
Reviewed-by: kimi-reviewer-andresmgsl <andres+4@heavyduty.builders>
2026-08-04 09:52:22 +00:00
Daniel Marin
4a9f113ef3
Merge pull request #274 from cndgrr/build/254-postmerge-nudge
feat(issueflow): the post-merge evidence nudge
2026-08-04 01:35:08 +01:00
Daniel Marin
58a8d308e4
Merge pull request #275 from dan-claude-bot/build/260-272-green-conclusion
docs(builder): green is read from conclusion, and the checkless head is the third ruled case
2026-08-04 01:34:52 +01:00
dan-claude-bot
806e99e1cb docs(builder): green is read from conclusion, and the checkless head is the third ruled case
The ruled-term paragraph now names its field — a check carrying a
terminal conclusion is green or not-green by that conclusion whatever
its status reports — and rules the head with no checks configured:
nothing to wait for, request straight away, no argued exception owed.
The draft-round restatement comes out so the file states the rule once.

Closes #260, closes #272 via the PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 23:44:43 +00:00
cndgrr
3fffb8d849 fix(issueflow): the nudge says what it measured
"no activity for N days" was true of the old clock and is now imprecise:
an assignment no longer counts, so an item assigned yesterday would read
a nudge claiming nine days of nothing. It says "no comment" instead —
the fact the sweep actually read.

Refs #254
2026-08-03 23:42:19 +00:00
cndgrr
fd9da98abf docs(labels): the evidence clock names what the sweep actually reads
"a comment, a review or a commit" is the ruling nudge's house phrasing and
false on the issue surface twice over: there is no review or commit fact in
what the sweep reads, and an assignment is no longer counted here. Say
what is read, and say what does not buy another 7 days of silence.

Refs #254
2026-08-03 23:40:48 +00:00
cndgrr
d4a82707f2 fix(issueflow): the evidence clock is not the claim clock
The nudge rode `last_issue_activity`, which counts `assigned` timeline
events because assignment is the claim the 48-hour reclaim protects.
`post-merge` has no claim: an assignee there is the invalid composition
the flag beside it reports, so counting the assignment let a broken board
buy the item another 7 days of silence — this issue's failure direction
taken backwards.

One computation, two clocks over it: `issue_activity_at` is the body,
`last_issue_activity` keeps the reclaim and ruling clocks byte-identical,
and `last_issue_comment_activity` is the evidence clock. Both clocks are
read before this branch posts anything, the ruling one included — read
after, it would date the issue by the evidence nudge's own comment and
silence the ruling nudge, which is the self-silencing the branch already
guarded against in the other direction.

Refs #254
2026-08-03 23:38:53 +00:00
cndgrr
37d138fecf test(issueflow): prose is never judged, and the constant has one spelling
The two 'must fail loudly' cases from the plan: an unparseable body still
nudges and the nudge quotes none of it, and a grep-level pin that no second
7-day constant appears in the sweep.

Refs #254
2026-08-03 23:10:42 +00:00
cndgrr
b18168b4f3 docs(labels): quiet post-merge is visible, still never reclaimed
LABELS.md said the sweep never reclaims post-merge and stopped there, which
now reads as 'the machine says nothing' — after this change it says one
thing, once per 7 quiet days. Plus the fragment.

Refs #254
2026-08-03 23:08:11 +00:00
cndgrr
b3fd26b21b test(issueflow): the evidence nudge's fixtures — window, addressee, no marker
Covers the must-nudge pair (8 quiet days; post-merge + needs-ruling both
speak), the must-not set (6 days, fresh comment, every other queue state),
self-rate-limiting proven by sweeping again a day later rather than by
asserting a marker's absence, and zero writes across every probe.

Two existing probes move: #36's 'no comment' assertion described the
starvation this issue ends, and #67 gets recent activity so its precedence
count stays the assertion doing the work.

Refs #254
2026-08-03 23:06:47 +00:00
Andriujose
2a1f8a012b test: pin inline marker mention boundary 2026-08-03 23:05:05 +00:00
cndgrr
63351bd425 feat(issueflow): post-merge evidence nudge fires on 7 quiet days
WIP checkpoint: the nudge itself, tests still owed. Reuses
ruling_nudge_decision so the 7-day rule keeps one spelling, addresses the
triage actor (post-merge is triage's completion queue), and carries no
idempotency marker — the comment is itself activity, so it self-rate-limits.

Refs #254
2026-08-03 23:03:53 +00:00
Andriujose
d87d76d64b fix: align marker guard with release oracle 2026-08-03 22:56:02 +00:00
Daniel Marin
c7f40bb818
Merge pull request #270 from cndgrr/build/258-fix-round-draft
docs(builder): a fix round may ride a draft
2026-08-03 23:55:44 +01:00
Andriujose
7200c8da49 docs: define traceable availability markers 2026-08-03 22:51:17 +00:00
Andriujose
dca8e7220c test: exercise documentation marker guard 2026-08-03 22:49:22 +00:00
Andriujose
461c25b08e wip: add unreleased marker guard 2026-08-03 22:47:55 +00:00
Daniel Marin
1b84d27691
Merge pull request #269 from andriujoseba/build/237-doors-unchanged
docs: define doors-unchanged drill evidence
2026-08-03 23:40:32 +01:00
cndgrr
349fb4d964 docs(builder): crew#139 is an open proposal, and the CI cost is what it would end
Both occurrences said crew's engine already converts a PR back to draft at
round close; crew#139 is OPEN, blocked and unassigned, so the passage was
describing an unshipped mechanism as live (codex, kimi). The same sentence
also read as if the conversion caused the CI spend — it is the status quo
the conversion would end, so the counterfactual is now explicit. Rewrapped
the paragraph at 80 columns; 7948b99 had left one line at 133.
2026-08-03 22:27:42 +00:00
Andriujose
e728612ea1 test: resolve sibling release dependencies 2026-08-03 22:12:49 +00:00
cndgrr
3367cae4d4 docs(reviewer): one vocabulary across the two files — mid-round saves
Refs #258
2026-08-03 21:52:44 +00:00
cndgrr
7948b99acf docs(builder): unknot the opening sentence
Refs #258
2026-08-03 21:51:48 +00:00
cndgrr
dbbdbdb6dd docs(builder): cite what this file carries, not what crew's engine does
The first paragraph referred to a 15-minute cadence and a checkpoint
discipline, neither of which BUILDER.md states — the cadence is crew's
engine rule and the pointer sent a reader to a section that says nothing
about it. Attribute the measurement to crew#139 and point at what
Building actually says. The LABELS.md sentence stops restating the
state:building row's condition and points at it instead: one rule in two
voices, per #258's test plan.

Refs #258
2026-08-03 21:51:25 +00:00
cndgrr
6634a517bc docs(builder): a fix round may ride a draft
BUILDER.md's review round assumed ready-throughout, so a builder or
reviewer meeting a mid-round draft found behaviour the doctrine never
described. Three points, doctrine not mechanism: the draft phase stays
the builder's through a fix round, ready-for-review is the builder's own
act and no engine's, and where the draft suppressed CI green is proven
at the flip with the request following it — step 1's rule at a stated
moment, not a second rule.

REVIEWER.md gains the reading that keeps a reviewer from misfiling it:
a draft carrying state:addressing is a fix round in progress.

Refs #258
2026-08-03 21:49:11 +00:00
Daniel Marin
3c96406081
Merge pull request #261 from cndgrr/build/252-blocker-echo
feat(issueflow): echo the parsed blocker set when it changes
2026-08-03 22:43:29 +01:00
Andriujose
e5a87201f8 test: derive release path from executable lines 2026-08-03 21:43:23 +00:00
Andriujose
75d85df20c docs: define doors-unchanged drill records 2026-08-03 21:41:34 +00:00
Andriujose
ba55d1d552 feat: declare the release door path 2026-08-03 21:39:02 +00:00
Daniel Marin
8f4478f67e
Merge pull request #268 from andriujoseba/build/218-refs-not-closing
feat: guard Refs PRs from closing issues
2026-08-03 22:32:20 +01:00
cndgrr
2498dfdce0 test(issueflow): AC-1's other input is an edit, not a re-sweep
AC-1 names two inputs and says "both by fixture". The suite had the first and,
for the second, only a re-sweep of a BYTE-IDENTICAL body — which is the test
plan's other must-not-echo bullet, and cannot stand in for this one: an
identical body is quiet under both spellings of the decision, the one that
keys on the parse and the one that keys on the prose, so it cannot tell them
apart. Only an edit that changes the prose and preserves the parse can.

The new probe reorders the refs and adds sentences on either side, leaving the
set at {#90, #91}, and asserts the marker count, the thread's total echo count
and the issue-edit count all hold still. What it pins is that the marker is a
function of the PARSE and not of the prose around it — the property the whole
idempotency rests on.

Mutation-proven isolating: an echo that also re-fires when the declaration
prose moved since the last echo — quiet on identical re-sweeps, correct on
every set change — passes the pre-existing suite 304/0 and reds only here.
2026-08-03 21:15:37 +00:00
cndgrr
2bab52dc71 fix(issueflow): the echo's illustrative #9 is an illustration, not a reference
The echo body wrote `no longer blocked by #9` unbackticked, twice in one
sentence. GitHub linkifies both, so every echo posted a pair of "mentioned in"
events onto this repo's issue #9 — an issue with nothing to do with the one
being echoed. On a 15-minute cron that is one pair per `blocked` issue on the
board, and the first sweep after merge would have written the whole board's
worth at once.

The file's own convention two branches down already avoids this: the
`blocked-unparseable` comment code-spans its `Blocked by #N` for exactly this
reason. The prose is unchanged, the rendered set is unchanged, and the marker
digests the parsed set rather than the comment body, so no marker moves.
2026-08-03 21:15:37 +00:00
Andriujose
360b262c47 fix: satisfy refs guard shellcheck 2026-08-03 21:00:40 +00:00
Andriujose
022d1fcda6 fix: exercise refs guard action boundary 2026-08-03 20:58:56 +00:00
cndgrr
d604abd074 test(issueflow): spell a healthy blocked+attention issue as no diagnostic
#263 landed on main after this branch's head and asserts that a healthy
assigned attention under blocked posts nothing at all. The #252 echo makes
every blocked issue carry one comment — its parse — so the proxy is false
while the contract behind it is not: probe 66 draws the parse echo and no
attention diagnostic.

Re-spelled the way the same section's other cases already spell it, as the
absence of the attention-malformed marker, plus a companion assertion that
exactly one comment landed. That is strictly tighter than test -f was: this
case now fails if an attention comment appears beside the echo, which the
old form could not detect once any comment existed.

Refs #252
2026-08-03 20:44:09 +00:00
cndgrr
fb89c92434 Merge remote-tracking branch 'origin/main' into build/252-blocker-echo 2026-08-03 20:42:09 +00:00
cndgrr
5314a8f343 test(issueflow): say what the pairwise family actually is
claude-bot: `{acme.widgets#9}` is not a reachable declared set — the clause
parser stops at the `.` and blocked_reference_records never hands the token
through, though issue_references does answer CROSS for it. The comment claimed
all four were declarations the reconciler accepts. The member stays (the
marker's contract is over the tokens the classifier admits) and the comment
now says which is which.

Refs #252
2026-08-03 20:39:14 +00:00
cndgrr
04bdde7b1e fix(issueflow): the parse echo is idempotent against the last echo, not the history
ensure_comment's any-occurrence grep answers "have I ever said this", which
is right for a flag like blocked-unparseable and wrong for a value that
changes. A -> B -> A found A's own first echo and stayed silent, leaving the
thread's newest echo asserting B while the sweep gated on A: a stale parse
presented as the current one, and the third edit did change the parsed set,
so the criterion says it speaks.

blocked_parse_echo_needed compares this parse's marker against the LAST
blockers-parsed-* marker on the thread. The read stays inside guarded_read /
skip_issue, so an unreadable history still fails closed (#247 D1) rather than
answering "nothing echoed yet" and re-posting. ensure_comment is untouched
for every other caller.

Refs #252
2026-08-03 20:35:10 +00:00
Andriujose
869d05bf85 fix: satisfy CI shellcheck gate 2026-08-03 20:34:35 +00:00
Andriujose
66b136efc0 docs: wire refs guard into ceremony flow 2026-08-03 20:31:34 +00:00
Andriujose
dcf72a9af8 feat: add refs-not-closing guard core 2026-08-03 20:29:51 +00:00
Daniel Marin
db63b677bb
Merge pull request #263 from andriujoseba/build/232-attention-diagnostics
feat: diagnose malformed attention targets
2026-08-03 21:24:27 +01:00
cndgrr
195c49b8e1 test(issueflow): the marker's collision test is pairwise, not through one form
Anchoring every pair on the `/` spelling passed under a fix that only
taught the slug about `/` — and that fix still collapses `acme-widgets#9`,
`acme_widgets#9` and `acme.widgets#9` onto one marker. Found by mutating
the implementation to that cheap fix and watching the suite stay green on
the cases that matter. The contract is that no two distinct parses
collide, so the assertion is now every pair.

Refs #252
2026-08-03 20:07:23 +00:00
cndgrr
5910c36137 fix(issueflow): key the parse echo to the set, not to a slug of it
The marker claimed to be scoped to the parsed set's value and was scoped
to a lossy rendering of it: `tr -c '[:alnum:]' '-'` maps `acme/widgets#9`
and `acme-widgets#9` — both parses this reconciler accepts — onto one
marker, so a declaration edited between them found the old echo and said
nothing. Silence in exactly the case the echo exists to speak about.

The identity is now a digest of the exact rendered set. The readable slug
stays in front of it and decides nothing. Distinguishing `/` would have
closed the reported pair and left the class: `-`, `_` and `.` are all
legal in a qualifier and all collapse the same way, so all four are
pinned, and the sweep probe observes the second echo actually landing.

Refs #252
2026-08-03 20:05:04 +00:00
Andriujose
8aa7b12bf9 test: satisfy CI shellcheck annotations 2026-08-03 19:55:32 +00:00
Andriujose
a9b3f4d766 test: cover attention target diagnostics 2026-08-03 19:53:25 +00:00
Andriujose
e7750c0c8f feat: diagnose malformed attention targets 2026-08-03 19:48:15 +00:00
Daniel Marin
5626a4ef1e
Merge pull request #259 from andriujoseba/build/230-attention-target
docs: clarify the attention target at triage write time
2026-08-03 20:40:48 +01:00
cndgrr
5cfb69e104 test(issueflow): the parse echo, mutation-proven in both directions
The idempotency contract is the marker's scope, so both directions are
pinned: an unchanged set must reuse its marker (or a 15-minute cron
repeats itself forever) and a changed one must not (or a misparse hides
under a marker the thread already carries). crew#308's negated clause is
replayed through the sweep, and the empty parse is echoed beside the
untouched `blocked-unparseable` flag.

Refs #252
2026-08-03 19:28:31 +00:00
cndgrr
374005ef77 feat(issueflow): echo the parsed blocker set when it changes
The clause parse is exact and unforgiving, and its output was invisible:
every incident in this class was found by a human running the parser by
hand, hours or days late. The sweep now states what it read — one marker
comment per distinct parsed set, comment-only, no label writes.

Refs #252
2026-08-03 19:24:59 +00:00
Daniel Marin
f73facea6e
Merge pull request #256 from cndgrr/build/247-read-guards
fix(issueflow): a failed read must never reach a decision function
2026-08-03 20:19:35 +01:00
Andriujose
dbb0554288 docs: clarify attention target 2026-08-03 19:07:08 +00:00
Daniel Marin
2e5aa52e29
Merge pull request #255 from andriujoseba/build/248-release-doctrine
docs: add release-management doctrine
2026-08-03 20:00:30 +01:00
cndgrr
6217798e14 fix(issueflow): a per-issue pass commits its whole effect, or none of it
The per-read guards closed the reported class — a failed read never reaches
a decision function — and left one layer standing. A pass could mutate and
only THEN reach a guarded read, fail it, and report the issue as skipped:
`stale` removed, or `needs-triage` minted, under a log line saying the
sweep had touched nothing. That is the same false report #247 exists to
close, told from the other end, and the panel reproduced it on four
separate compositions.

Fixed as the ordering invariant rather than per site. Inside
reconcile_issue_pass's subshell, run() and log() stage their effects, and
commit_staged_effects replays them in order once the pass has completed.
skip_issue emits its own line directly and exits, so the buffer dies with
the subshell. A skip therefore implies zero `gh issue edit`, zero
`gh issue comment`, and no log line about a mutation that never landed —
for compositions nobody has written yet, because reconcile_issue has no way
to mutate directly. Reads stay where they are: they may happen anywhere,
since nothing lands until the end.

Stated per site it would hold until the next composition. Two consequences
worth naming: reconcile_ruling is covered without touching lib/ruling.sh,
because it posts through the sourcing script's run()/log() — the PR surface
keeps its own and is unaffected; and a genuine crash mid-pass now also
lands nothing, where before it left the earlier mutations applied. D4's
handler string, D6's tail and D7's exit 0 are all unchanged, and the
healthy path is byte-identical: every staged write commits under the same
`>/dev/null` its call site already applied.

Refs #247
2026-08-03 18:55:52 +00:00
Andriujose
8c29424519 docs: correct release gate mechanics 2026-08-03 18:26:58 +00:00
cndgrr
f965e8404c test(issueflow): an absent fixture answers nothing to a --jq read, as gh does
The sourced stub returned a literal `[]` to --jq callers when a fixture
was missing, where the real API answers an empty list and the filter
yields nothing. last_issue_activity then sorted `[]` beside an ISO-8601
timestamp — and `[]` outsorts a timestamp in the C locale but not in a
UTF-8 one, so the sweep dated an issue by a stub artifact on the runner
and by created_at here.

The old code swallowed the resulting `date` failure and graded the claim
on a literal 0 anyway; #247's guards turn a failed read into a skip,
which is what made the lie visible. Adopt the arrival stub's shape.
Suite green under LC_ALL=C, C.UTF-8 and en_US.UTF-8.

Refs #247
2026-08-03 18:14:19 +00:00
cndgrr
24f62cc8e9 test(issueflow): the failing --jq read yields no timestamps, as gh does
The .http-error mode applies a requested --jq filter to the error body,
so a failing comments read returns nothing rather than a JSON blob —
which is what let last_issue_activity fall back to created_at and
reclaim a live claim. With it, all three of the issue's must-fail-before
cases fail against the pre-change script, the destroyed claim included.

Pin offsite_timeline's own deliberate silence directly: the activity
read hits the same endpoint, so probe 32 now skips before the offsite
verification it used to reach (D8 leaves that read alone).

Refs #247
2026-08-03 18:08:19 +00:00
cndgrr
865d5bd1df test(issueflow): drive the real 5xx — a JSON error body on stdout
The PATH-stubbed gh gains a `.http-error` mode: the response body goes
to STDOUT, the reason to stderr, the status non-zero. The existing
`.error` sentinel produces empty stdout, which is the *safe* path — an
empty label set either way — and is why this class was never caught.

The three must-fail-before cases, plus the 200-`null` path a status
check alone leaves open, the suppressed-marker duplicate, the D6 tail's
count and numbers, and the crash handler proven distinct from a skip.

Refs #247
2026-08-03 18:06:13 +00:00
cndgrr
13e8f54d60 fix(issueflow): a failed read never reaches a decision function
`gh api` prints a 5xx response body to stdout AND exits non-zero, and
GitHub's 5xx body is a JSON object. Inside the per-issue subshell that
payload passed `has("pull_request") | not`, emptied `.labels[]`, and
`queue_decision` — correct on the input it was handed — wrote
`needs-triage` onto a healthy epic. The run then logged `reconciled.`
and exited 0 (crew#329, #247).

errexit could not have caught it: a command whose status is tested by
`||` runs with errexit suppressed, and the suppression extends through
the whole subshell body, so the `|| log` handler is what disables the
errexit that would have aborted at the failed read. Removing the handler
revives errexit and loses #91's resilience, and an inline `set -e` does
not re-arm it. Explicit per-read checks are the mechanism.

Every read inside that subshell is now checked — the issue read on its
status AND on its payload shape (an HTTP 200 whose body is `null` exits
0 and empties the label set just the same), both reads in
`last_issue_activity`, and the comments read in
`issue_comment_has_marker`. On failure the issue is left exactly as it
is, the reason rides its own `#$n:` line, and the subshell exits with a
distinguished status the sweep counts, so a deliberate skip is not
reported as a crash and a genuine crash is still named byte-identically.

`read_failure_reason` moves to lib/read.sh beside a new `guarded_read`,
sourced by both reconcilers: labels-reconcile's copy was the only one,
and the issue surface needs the identical rule.

Refs #247
2026-08-03 18:01:58 +00:00
Daniel Marin
d8a70657eb
Merge pull request #250 from cndgrr/build/236-unrequested-green-gate
fix(labels): blocker:unrequested waits for green, and for the round to settle
2026-08-03 18:48:22 +01:00
Andriujose
a74ebb9876 docs: align release triggers with shipped flow 2026-08-03 17:33:39 +00:00
Andriujose
ab60709f49 docs: add release-management doctrine 2026-08-03 17:29:15 +00:00
cndgrr
d4512a1e82 test(labels): drive the fetch that feeds the grace, at the sweep level
The predicate's fixtures cannot see the read that sets HEAD_COMMIT_AT, so a
sweep probe drives it both ways: read, and the blocker is written off a dated
head; denied, and the denial is named on its own line while the state still
converges — this read narrows one blocker, it does not skip the PR the way an
unreadable rollup does. Renaming the assignment reds the probe.

The read also moves after the mergeability/checks skip: a PR the sweep walks
away from must not pay for a call whose only consumer is a blocker that pass
will never decide.

Refs #236
2026-08-03 17:23:11 +00:00
Daniel Marin
d225c68fa4
Merge pull request #243 from andriujoseba/build/241-open-pr-refs
fix: preserve claims with open Refs PRs
2026-08-03 18:21:21 +01:00
cndgrr
8459a9255b test(labels): drive the green gate and the grace, and mutate both to prove them
The six cases the issue names, plus the boundary (the grace is inclusive), a
verdict inside the window against an old head, both unreadable timestamps, and
the configured-grace override.

Two proofs run rather than asserted in prose: a copy of the script with the
gate removed must flag the PENDING fixture, and a copy with the grace removed
must flag the inside-the-window one. The harness checks itself against the
unmutated copy first, or a flip would prove nothing.

The pre-#236 stall fixtures gain real timestamps. Their symbolic stamps are
not unreadable — GNU date reads `t1` as 01:00 in military timezone T, a time on
whatever day the suite runs — so a grace measured against a fixed NOW would
flip with the calendar. Every assertion is byte-identical.

Refs #236
2026-08-03 17:18:46 +00:00
cndgrr
b5ec236b42 fix(labels): blocker:unrequested waits for green, and for the round to settle
`blocker:unrequested` is the one blocker that names an act the author must
perform, and it never knew whether performing it was permitted. BUILDER.md's
review round requires a green check at the head before requesting, so a
builder waiting out a pending run is complying — and the blocker fired on
compliance (crew#318 ~12:44Z, ceremony#235 12:30Z, both 2026-08-03).

Gate the branch on CHECKS ∈ SUCCESS | NONE (D1): PENDING is CI's move, which
state:addressing already says, and FAILURE belongs to blocker:ci-red rather
than to a second label on the same stall. Then require the supporting facts —
the head's own date and the round's newest submitted review — to have stood
for RECONCILE_UNREQUESTED_GRACE (default 300s, D2), measured off those
timestamps because this sweep is stateless per pass. A timestamp that cannot
be read refuses the blocker.

Refs #236
2026-08-03 17:12:38 +00:00
Andriujose
071ac49cc2 test: retain executable transition control 2026-08-03 17:08:16 +00:00
Andriujose
702ec5fc5d test: model both open PR linkage paths 2026-08-03 17:08:16 +00:00
Andriujose
e133924887 fix: preserve claims linked by open Refs PRs 2026-08-03 17:07:40 +00:00
Andriujose
19ae4aedd1 test: reproduce open Refs claim loss 2026-08-03 17:07:40 +00:00
Daniel Marin
78a86198d2
Merge pull request #245 from cndgrr/build/242-post-merge-pr-order
fix(issueflow): the deliverable PR is the last merged, not the highest numbered
2026-08-03 18:03:10 +01:00
cndgrr
544d4a0603 style(test): separate the merge-order block from the offsite decisions
Refs #242
2026-08-03 16:29:21 +00:00
cndgrr
9c690f02b7 test(issueflow): drive the merge-order selection, and the spent-marker shape end to end
issue_probe's merged-PR argument becomes a spec list — `PR` or `PR@<iso>`
— so a probe can state merge order; the bare form keeps every existing call
site literal.

The direct-drive cases cover crew#176's shape (the lower number merged
later), agreeing orders, interleaved issues, the mergedAt tie broken by
highest PR number under both input orders, and the empty answer. The
end-to-end probe is crew#321's: a marker already standing for the
later-merged, lower-numbered PR must suppress the transition, which
selecting by number could never do.

Two static pins keep the request count honest — the sweep issues exactly two
GraphQL queries, with mergedAt selected on the merged-PR node it already
fetched.

Refs #242
2026-08-03 16:27:43 +00:00
cndgrr
f4afaa1346 fix(issueflow): the deliverable PR is the last merged, not the highest numbered
post_merge_pr_for_issue answered "which merged Refs PR is this issue's
deliverable?" with sort -n | tail -n1. Merge order is not number order:
crew#176's two Refs PRs merged #184 at 19:05:16Z and #182 at 19:05:18Z.

MERGED_REF_PR_RECORDS gains mergedAt as a third column — a field on the
merged-PR node set already fetched, so no additional GraphQL request — and
the selection sorts on it, breaking ties by highest PR number so the answer
never depends on input order.

Refs #242
2026-08-03 16:23:15 +00:00
Daniel Marin
46329f5993
Merge pull request #244 from cndgrr/build/231-labels-attention-absolute
docs(labels): the `attention` absolute stops denying the shipped reconciler
2026-08-03 17:17:27 +01:00
cndgrr
bded7d0b54 docs(labels): the attention absolute stops denying the shipped reconciler
LABELS.md asserted 'nothing in actions/ sets, clears, reads, or validates
it' and then documented two exceptions to itself four sentences later. The
sentence is false on two of the four verbs: issueflow-reconcile.sh clears a
carried attention on the derived claimed -> post-merge transition and reads
it to gate the post-merge-assigned diagnostic. Keep the hand-set intent,
drop the absolute (#231).
2026-08-03 15:48:05 +00:00
4e929e2083 test(forge): the negative half of the github pass-through pins
Some checks failed
CI / test (pull_request) Successful in 1m25s
CI / release-exercise (pull_request) Successful in 8s
CI / self-guards (pull_request) Successful in 5s
CI / action-exercise (pull_request) Successful in 4s
CI / docs-sync-exercise (pull_request) Successful in 4s
labels / labels (pull_request) Failing after 6s
@grok-reviewer-andresmgsl landed the term-5 pins in ff17d1e while I had the
same two asks in flight locally; theirs is on the branch and I dropped my
duplicate rather than push a competing tip. This adds only what the two
suites did not share.

Their pins are positive: the github twins DO call the right endpoints, and
forge_pr_activity emits all three timestamp sources. Mine had two negatives
they did not, and negatives are what catch the drift a 1:1-extraction path
actually suffers — a positive pin still passes if the github path GAINS
forgejo behaviour, and term 5 is a statement about what must NOT change.

  - the github timeline is never reshaped. That timeline already IS the
    shape ruling.sh selects on, so a projection here would be a second,
    divergent normalizer maintained by nobody.
  - github activity never derives inline comments from reviews. That
    derivation exists on the forgejo path only because the flat endpoint
    404s there; a github twin quietly adopting the workaround is the
    "both backends drift together" failure term 5 forbids.

Mutation-verified: adding a --jq projection to the github timeline, and
swapping the flat PR-comments read for a reviews-derived one, each red their
own case. forge-backends 77 -> 79.

Refs #188
2026-08-03 15:30:28 +00:00
ff17d1ea3f fix(forge): term-5 GitHub pins for timeline/activity + keep activity stderr (#188)
Some checks failed
CI / test (pull_request) Successful in 1m25s
CI / release-exercise (pull_request) Successful in 9s
CI / self-guards (pull_request) Successful in 5s
CI / action-exercise (pull_request) Successful in 4s
CI / docs-sync-exercise (pull_request) Successful in 4s
labels / labels (pull_request) Failing after 6s
Codex 1566 held APPROVE: only Forgejo stubs covered forge_timeline and
forge_pr_activity. Pin the github twins as 1:1 extractions (timeline
paginate; issue comments + flat pulls comments + commits).

Cluade #4879: drop 2>/dev/null on the labels-reconcile activity call site
so a failed read still degrades last_activity but names the failure in the
job log (keep || true).
2026-08-03 15:26:08 +00:00
5c8e4f5b84 feat(forge): timeline normalizer, portable PR activity, shellcheck install (#188)
Some checks failed
CI / test (pull_request) Successful in 1m26s
CI / release-exercise (pull_request) Successful in 8s
CI / self-guards (pull_request) Successful in 5s
CI / action-exercise (pull_request) Successful in 4s
CI / docs-sync-exercise (pull_request) Successful in 4s
labels / labels (pull_request) Failing after 6s
Panel-unanimous batch that was staged unpushed on 57abe15 (#4853):

- forge_timeline: project Forgejo label events into the GitHub shape
  so the ruling ladder fires on this forge (measured mapping #4849)
- forge_pr_activity: stop calling /pulls/{n}/comments (404 here); use
  reviews with comments_count > 0 for inline comments (#4844)
- ci.yml: install shellcheck before lint, mirroring actionlint — the
  act-22.04 runner image does not ship it

Status captured before jq so an unreadable timeline cannot report empty.
2026-08-03 15:13:30 +00:00
github-actions[bot]
985ee7e6e4 chore: bump main to 0.5.1-dev — a dev install must not impersonate 0.5.0 2026-08-03 12:40:50 +00:00
Daniel Marin
ee75c2aba3
Merge pull request #235 from dan-claude-bot/build/233-release-0-5-0
release: cut 0.5.0
2026-08-03 13:40:35 +01:00
dan-claude-bot
d48374ff00 drill: probe 5 states suite coverage, not live dogfood
The #226 delta sits behind reconcile_ruling's needs-ruling gate and this
board has no such item — the post-merge sweeps ran the file, never the
delta. The record now says what was observed and why the live claim is
unreachable (round 1, claude).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:28:40 +00:00
dan-claude-bot
e5e7555184 release: cut 0.5.0
Five fragments assembled into '## 0.5.0 — 2026-08-03' (#205 #216 #221
#224 #226); VERSION to bare 0.5.0; the three CEREMONY_SELF_REF carriers
stamped "0.5.0" in this one commit; the panel-rows unreleased marker in
docs/CONSUMERS.md cleared to name 0.5.0; drills/0.5.0.md records the
doors-unchanged ruling with the measurements as they are at 0ac3a6f.

Refs #233.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:01:05 +00:00
Daniel Marin
0ac3a6ff7e
Merge pull request #234 from dan-claude-bot/build/226-best-shaped-escalation
fix: select the best-shaped escalation, not the earliest
2026-08-03 12:57:42 +01:00
dan-claude-bot
0df40f8d08 fix: select the best-shaped escalation, not the earliest
Closes-adjacent contract in the PR body; authorizing issue #226.

ruling_escalation_row scored every setter in-window row 0-4 by the shared
field matcher; highest wins, equal scores break to the earliest epoch, an
undecodable body scores 0. ruling_shape_decision now grades through the
same matcher, so the selector and the check cannot drift (crew#293).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 10:55:45 +00:00
57abe15a77 fix(issueflow): the issue/PR discriminator is GitHub-only
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
Found by rehearsing DRY_RUN against heavy-duty/rig's live board, not by any
test. issueflow swept ZERO issues on Forgejo and printed "reconciled." — the
blind sweep again, one layer in, and invisible because the log is identical
to a legitimately empty queue.

Measured on the two list endpoints, 2026-08-02:

  GitHub   plain issues OMIT pull_request  -> 0 of 9 carried the key
  Forgejo  every entry HAS it, null on issues -> 10 of 10 carried it

So `select(has("pull_request") | not)` matched nothing here. Replaced with
`.pull_request == null`, which is true for an issue on both forges (an absent
key reads as null) and false for a PR on both. Verified against both live
list endpoints: Forgejo 10 open issues, GitHub 9 — each matching its API.

With the fix the sweep produces real decisions against rig rather than
silence: needs-triage on six issues with no queue state, the merged-Refs
post-merge transition on #133, and the conflicting-queue-labels flag on #129.

The regression test keeps the old expression as a must-fail: it disagrees
with the new one on exactly the Forgejo shape.

Refs #188
2026-08-02 20:44:58 +00:00
9db8317543 fix(labels-scope): jq 1.6 cannot parse $label — the runner image ships 1.6
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
Found by running ceremony's own CI at 9357f09 on a real Forgejo runner
rather than reasoning about it.

`label` is a reserved word in jq's grammar (`label $out | … | break $out`),
so jq **1.6** rejects `$label` outright:

  jq: error: syntax error, unexpected label, expecting IDENT

jq 1.7 parses it, which is why this survived: GitHub's hosted ubuntu-latest
ships 1.7, and ghcr.io/catthehacker/ubuntu:act-22.04 — the image this
instance maps ubuntu-latest to — ships 1.6. So parse_labeler_config died on
a compile error before it read a byte of config, and EVERY scope derivation
on this forge failed. Renamed to $lbl in the jq program only; the bash
locals keep their names.

Also makes test/forge.test.sh hermetic. Its "github + gh passes" case
depended on gh being on the HOST's PATH, so it passed on a developer box and
failed in the runner image, which has no gh. The preflight cases now run
against stub binaries, and the missing-binary refusal gets its own arm on a
PATH carrying the shell and text tools but no clients — the condition under
test, rather than whatever the machine happens to have.

Verified in both environments: local (jq 1.7, gh present) and the runner
image (jq 1.6, no gh) — shellcheck 0, 22 files 0 failed in each.

Refs #188
2026-08-02 20:34:02 +00:00
9357f09aea fix: the four findings from the panel round on 2168e4e
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
@codex-reviewer-andresmgsl #4780, concurred by @grok-reviewer-andresmgsl
#4785. All four real.

1. Three issueflow call sites still named per_page=100. The backend
   sanitized it so it worked, but the frozen term and the changelog both say
   no call site names a page size — and a contract that holds only because
   something downstream cleans up is not the contract. Endpoints now carry
   their logical query alone.

2. The suite's summary and `[ "$fail" -eq 0 ]` gate sat in the MIDDLE of
   test/labels-reconcile.test.sh, and the eight outstanding_requests expects
   were appended after them. Proven before fixing: a deliberately broken
   term-4 assertion printed FAIL, was excluded from the totals, and the
   suite still exited 0. Those assertions were decorative. The gate moves to
   the true end, with a note that nothing goes below it; the reported count
   goes 157 -> 164, which is the eight that were never being counted.

3. forge_labels_add and forge_request_reviewer arrived with the port and had
   no boundary pins. Both backends now have them, and the labels_add cases
   pin the property ceremony#128 turns on: an additive POST, never a PUT of
   the whole set, exactly one write so nothing is read-modify-written.
   Mutation-verified — making it RMW/PUT, or routing github through
   `issue edit --add-label`, each red their own cases.

4. The historical comment said the old gathers were `forge_api graphql`. My
   own mechanical port rewrote it; before #188 they were `gh api graphql`
   and the abstraction did not exist.

Refs #188
2026-08-02 19:58:51 +00:00
2168e4ef9a test(forge): hermetic cases for the two forgejo edit asymmetries
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
The coverage owed with the call-site port (@grok-reviewer-andresmgsl #4741
note 2, #4751 item 2). Live scratch-repo evidence proved these work; these
pin the request SHAPE so they keep working.

  - a removal resolves name -> numeric id, and never sends the name as the
    path segment (measured: DELETE .../labels/probe:one -> 422,
    DELETE .../labels/149 -> 204);
  - a removal of a label the repo does not have writes nothing, matching gh:
    the reconcilers call --remove-label unconditionally to converge state;
  - adds take names directly, one request, comma-separated values split as
    gh splits them;
  - an assignee removal PATCHes the SURVIVING list, because Forgejo sets
    assignees rather than adding and removing them — a naive translation
    would have cleared every other assignee as a side effect of removing
    one, which is what the mutation test proves is caught.

Payloads are now compact JSON. They were pretty-printed, which spread a
single write across several lines — harder to read in a log, and it hid the
shape from any assertion matching a line.

Refs #188
2026-08-02 19:50:37 +00:00
f2d5fcd565 feat(forge): derive outstanding review requests from the head, not the field
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
Term 4. GitHub clears requested_reviewers when a verdict lands, so the field
answers "who still owes a verdict" by itself. Forgejo never clears it —
measured: rig!140 listed all three panelists with all three verdicts in, and
rig!146 still lists three while MERGED, so the field is stale even on a
closed PR.

Read raw on Forgejo that is not a cosmetic over-count. `requested` drives
three decisions, and a permanently-true field pins a PR at
state:bots-reviewing for life and stops blocker:unrequested from ever being
true: the sweep believes a round is live forever and no staleness can
correct it.

So the requested set is intersected with who has NOT submitted a verdict for
the current head, derived from /pulls/{n}/reviews — the read that is true on
both forges. On GitHub the filter removes nothing, because the field is
already accurate; term 5 holds by construction rather than by care.

A STALE approval — an approval of an older head — still owes a verdict. That
is the case that matters: treating it as answered would let a stale round
read as complete, which is the shape #136 exists to prevent.

Mutation-verified both ways: reading the field raw again reds three cases,
and treating STALE as answered reds two.

Also documents @grok-reviewer-andresmgsl's ask (#4763): every panel= account
must be able to read the repo, or the forge refuses the review request —
422 naming the account on Forgejo. A real failure mode for private
consumers, and it fails loudly rather than sweeping blind.

Refs #188
2026-08-02 19:48:09 +00:00
baf4a20571 feat(forge): port every reconciler call site onto the shim
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.

The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".

Two sites needed judgment rather than substitution:

  - labels-scope's write is forge_labels_add, a genuine additive POST on
    both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
    that write not being a read-modify-PUT: the labeler action computed
    (labels-at-job-start union derived) and PUT the whole set, silently
    dropping a label applied while the job ran. Routing it through a generic
    edit verb would have quietly reopened that.

  - the human-review request is forge_request_reviewer. Contrary to my
    earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
    Forgejo — 422 naming the reviewer's access without it, 201 with it. The
    earlier 404 was a GET, which the endpoint does not serve, plus a
    username that did not exist.

Test churn, all of it the term-5 boundary move:

  - the suites select the github backend, so their existing gh() stubs stay
    the boundary and keep intercepting;
  - stubs strip the paging the shim injects, so fixtures stay keyed on the
    logical endpoint (inlined in the PATH stub, which is a standalone
    executable and cannot see a shell function);
  - fixtures renamed off the per_page suffix for the same reason;
  - recorded-mutation assertions now match the verb, not the raw gh line;
  - gh() stubs carry SC2317: they are reached through the backend now, so
    shellcheck can no longer see the call path.

Refs #188
2026-08-02 19:43:58 +00:00
dce12e0bb5 fix(test): the second curl stub needed the SC2317 disable too
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
a968e13 was pushed with shellcheck red. I chained the gates and the push in
one command, so a non-zero gate did not stop the push — the gate has to be a
condition, not a line of output I read afterwards.

Refs #188
2026-08-02 19:24:27 +00:00
a968e13ca4 fix(forge): parity gaps in the forgejo verbs — upsert, timestamps, typos
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
@codex-reviewer-andresmgsl's three findings (#4743), all real.

1. forge_label_create is now an UPSERT, matching gh label create --force.
   bootstrap_labels creates every declared label on EVERY workflow_dispatch,
   so a plain POST onto an existing name aborted the bootstrap under set -e
   from the second dispatch onward. Resolves name -> id and PATCHes when it
   exists.

2. forge_pr_view carries createdAt/completedAt. checks_state groups repeated
   contexts and selects the newest by [.startedAt, .createdAt, .completedAt];
   mapping only {context,state} left the winner to incidental array order, so
   a stale re-run could outrank the live verdict. The combined status carries
   created_at and updated_at — measured.

3. forge_issue_edit refuses unknown flags and missing values. The github
   backend hands them to gh, which fails; dropping them here turned a
   mis-typed port site into a mutation that silently did not happen — this
   issue's own failure class, inside the fix for it.

Also settles @grok-reviewer-andresmgsl's note 3 (#4741): Forgejo Actions DO
land as commit statuses on this instance, so the rollup is not empty.
rig main carries four — "ci / check (push)" and siblings, state success,
each with created_at. statusCheckRollup therefore populates, and NONE is not
silently substituted for SUCCESS.

Each fix mutation-verified: dropping the timestamps, forcing POST-always, and
restoring the silent flag skip each red exactly their own cases. The
newest-verdict case drives the real checks_state, not a copy.

Refs #188
2026-08-02 19:22:28 +00:00
adf3299192 test(forge): assert the distinguishing text, not a surviving substring
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
@codex-reviewer-andresmgsl (#4727) and @grok-reviewer-andresmgsl (#4734):
"...and the refusal names both totals" searched only for "4", so it stayed
green if the later total vanished from the message. A case named "names
BOTH" must fail when one goes. Now asserts "4 then 9".

Auditing this file's siblings for the same shape found a second, older
instance: "the refusal names the client" searched for "gh", which also
occurs in the explanatory prose ("gh speaks GitHub's /api/v3..."), so it
would have passed even if the client name never reached the message. Now
asserts "the 'gh' client cannot speak it".

Both verified by mutation: removing the second total, and removing the
interpolated client name, each red exactly their own case.

Refs #188
2026-08-02 19:16:48 +00:00
714a2e0413 feat(forge): the reconciler verb surface on both backends
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
github is the existing gh invocation extracted 1:1 (term 5). forgejo is
/api/v1, and encodes three asymmetries measured against this instance on a
scratch repo — never a live board:

1. Adding labels takes NAMES; removing one takes a numeric ID.
     POST   /issues/1/labels {"labels":["probe:one"]}  -> 200
     DELETE /issues/1/labels/probe:one                 -> 422
     DELETE /issues/1/labels/149                       -> 204
   So a removal resolves name -> id first. gh hides this; the shim cannot.

2. Assignees are SET, not added and removed: PATCH /issues/{n} takes the
   whole list and {"assignees":[]} clears it. --remove-assignee is therefore
   a read-modify-write, not a delete.

3. There is no statusCheckRollup. The portable equivalent is the combined
   commit status, GET /commits/{sha}/status, mapped into the node shape
   checks_state already parses so the decision code is untouched.

gh pr list --limit 100 moves behind forge_pr_list: that page size lives in
gh's own flag namespace, so no URL-parameter strip could have caught it
(@grok-reviewer-andresmgsl's note 3).

Every verb driven live against a real Forgejo instance: label list/create/
delete, add and remove labels by name, a removal of a label the repo does
not have (no-op, as gh behaves), comment, assignee add and remove, pr_list.

Call sites are still unported, so this is not yet reachable on either forge.

Refs #188
2026-08-02 19:13:14 +00:00
66e20f12f0 fix(forge): validate the completeness bound itself, on every page
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
@codex-reviewer-andresmgsl's three findings (#4712), each a route by which
an unprovable read could still be reported as a whole one — the guard
leaking the failure class it exists to stop.

1. x-total-count was never validated. `X-Total-Count: not-a-number` returned
   rc=0 with that string as the bound the walk compared against, reproduced
   on ab23a3b. Now required to be a canonical non-negative integer.

2. The total was read once. A collection changing size under the walk was
   invisible: page 1 declaring 4 and page 2 declaring 9 stopped at 4
   believing itself whole. Now re-read per page; a moving total means the
   read was not atomic and is refused.

3. A 200 whose body is not an array counted as zero items, so an error
   object or scalar arriving where a list belongs read as a complete EMPTY
   collection whenever the declared total was 0. Now refused, quoting the
   body. A genuinely empty array is still fine — covered.

Each guard is mutation-verified: removing it reds exactly its own cases and
no others.

Refs #188
2026-08-02 19:07:32 +00:00
87b088114a fix(test): silence the two lint classes the new backend suite introduces
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
SC2016 on the deliberate single-quoted bash -c (the expansion belongs to
the isolated process, as the sibling case in issueflow-reconcile.test.sh
already documents), and SC2317 on the curl stub, which shellcheck cannot
see is invoked indirectly by forge_api.

Found only after committing, because .github/scripts/shellcheck-all.sh
derives its lint set from `git ls-files` — an UNTRACKED file is not linted
at all. "Gates clean" measured before `git add` was measuring a set that
excluded the file just written. Verified from a clean clone at the pushed
SHA, which is what caught it.

Refs #188
2026-08-02 19:03:40 +00:00
ab23a3b1b6 feat(forge): two backends behind one call surface, and the shim owns paging
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
Term 1's foundation. lib/forge.sh gains forge_select, which sources exactly
one of lib/forge-github.sh or lib/forge-forgejo.sh; both define the same
verbs, so no branching reaches the 61 call sites. The github backend is the
current gh invocation extracted 1:1 — term 5 is kept by making that path
boring.

The page size moves OUT of the call sites and into the backend, because it
is not portable and fails silently. Measured 2026-08-02:

  ?per_page=100   GitHub 100 items   Forgejo 30 items  (ignored)
  ?limit=100      GitHub  30 items   Forgejo 50 items  (capped)

Both answer HTTP 200 with valid JSON. Every call site here is GitHub-shaped,
so a verbatim port would have swept 30 of rig's 137 issues and printed
"reconciled." — criterion 2 failing green, the same failure class as the
blind sweep. Both page_url helpers strip a stray page-size parameter in
either dialect, so a call site cannot reintroduce it by accident.

Forgejo caps a page at 50 whatever is asked, so pagination is mandatory, not
an optimisation. The gather is then PROVEN complete against x-total-count
rather than assumed complete because a loop ended.

@kimi-reviewer-andresmgsl's hardening (#4699): a missing x-total-count is
itself a loud refusal. Header exposure is a server setting, and an assert
that cannot run must not silently pass — that is the failure class
re-entering through the guard built to stop it.

Call sites are not ported yet; that is the next commit.

Refs #188
2026-08-02 19:00:58 +00:00
3885437f02 test(forge): cover the open-pull REST gather at main() granularity
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
@codex-reviewer-andresmgsl's draft-stage finding: the closed/merged half of
the term-3 replacement had an executable-path case, the open half did not.
The 27 closes_references cases test the parser, not the
`.body | @base64` -> base64 -d -> closes_references wiring around it.

Both directions in one sweep so neither assertion passes vacuously: #50 is
closed by an open PR and keeps its claim, #51 is closed by nothing and is
reclaimed. `Closes #50` sits on the third line of the body, so the newline
protection is non-vacuous — an @tsv-shaped regression that keeps only the
first line reclaims #50 and reds the case.

Verified by mutation: replacing the decode with `base64 -d | head -1` fails
exactly "a claim closed by an open PR survives the base64 round trip" and
nothing else; reverting restores 148/148.

The clock is injected. INOW is a fixed 2033 epoch, so without ISSUEFLOW_NOW
the subprocess reads its own wall clock, dates both claims in the future and
keeps them on a negative age — green, and proving nothing. Caught while
writing this case.

Also renames the sibling assertion that still said "through GraphQL"; that
gather has been REST since 5797b41.

Refs #188
2026-08-02 18:49:28 +00:00
5797b418b9 feat(forge): replace both gh api graphql sites with REST + a body parser
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
Term 3 of #188. Forgejo has no GraphQL API, so these two gathers could not
be translated — there is no endpoint to translate them to. A real
forgejo-runner job says so from the other side: GITHUB_GRAPHQL_URL arrives
set to the empty string (probe task 278).

MERGED_REF_PR_RECORDS was already a body parse; GraphQL was buying
pagination, nothing semantic. OPEN_PR_ISSUES used GitHub's own parse of the
closing keywords, so it becomes lib/closes_references.sh — a sibling of
refs_references, sharing its LOCAL/CROSS classifier so rig#112 can still
never be read as local #112 (#61).

Both gathers now read /pulls, which /api/v3 and /api/v1 return in the same
shape (measured on both). merged_at replaces GraphQL's states: MERGED.
Bodies travel base64: jq's @tsv escapes a newline to a literal backslash-n,
which a line parser reads as one line and loses every declaration after the
first.

The accepted delta, written down rather than rediscovered: GitHub also
records closing links attached through the PR development sidebar, which
live in no body. This family declares links in the body, so the delta is
zero here.

Refs #188
2026-08-02 18:41:03 +00:00
7d52b2cd4a feat(forge): refuse loudly when the client cannot speak the forge
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
The preflight half of #188, landed first so it stands alone: the forge is
decided once, before any sweep, and a client that cannot speak it exits
non-zero with a named reason.

Measured against forgejo.heavyduty.builders at 84bb1a4 — two of the three
actions reported SUCCESS having read nothing:

  labels-scope         exit 0  "no .github/labeler.yml" (the file is HTTP 200)
  labels-reconcile     exit 0  "reconciled."            (zero PRs enumerated)
  issueflow-reconcile  exit 1  "unexpected end of JSON input"

labels-reconcile's blind-sweep warning (#96) could not fire: it counts
unreadable PRs against a list `gh pr list` never produced, and a process
substitution's failure does not trip set -e, so total stayed 0. Installing
gh makes it worse, silencing the one loud failure.

Detection is measured, not inferred from docs: a real forgejo-runner v6.3.1
job (probe task 278) shows Forgejo populating the whole GITHUB_* namespace,
so GITHUB_ACTIONS proves nothing. GITHUB_API_URL's shape, GITEA_ACTIONS and
GITHUB_SERVER_URL do. The same probe shows the runner image carries neither
gh nor stoke, which is what makes the forgejo backend REST.

Tests declare CEREMONY_FORGE at the forge boundary rather than stubbing gh
and staying silent about the forge — the boundary move term 5 asks for.

Refs #188
2026-08-02 18:29:08 +00:00
Daniel Marin
b18c0bc0b2
Merge pull request #227 from dan-claude-bot/build/224-cut-0-5-0-bundle
feat: per-author panels, draft vs the round, the marker sweep, the write-token rule — the 0.5.0 bundle
2026-08-02 16:13:46 +01:00
dan-claude-bot
44b1a3d23c fix: a draft never reads state:needs-human — round 1, claude
The reorder let a draft with a live human request plus a standing block
or comment fall through to round_state, whose human-request precedence
sits above BLOCK/FEEDBACK — 224 of claude's 1500 fixture cases read
needs-human on a PR GitHub cannot merge. decide_state now disqualifies
needs-human unconditionally under DRAFT=true, landing on
state:addressing like the blocker/needs-ruling/blocked clauses. The two
new rows assert the criterion where it can actually fail: human
requested x {CHANGES_REQUESTED, COMMENTED}. Also grok's nit: the
bootstrap row for state:building now matches LABELS.md (draft is
evidence, not the definition), and the CONSUMERS.md reflow nits are in.

Refs #205

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 14:13:25 +00:00
dan-claude-bot
069faf481a fix: refuse a bracket login that is not [A-Za-z0-9-] — round 1, codex
panel[z]]=b parsed at the round-1 head: the case pattern only proves
some ]= occurs, so the stray ] stayed inside the login and the real
author silently fell back to the base panel — the misroute D4 exists to
refuse. The login charset is now enforced with the bracket-specific
diagnostic; codex's probe and an invalid-character row are the new
must-fail fixtures.

Refs #224

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 14:13:25 +00:00
dan-claude-bot
6f245639ca docs: the write-capable token rule — repo-owned by default, established publishers only, SHA-pinned
The ruling from discussion #171 as ruled: canonical text in REVIEWER.md
§What you review against item 2 (beside the verify-at-pin sub-bullet it
is the sibling of), short form in BUILDER.md §Building pointing at it.
CONTRIBUTING.md and docs/CONSUMERS.md checked for contradiction or
duplication: none — their pin prose is the mirror/caller pinning rule —
so both are deliberately untouched.

Refs #216

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 13:32:31 +00:00
dan-claude-bot
ecb0371cad docs: clear five stale unreleased markers; the release PR owns clearing
Each marker now says available-at-tag in the guide's existing L420
phrasing, verified by tag containment in #221; every never-mix-refs
sentence survives verbatim. The convention paragraph gains its missing
half: the release PR that ships machinery clears, in that same PR,
every marker its assembled section makes false.

Refs #221

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 13:32:31 +00:00
dan-claude-bot
7c53267377 fix: a standing non-approving verdict outranks draft in decide_state
round_outranks_draft consults the round before draft short-circuits: a
re-drafted PR carrying CHANGES_REQUESTED, an owed round-reply, or
push-staled approvals reads state:addressing; a live panel request on a
draft surfaces as state:bots-reviewing rather than being absorbed
(the must-not-paper-over combination, decided as: visible). Approvals do
not outrank draft, so a draft never reads needs-human, and a virgin
draft is byte-identical to before. LABELS.md's state:building row makes
draft evidence, not the definition.

Refs #205

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 13:29:15 +00:00
dan-claude-bot
8db6c3ae29 feat: per-author review panels — labels.conf gains panel[<login>]= rows
One resolution point (panel_for_author) feeds set_required_bots; the
author's row when the conf defines one, the base panel= otherwise, minus
the author in either case. Bracket prefixes are matched quoted so the
case patterns cannot glob (D7, panela= tripwire). configured_label_rows
skips the rows so a dispatch bootstrap cannot mint a label named after
one. BUILDER.md/REVIEWER.md carry the one D9 wording; CONSUMERS.md
publishes the row as unreleased with the parse-failure warning.

Refs #224

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 13:29:15 +00:00
github-actions[bot]
80da0a8a1f chore: bump main to 0.4.2-dev — a dev install must not impersonate 0.4.1 2026-08-01 18:15:54 +00:00
Daniel Marin
2050b278f1
Merge pull request #214 from dan-claude-bot/build/212-release-0-4-1
release: cut 0.4.1
2026-08-01 19:15:42 +01:00
dan-claude-bot
74d59e9554 release: cut 0.4.1
Consume the two displacement-fix fragments into the 0.4.1 section, stamp
VERSION and every CEREMONY_SELF_REF carrier, and record the doors-unchanged
drill ruling with the candidate-head evidence table.

Refs #212

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 17:54:47 +00:00
Daniel Marin
c2987fd8d8
Merge pull request #211 from dan-claude-bot/build/209-detach-reconcile-sweep
labels: detach the reconcile sweep from PR-triggered runs (#209)
2026-08-01 18:50:26 +01:00
Daniel Marin
b6d7e26d82
Merge pull request #210 from dan-claude-bot/build/208-checks-state-self-filter
fix: checks_state never grades the label machine's own runs (#208)
2026-08-01 18:44:44 +01:00
dan-claude-bot
be660358f2 docs: spell out crew's four-edit migration; fold in crew#250 field facts (#209)
crew#250 verified two facts the design prose now carries: a queue-
displaced run is not independently rerunnable (gh run rerun / --failed /
--job all refuse), so a victim PR had no manual escape hatch; and the
displacing burst is deterministic — one review_requested event per
panelist per request — so displacement is the steady state of a working
fleet, scaling with panel size, not a traffic spike.

CONSUMERS.md now walks the adoption as one atomic four-edit PR with crew
as the worked example: the pin bump in every ceremony uses: reference,
the new labels-sweep.yml caller, the cron RELOCATED (bold warning: a
copied-not-moved schedule double-fires sweeps into the one shared group
and reads as the bug getting worse after the fix), and actions: write
replacing the labels caller's actions: read.

Refs #209

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 17:25:26 +00:00
dan-claude-bot
45aa806207 labels: detach the reconcile sweep from PR-triggered runs (#209)
The sweep rode the same workflow run as the PR event that woke it, so
every displacement in the shared labels-reconcile queue recorded a
CANCELLED reconcile check on some PR — fake red CI that held review
requests. The reconcile + issueflow jobs move, unchanged, to a new
reusable labels-sweep.yml behind their own caller; labels.yml gains a
trigger job that dispatches the consumer's sweep caller with the plain
GITHUB_TOKEN (workflow_dispatch is a documented no-retrigger exemption)
on every event that used to run reconcile. A displaced sweep now cancels
on the Actions tab, attached to no PR; PR checks show scope + trigger.

Because every trigger-driven wake arrives as workflow_dispatch, the event
name alone no longer separates the operator's manual bootstrap from an
event-woken sweep: the sweep caller's bootstrap dispatch input does — the
trigger passes no, a bare manual dispatch defaults to yes. The sweep
reusable also takes pr_workflow_name, exported as SELF_WORKFLOW for the
#208 reconciler (harmless to earlier ones; zero file overlap with #208).

The trigger is deliberately loud: a pin bumped without the sweep caller,
its bootstrap input, or actions: write on the labels caller goes red at
the trigger job instead of silently never sweeping again — documented in
docs/CONSUMERS.md with the split stubs and the atomic-adoption note.

Refs #209

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 16:20:00 +00:00
dan-claude-bot
8841d9711f fix: checks_state never grades the label machine's own runs (#208)
The shared reconcile concurrency group displaces queued sweeps as
CANCELLED, and the displaced run's successor attaches to a different PR —
so on the victim the newest self entry stayed CANCELLED, scored FAILURE,
and the sweep set blocker:ci-red off its own corpse every cadence
(crew#227). Drop rollup entries whose workflowName matches SELF_WORKFLOW
(defaulting to the ambient GITHUB_WORKFLOW — the caller's name, so no
workflow edit and no hardcoded consumer name) before the newest-per-context
collapse; an empty name filters nothing. A self-only rollup now honestly
scores NONE, and a genuine foreign failure still blocks beside a cancelled
self entry — the must-fail guard against re-opening #136.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 16:12:17 +00:00
github-actions[bot]
84bb1a424d chore: bump main to 0.4.1-dev — a dev install must not impersonate 0.4.0
Some checks failed
CI / test (push) Has been cancelled
CI / release-exercise (push) Has been cancelled
CI / self-guards (push) Has been cancelled
CI / action-exercise (push) Has been cancelled
CI / docs-sync-exercise (push) Has been cancelled
release / release (push) Has been cancelled
2026-07-29 12:27:50 +00:00
Daniel Marin
0a54e31972
Merge pull request #207 from codex-bot-andresmgsl/build/206-release-0-4-0
Some checks failed
release / release (push) Has been cancelled
release: cut 0.4.0
2026-07-29 13:27:32 +01:00
codex-bot-andresmgsl
98c8dc2eb4 docs: record 0.4.0 live drill 2026-07-29 10:58:28 +00:00
codex-bot-andresmgsl
7c755bcd40 release: stamp 0.4.0 candidate 2026-07-29 10:51:51 +00:00
Daniel Marin
fa04d67033
Merge pull request #204 from codex-bot-andresmgsl/build/203-sweep-cadence-manual-sweep
docs: explain sweep cadence and manual dispatch
2026-07-29 11:40:36 +01:00
codex-bot-andresmgsl
4198597834 docs: correct labels maintenance cadence 2026-07-29 10:37:03 +00:00
codex-bot-andresmgsl
3eff28e624 docs: explain sweep cadence and manual dispatch 2026-07-29 09:27:29 +00:00
Daniel Marin
1de6b742f8
Merge pull request #202 from codex-bot-andresmgsl/build/198-contributing-flow-boundary
docs: make BUILDER the single PR flow contract
2026-07-29 10:23:37 +01:00
codex-bot-andresmgsl
6fe549cdb7 docs: make BUILDER the single PR flow contract 2026-07-28 22:31:55 +00:00
Daniel Marin
c4b49cd8d3
Merge pull request #200 from claude-bot-andresmgsl/build/199-labels-sweep-cadence
labels: reduce full-board sweep trigger frequency (#199)
2026-07-28 23:15:09 +01:00
claude-bot-andresmgsl
8cf3c335df labels: name what the hourly cron uniquely covers (#199 round)
The trigger comments, CONSUMERS stub, changelog, and reusable labels.yml
comment said "events carry every real state change in seconds; the cron
only backstops a forgotten handoff." That is backwards: no subscribed
event wakes this sweep for a review verdict landing (no pull_request_review
trigger), blocker:ci-red set/cleared, blocker:conflict when another PR
merges, or the time-based stale/48h-reclaim. The hourly cron is the sole
discovery path for those four classes, not a forgotten-handoff net —
so the comments now name them and warn against deleting the cron (AC5).

codex-2 / kimi-2 (both blocking, round 2 @0a812c4b).
2026-07-28 19:26:40 +00:00
claude-bot-andresmgsl
0a812c4b19 labels: keep edited/reopened on issues; correct 0.3.0 adoption prose (#199 round)
Round fixes on #200.

codex-1 (blocking): the issues narrowing dropped `edited`/`reopened`, but both
carry a queue-state change an event uniquely carries — `edited` a body rewrite
of the `Blocked by #N` declaration the sweep parses
(issueflow-reconcile.sh:179), `reopened` a closed issue re-entering the queue.
Dropping them tripped #199's must-fail. Narrow to
`[opened, closed, edited, reopened]`, dropping only the churn/validation
actions labeled/unlabeled/assigned/unassigned. Trigger tests now pin
edited/reopened present and the four dropped; labels.test.sh exact-list updated.

kimi (blocking): the "supersedes unreleased #144" prose was false — #144's
edited/reopened shipped in 0.3.0. Dissolved: we now keep them. CONSUMERS prose
rewritten to the real version history (0.2.0 #32 / 0.3.0 #144 / #199 narrows),
and the #137 review-request line corrected from "unreleased" to shipped-in-0.3.0.

kimi (non-blocking): reusable labels.yml comment no longer cites */15.

codex-2 (AC1 after-measurement / closing) escalated to triage on #199 — held,
not guessed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 19:04:27 +00:00
claude-bot-andresmgsl
155828a069 changelog: group the #199 fragment and split it under the 300-char bound
The tree is grouped (fragments carry ### headings) and changelog-armed
caps each '- ' entry at 300 chars; the single 574-char bullet failed
both. Rewrite as three '### Changed' bullets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 18:24:23 +00:00
claude-bot-andresmgsl
f8ad0b34c7 test: guard the #199 trigger surface; update #144 parity to [opened, closed]
Add test/labels-triggers.test.sh: reconcile stays cancel-in-progress:
false (the must-fail — true kills a sweep mid-board), the cron is hourly
not */15, each churn action is gone from the issues surface, and the PR
labeled handoff wake survives the issues narrowing. Update the #137/#144
parity block in labels.test.sh to the narrowed [opened, closed] contract
and replace its fragile inline pull_request_target scan (the #199 prose
comments name the trigger keys) with the anchored event_types reader.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 18:21:48 +00:00
claude-bot-andresmgsl
12887cc9de docs: narrow CONSUMERS stub issues triggers, relax cron to hourly (#199)
Bring the published caller stub to the #199 shape in lockstep with
self-labels.yml (preserving the stub<->caller parity #144 established):
hourly cron, issues: [opened, closed], per-trigger why-comments. Rewrite
the adoption prose — 0.2.0 shipped the broad set; #199 narrows it at its
pin bump and supersedes the unreleased edited/reopened additions (#144).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 18:13:15 +00:00
Daniel Marin
0e3d8de2a6
Merge pull request #197 from codex-bot-andresmgsl/build/196-round-log-handoff
docs: make round replies the durable handoff record
2026-07-28 19:11:10 +01:00
claude-bot-andresmgsl
dfafeedeec labels: relax self caller cron to hourly, narrow issues triggers (#199)
The advisory */15 cron fired a full-board sweep four times an hour at
GitHub's 1-minute billing floor; events carry real state changes in
seconds, so the cron only backstops a forgotten handoff. Relax it to
hourly and narrow the issues: trigger to [opened, closed] — the two
actions with promptness-critical reconcile behavior — dropping the
labeled/unlabeled churn source. Per-trigger why-comments added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 18:10:23 +00:00
codex-bot-andresmgsl
0405d206ae docs: align mechanical handoff wording 2026-07-28 17:57:31 +00:00
codex-bot-andresmgsl
ab77fa93ac docs: relocate handoff summary to round log 2026-07-28 17:04:33 +00:00
Daniel Marin
0a9316f7e1
Merge pull request #195 from codex-bot-andresmgsl/build/193-fleet-roster-audit
docs(fleet): move roster ownership to operator config
2026-07-28 12:08:48 +01:00
codex-bot-andresmgsl
5757e24cd4 docs(fleet): move roster ownership to operator config 2026-07-28 10:48:03 +00:00
Daniel Marin
c96d2d8f4e
Merge pull request #194 from codex-bot-andresmgsl/build/192-reduce-fleet-registry
docs(fleet): reduce registry mechanism to a pinned pointer
2026-07-28 11:18:56 +01:00
codex-bot-andresmgsl
7fc0425184 docs(fleet): reduce registry mechanism to a pinned pointer 2026-07-28 09:58:49 +00:00
Daniel Marin
432dff0613
Merge pull request #191 from dan-claude-bot/build/189-ci-red-deployed
docs(fleet): ci-red is deployed engine — advance the stamp to crew@4da17c4
2026-07-27 23:04:12 +01:00
dan-claude-bot
4f3fc2a20b docs(fleet): reconcile the attention wake too — the stamp covers the whole file
Refs #189. Round 1 on #191 (codex blocking, kimi nit).

codex caught that advancing the stamp to crew@4da17c4 made the attention
section false. It did, and the error is mine in kind, not only in detail: a
reconciliation stamp is a claim about the WHOLE file against that tree, and I
audited only the ci-red surface across a 17-commit advance. That is the #187
failure mode, on the PR that closes #189.

What was false at the pinned SHA, all from the crew#66 ruling (danmt,
2026-07-27) landing in d578150e:

- "One wake is registry-independent, by design: attention" — no wake is
  exempt now. _attention_partition splits rows against the registry; OUT rows
  are reported and never acted on.
- "the assignment is what carries the authorization — there is nothing here
  for a repo list to scope" — this is the position the ruling REJECTED. The
  cost was argued first: a cross-repo handoff now waits on an operator adding
  the repo, which is why an out-of-scope demand also pings the operator over
  the boot-gate channel rather than only reaching duty.log.
- "a fix that bounds this wake to the registry would re-create the #16
  incident" — that fix landed, with the ping as its mitigation.
- The parenthetical calling crew's repos-default.txt header a contradiction
  "raised there as a discussion" — the discussion is crew#66, it was ruled,
  and crew's header now names the attention wake explicitly. This file was
  preserving the losing side of a settled question.

Also in range and owned by this section: d849f166 ledgered the wake, so
"a session that dies before acking is relaunched; that is the whole
crash-recovery story" no longer is. Dying relaunches; COMPLETING without
acking is a decline, and the ledger stops it re-firing until the issue
moves. Meaning, not mechanism, so it belongs here.

Re-audited the rest of the advance rather than spot-fixing: fleet.roster
still declares itself the TARGET environment (the roster paragraph holds),
and the roster/install commits touch role resolution inside crew, which this
file does not describe.

kimi's nit, which the earlier rewrite made mine: the paper inventory counted
one row while the triage-signals bullet marks a second. Both are needs-ruling
rows and both are now named — a number that has to be recounted every time a
wake lands is the thing that went wrong.

18/18 test files; changelog_fragment_problem OK; shellcheck, self-ref and
git diff --check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:36:10 +00:00
dan-claude-bot
45934b54a2 docs(fleet): ci-red is deployed engine — advance the stamp to crew@4da17c4
Refs #189 — the post-merge, triage-owned task, now reachable: heavy-duty/crew#64
merged at 4da17c49594c2d86bd3793fa3567846cbca38e90.

#190 wrote the ci-red wake into FLEET.md deliberately marked on paper, per this
file's convention for a spec that is not yet running, and left the reconciliation
stamp at crew@01fb49c because advancing it to a SHA that did not carry the wake
would be the drift #187 exists to remove. crew#64 has merged, so both halves flip:

- The stamp advances to crew@4da17c4, and every crew permalink in the file with
  it. A stamp and its evidence links naming different trees is worse than no
  stamp: the reader diffs the wrong engine and finds no drift because the tree
  they were pointed at is the one the prose was written against.
- The four on-paper markers go: the duty-order caveat, the ci-red bullet's
  parenthetical, the Build bullet's "once ci-red deploys", and the closing
  paper inventory — which now names one remaining paper wake, the notifier's
  needs-ruling queue, not two.

The Build bullet is not a pure marker removal. crew#64's last review round
changed what it has to say: the operator ruled the round gate a whitelist, so
the wake admits a green head OR one with no checks configured, and holds a red
head AND one whose check has not finished. Copying the old "never a round at a
red head" through the flip would have shipped a fresh inaccuracy on the same
commit that claims the file is reconciled. The ci-red bullet gains the matching
sentence from the other side: an unfinished check is not a red head and wakes
nothing, because nothing has failed yet.

Verified at the stamped SHA rather than assumed: duty.sh's header carries
attention → … → resume → ci-red → build (and says it is what this file is
reconciled against), shared/README.md's duty order matches, and notify.sh's
only label filter is still state:needs-human — which is what keeps the
remaining paper claim true.

18/18 test files pass; changelog_fragment_problem OK (it caught a 387-char
entry against the 300 bound, now split); shellcheck, actionlint self-ref and
git diff --check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 21:22:07 +00:00
Daniel Marin
e3820dbf9a
Merge pull request #190 from claude-bot-andresmgsl/build/189-ci-red-doctrine
docs: the check at the head — round precondition, CI-red recovery, and the ci-red wake on paper
2026-07-27 21:25:20 +01:00
claude-bot-andresmgsl
44ff7524af docs(builder): re-request by head, not by verdict — a push stales every approval
The round protocol told the author to re-request exactly the reviewers
who did not approve, but the handoff predicate counts an approval only
at the current head: any push while answering a round staled the
earlier approver's verdict, doctrine said not to re-request them, and
the PR could never converge — the silent-stall shape of #26/#39.
Step 2 now re-requests by head, not by verdict: every panelist after a
push, the non-approvers alone at an unchanged head. Shape 2's wording
is aligned so the two paragraphs agree.

Defect raised by dan-claude-bot on #190; folded in at the operator's
direction while the paragraph is open. Refs #189.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 19:53:59 +00:00
claude-bot-andresmgsl
e9cf461ed3 docs(builder): avoid 'ledger' for review-verdict state in the red-head rule
Refs #189

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:47:00 +00:00
claude-bot-andresmgsl
e85a7d42f3 docs(fleet): ci-red in the duty order and wake list, on paper; changelog fragment
FLEET.md gains the ci-red wake between resume and build in crew#64's
engine position, marked on paper per this file's existing convention (the
notifier queue, triage's past-24h wake); the Build bullet records the
red-head exclusion as reported-not-swallowed; the reconciliation stamp
stays at crew@01fb49c because crew#64 has not merged.

Refs #189

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:46:10 +00:00
claude-bot-andresmgsl
203907b0ea docs(builder): green check at the head gates both request points; red-head recovery in picking
The round precondition (crew#45) at step 1 and the re-request in step 2,
with the argued exception and its evidence requirement; the ruled
classification (cancelled/stale not green, skipped/neutral green,
operator 2026-07-27); ci-red pickup precedence and crew#17's recovery
path in Picking, with the explicit shape-2 carve-out so a red head never
reads as parked.

Refs #189

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:45:03 +00:00
Daniel Marin
a414f3105b
Merge pull request #188 from claude-bot-andresmgsl/build/187-fleet-split
docs: FLEET.md reconciled against crew@01fb49c — registry is the scope
2026-07-27 19:29:15 +01:00
claude-bot-andresmgsl
7a49de7efb docs: FLEET.md reconciled against crew@01fb49c — registry is the scope
The two scope passages said the opposite of merged behaviour: a reviewer's
registry was 'the org itself' and 'no repo filter may gate' a request.
duty-review.sh implements repos.txt as the queue's scope since the
2026-07-25 operator ruling (crew#16) — out-of-scope requests WARN, never
act. The attention wake survives as the one stated registry-independent
exception: the assignment is the authorization (duty-attention.sh).

Mechanism moved to crew: the duty-loop anatomy and resilience prose is now
a pointer to crew's shared/README.md, folding the two drifted facts (cron
runs tick.sh; hygiene self-schedules inside the duty tick). Wake lists
follow the engine's duty order; the roster keeps the as-built bench beside
fleet.roster's target with the delta stated; stamp updated to crew@01fb49c.

Closes #187

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 18:16:17 +00:00
Daniel Marin
d9c5b92dd1
Merge pull request #186 from claude-bot-andresmgsl/build/184-blocked-by-union
fix: blocked_reference_records unions every Blocked by clause (#184)
2026-07-25 16:40:25 +01:00
claude-bot-andresmgsl
0eea112d50 fix: blocked_reference_records unions every Blocked by clause
Binding to the first marker occurrence dropped every later sentence of a
repeated declaration and let earlier prose hijack the parse — the false
ready promotion on rig#154. Each occurrence now contributes its own
clause, terminated at its own first ./; (unterminated -> end of input),
and the union feeds the unchanged classification and decision table.

Closes #184

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:58:39 +00:00
Daniel Marin
80c0dca3b0
Merge pull request #183 from claude-bot-andresmgsl/build/182-shape-sentinel
lib/changelog.sh + changelog-armed — the shape anchor becomes declarable; ceremony flips to grouped
2026-07-25 14:43:53 +01:00
claude-bot-andresmgsl
0b158a6917 fix: the sentinel's one-line contract is checked on the file, not the $(cat) word
Command substitution strips every trailing newline, so 'grouped\n\n'
reached the case as a clean word and passed — the round's shared blocker
(codex, grok). A line count taken from the file itself now refuses any
physically multi-line sentinel before the word check runs, with the
existing diagnosis naming the file. Red rows: grouped/flat with a
trailing blank line in the unit suite, grouped in the armed suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:29:02 +00:00
claude-bot-andresmgsl
be666ebed6 feat: ceremony flips to grouped — changelog.d/shape = grouped, five fragments converted, docs per D6
167/175 gain '### Added', 173/178 '### Changed', 180 '### Fixed' — every
bullet byte-identical, headings only (the #158 bar, inverted). CONSUMERS.md
names the sentinel and the flip procedure; changelog.d/README.md names the
sentinel. 182.md is this PR's own fragment, grouped atop the sentinel it
ships.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:14:05 +00:00
claude-bot-andresmgsl
2fd9cb7ded test: sentinel rows across the shape predicate, the armed guard, and the assembler
Must-pass and must-fail rows from #182's test plan: the flip shape green
under the sentinel, drift and malformed sentinels red with file named, the
D3 fragment-list assertion, and D5 sentinel survival through consumption.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:12:30 +00:00
claude-bot-andresmgsl
7d29acdeb0 feat: changelog_shape_problem reads the declarable anchor changelog.d/shape
The sentinel pins the set's shape, outranking the newest-published-section
inference (#182 D2); malformed content is a red diagnosis naming the file.
bin/changelog-assemble skips 'shape' in the stray-file loop so the sentinel
survives the consumption (#182 D5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 13:09:37 +00:00
Daniel Marin
4debf53872
Merge pull request #181 from claude-bot-andresmgsl/build/180-blocked-excludes-nh
fix: `blocked` excludes `state:needs-human` in decide_state()
2026-07-25 11:31:23 +01:00
Daniel Marin
ede2767232
Merge pull request #176 from codex-bot-andresmgsl/build/175-post-merge-queue-state
feat: add post-merge issue queue state
2026-07-25 11:31:08 +01:00
claude-bot-andresmgsl
1c9a82aaf0 fix: blocked excludes state:needs-human in decide_state (#180)
During the ceremony#111 freeze, rig#126/#128 carried blocked beside
state:needs-human — the round had finished, but the hold said the merge
must not happen, and rig#126 was merged seven minutes after the
reconciler wrote the green label. decide_state() only joined the two
axes through blockers(), which emits branch facts; the hand-set blocked
label was never consulted.

blocked becomes the second exclusion on state:needs-human, exactly
parallel to needs-ruling: round says needs-human + has_label blocked ->
state:addressing. Deliberately not a blockers() emission — BLOCKERS is
machine-owned and the converge loop would strip the live hold on the
next tick, the same trap #51 names for needs-ruling.

Ruling record: discussion 122, armed default A fired 2026-07-25T09:00Z.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 09:08:54 +00:00
Daniel Marin
24255a97cd
Merge pull request #179 from claude-bot-andresmgsl/build/178-park-stands
docs: a park is declared once and stands; a nothing-changed resumption posts nothing
2026-07-25 08:02:52 +01:00
codex-bot-andresmgsl
5a3d72f09c fix: make post-merge transitions episode-aware 2026-07-25 04:29:37 +00:00
claude-bot-andresmgsl
9ff8ed6c03 docs: a park is declared once and stands; nothing-changed resumptions post nothing
The park contract said declared-never-inferred but not that the
declaration stands, so a conservative builder re-declared every ~5
minutes — rig#145 collected 38 identical resumption audits in one
night. Now the declaration stands until the park's facts change; the
only repeat owed is the no-open-PR refresh inside the 48-hour reclaim
window. Decided on #177.

Closes #178

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 04:27:04 +00:00
codex-bot-andresmgsl
24dd818b35 test: cover post-merge queue boundaries 2026-07-25 00:14:23 +00:00
codex-bot-andresmgsl
bb7dd51ba7 feat: transition merged refs work to post-merge 2026-07-25 00:11:42 +00:00
codex-bot-andresmgsl
8e6423a07c docs: define post-merge queue state 2026-07-25 00:09:14 +00:00
Daniel Marin
486bbd10a4
Merge pull request #174 from codex-bot-andresmgsl/build/173-private-read-scopes
docs: add actions read to private caller guidance
2026-07-25 00:35:46 +01:00
codex-bot-andresmgsl
eae000bd62 docs: add actions read to private caller guidance 2026-07-24 22:57:22 +00:00
Daniel Marin
68354f846a
Merge pull request #169 from claude-bot-andresmgsl/build/167-fragment-entry-bound
changelog_fragment_problem — entries are bounded at 300 characters
2026-07-24 19:54:09 +01:00
claude-bot-andresmgsl
6c746364af feat: changelog_fragment_problem bounds entries at 300 characters (#167)
One definition in the fragment predicate; changelog-armed reds the PR
that writes the fragment and the assembler refuses at release, both by
inheritance. Doctrine names the number in BUILDER.md and CHANGELOG.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 18:25:46 +00:00
github-actions[bot]
9008e03b03 chore: bump main to 0.3.1-dev — a dev install must not impersonate 0.3.0 2026-07-24 17:39:49 +00:00
Daniel Marin
bc469d9de5
Merge pull request #164 from codex-bot-andresmgsl/build/160-release-0-3-0
release: cut 0.3.0
2026-07-24 18:39:38 +01:00
codex-bot-andresmgsl
66c449dd3a docs: record 0.3.0 live drill 2026-07-24 17:12:55 +00:00
codex-bot-andresmgsl
da186729c5 release: prepare 0.3.0 2026-07-24 17:06:25 +00:00
Daniel Marin
a7aedfd081
Merge pull request #163 from codex-bot-andresmgsl/build/159-changelog-shape-guard
feat: fail shape drift on the introducing PR
2026-07-24 17:56:58 +01:00
codex-bot-andresmgsl
eb25b38c14 test: cover grouped anchored release replay 2026-07-24 16:17:02 +00:00
codex-bot-andresmgsl
fdf544b390 feat: enforce changelog shape in guard and assembler 2026-07-24 16:14:35 +00:00
codex-bot-andresmgsl
ef658f52a4 feat: centralize changelog shape validation 2026-07-24 16:12:52 +00:00
Daniel Marin
f19d671b74
Merge pull request #162 from claude-bot-andresmgsl/build/158-strip-grouped-headings
fix: strip the grouped headings from the four drifted fragments
2026-07-24 17:05:32 +01:00
claude-bot-andresmgsl
fb6f16ae0c fix: strip grouped headings from the four drifted fragments
Ceremony is a flat repo (#112 D3); changelog.d/135.md, 137.md, 144.md and
151.md landed with a '### Fixed'/'### Changed' heading, each individually
legal to changelog_fragment_problem, and together they made the directory
mixed-shape — 'bin/changelog-assemble 0.3.0 --check' refused on main.
Delete the heading line and its following blank line from each; every
bullet stays byte-identical (#157 D1). No fragment for this PR: its whole
diff is unpublished fragment text (#157 D2).

Closes #158

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:46:48 +00:00
Daniel Marin
f168bbfbac
Merge pull request #155 from claude-bot-andresmgsl/build/154-hold-ends-on-labels
docs: a directive hold ends on the labels, and stale hold prose is triage's to correct
2026-07-24 16:10:05 +01:00
Daniel Marin
cb5bfb1e01
Merge pull request #153 from claude-bot-andresmgsl/build/149-fleet-reviewer-wake
docs: FLEET.md's reviewer wake describes the deployed requested_reviewers sweep
2026-07-24 15:40:42 +01:00
claude-bot-andresmgsl
baf0c04f1f fix: TRIAGE.md cites the real #151 needs-ruling comment; BUILDER.md timestamp to the second
The permalink for the needs-ruling ask on #151 pointed at a comment id
that does not exist; the ask is comment 5070768876 (dan-claude-bot,
2026-07-24T14:10:34Z). Also corrects the #149 claim citation from
14:11:44Z to the comment's actual 14:11:45Z.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:37:52 +00:00
Daniel Marin
0e91a0d62f
Merge pull request #152 from claude-bot-andresmgsl/build/151-refs-not-closes
docs: a post-merge acceptance criterion rides Refs #N — triage closes on the evidence
2026-07-24 15:27:43 +01:00
claude-bot-andresmgsl
d9d73878cc docs: a directive hold ends on the labels; stale hold prose is triage's to correct
BUILDER.md shape 5 gains its ending: the hold owner's most recent
queue-label event governs over stale prose, the timeline read comes
before standing down or up on a hold, a claim against stale prose cites
the events it read, and a refused claim has two exits. TRIAGE.md
requires re-reading label events before asserting label-borne state and
makes a lifted hold's stale body header triage's to correct in the same
tick.

Closes #154

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:23:16 +00:00
claude-bot-andresmgsl
5f491ffc1f docs: FLEET.md's reviewer wake describes the deployed requested_reviewers sweep
The Reviewers bullet stated a gh-search request trigger and a sequential
first-this-second-that ordering; all four reviewer boxes actually run an
org-wide pulls-API requested_reviewers sweep merged with the repos.txt
backstop, deduplicated by (repo, PR) before acting (crew@b2fd864). The
on-paper list narrows to the notifier's needs-ruling queue, repos.txt is
the registry only on the triage box, and the Status block now carries the
crew ref this description was last reconciled against.

Part of the drift #148 reported; spec and citations in #149.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 14:14:36 +00:00
Daniel Marin
9e960f8dab
Merge pull request #150 from claude-bot-andresmgsl/build/144-consumers-issues-parity
fix: the CONSUMERS.md stub's issues: types match the caller's, parity-tested
2026-07-24 14:56:29 +01:00
claude-bot-andresmgsl
b3b9830700 docs: a post-merge acceptance criterion rides Refs #N — triage closes on the evidence
Merging #143 auto-closed #137 with a post-merge criterion unmet: the PR
carried Closes #137 as doctrine required, the criterion could only be
checked after the merge, and the contract was silent on the disagreement
between GitHub's keyword and the pipeline's close authority — the same
disagreement the cross-repo carve-out already resolved, one case over.

BUILDER.md gains the second exception beside the first: when the issue
body marks a criterion post-merge, the same-repo PR uses Refs #N and
triage closes by hand on the evidence. TRIAGE.md makes the criterion
carry its own mechanism; REVIEWER.md lists Refs #N as a spec pointer and
stops treating the reference-only PR as a defect; CONTRIBUTING.md points
at the one home instead of restating. No machinery, no label (#151 D5).

Closes #151

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:48:27 +00:00
Daniel Marin
e8675548ce
Merge pull request #147 from claude-bot-andresmgsl/build/135-drill-cleanup
docs: the drill's delete is the operator's step — the builder archives
2026-07-24 14:46:28 +01:00
claude-bot-andresmgsl
0b77d4b860 fix: the CONSUMERS.md stub's issues: types match the caller's, parity-tested
The stub published [opened, labeled, unlabeled, assigned, unassigned,
closed] while ceremony's own caller listens on eight types — PR #32's
70db91f widened the caller by edited and reopened and the stub never
followed. Both are load-bearing: an edited body rewrites the Blocked-by
declaration the reconcile sweep parses, and a reopened issue re-enters
the queue wearing labels derived at close (#144).

The stub's list is now byte-identical to the caller's, a parity row in
test/labels.test.sh keeps it that way (red on a dropped type, a drift,
or a reorder in one file only), and one adoption note names the tag the
widened list rides in on. The caller does not narrow.

Closes #144

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:43:35 +00:00
Daniel Marin
6a16a30bab
Merge pull request #143 from claude-bot-andresmgsl/build/137-review-request-wake
fix: review_requested wakes the labels sweep — blocker:unrequested clears when the ask lands
2026-07-24 14:33:11 +01:00
Daniel Marin
0fcd818396
Merge pull request #146 from codex-bot-andresmgsl/build/145-review-mechanics-doctrine
docs: teach reviewers the queue mechanics
2026-07-24 14:32:57 +01:00
claude-bot-andresmgsl
89de86c460 chore: retrigger checks — queued reconcile duplicate was cancelled (#139 shape)
48a3052's only labels/reconcile entry is a queue-evicted CANCELLED (its
scope sibling in the same run passed); an all-cancelled group keeps
blocking by design under #139's rule, so only a fresh run on this head
can clear the manufactured red. No verdict binds 48a3052 yet, so this
head move stales nothing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:20:22 +00:00
claude-bot-andresmgsl
357be8e65d docs: the drill's delete is the operator's step — the builder archives (#135)
Both 0.2.0 drills ended at the same wall independently: bot tokens
deliberately lack delete_repo, so 'it gets deleted at the end' named a
step no builder in this fleet can perform. One drill held its release
draft in state:building retrying a 403 that cannot succeed; the other
shipped a record asserting a delete that had not happened. Step 1 now
names archive (archived:true, inside the repo scope) as the builder's
end state and the delete as the operator's, states that cleanup gates
nothing, and says why the archived leftover is safe to leave. The
record now states the disposal its author actually observed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:16:42 +00:00
Daniel Marin
089f2dba29
Merge pull request #140 from claude-bot-andresmgsl/build/139-cancelled-not-verdict
fix: a queue-cancelled duplicate check is not a verdict — checks_state discards it when a real one stands
2026-07-24 14:14:36 +01:00
Daniel Marin
ad5c175d0c
Merge pull request #133 from claude-bot-andresmgsl/build/130-labels-scope-clobber
fix: labels/scope writes additively — a label applied mid-job survives
2026-07-24 14:14:03 +01:00
Daniel Marin
4bb6ee8f12
Merge pull request #141 from codex-bot-andresmgsl/build/131-release-pr-changelog-exemption
docs: state the release PR changelog exemption
2026-07-24 14:13:45 +01:00
claude-bot-andresmgsl
48a3052934 docs: CONSUMERS release-state notes say 0.2.0 shipped the issues: block (#137 D6)
The three sites called ceremony#32 machinery unreleased after 0.2.0
(tagged 2026-07-24) became the first tag carrying it: the in-stub comment
above the issues: block, the issues: adoption paragraph, and the
triage-actors= paragraph. All three now state availability at 0.2.0 and
later; the 0.1.0-omission guidance and the parse-failure sentence stay —
still true. No type list moves (the caller/stub issues: subset drift is
#144, not this PR).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 13:13:31 +00:00
codex-bot-andresmgsl
1233e9b1b6 docs: capture reviewer queue mechanics 2026-07-24 13:12:21 +00:00
claude-bot-andresmgsl
c0e796b6c9 fix: review_requested wakes the sweep — blocker:unrequested clears when the ask lands
The reconciler's rule was right and blind: the caller never listened on
review_requested/review_request_removed, so the one event that falsifies
(or restores) blocker:unrequested could not clear it, and a quiet repo
wore the red flag until the advisory cron (#137's timeline: 93 seconds,
cleared only by an unrelated PR's push).

- self-labels.yml + the CONSUMERS.md stub gain both types; the scope job
  skips them (no paths change; running labeler there widens #130's window)
- test/labels.test.sh: caller/stub parity row with mutation cases —
  dropped type either side, one-sided reorder, all red
- CONSUMERS.md no longer claims trigger adoption is a bare pin bump; the
  pending stub edit is named and rides the first tag carrying ceremony#137

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 12:50:09 +00:00
Daniel Marin
7b97554f19
Merge pull request #136 from claude-bot-andresmgsl/build/134-facts-root-commit
fix: facts.sh reads a parentless head as base_ver=(none), not exit 128
2026-07-24 13:47:33 +01:00
codex-bot-andresmgsl
050a24db8a fix: format changelog fragment as an entry 2026-07-24 12:43:17 +00:00
codex-bot-andresmgsl
3792d52b82 docs: exempt release PRs from changelog fragments 2026-07-24 12:41:46 +00:00
github-actions[bot]
ae6b509772 chore: bump main to 0.2.1-dev — a dev install must not impersonate 0.2.0 2026-07-24 12:36:19 +00:00
Daniel Marin
94e019b3fc
Merge pull request #128 from codex-bot-andresmgsl/build/118-release-0-2-0
release: 0.2.0
2026-07-24 13:36:07 +01:00
claude-bot-andresmgsl
d8f54aab04 fix: a queue-cancelled duplicate check is not a verdict
checks_state discards a CANCELLED entry only when its context group holds
at least one non-cancelled sibling — before the sort, so the duplicate the
repo-global reconcile queue evicted (after it had already attached a check
to the head) cannot outvote the success that did its work (#136 a17e497,
#133 4002924). An all-cancelled context never reported at all and still
classifies FAILURE; {FAILURE older, CANCELLED newest} keeps its red.

The fixture that pinned the opposite rule imagined a cancelled run
replacing a success; it never saw one that replaced nothing. Rewritten
with its reason, plus the recorded a17e497 shape, the all-cancelled
groups, and the cancelled-over-FAILURE case.

Closes #139

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 12:34:51 +00:00
claude-bot-andresmgsl
94f890aa1c chore: retrigger checks — queued reconcile duplicate was cancelled
Same pattern as PR #136's fa76851: the labels/reconcile run for 4002924
was a queued duplicate cancelled by the repo-global concurrency group,
GitHub refuses a rerun, and the reconciler reads the cancellation as
ci-red. Nothing in the tree changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 12:23:29 +00:00
claude-bot-andresmgsl
40029242cb fix(round): labeler.yml header tells the truth; labels-scope maps to scope:labels
The mapping header still described actions/labeler@v5 + sync-labels —
the exact mechanism this PR removed; it now describes labels-scope's
base-ref read and additive POST, keeping the #128 incident. The
scope:labels row gains actions/labels-scope/** and its test, and two
fixtures derive against the real mapping so the coverage is tested, not
just present. The reconcile job comment names labels-scope instead of
labeler (grok nit 3).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 12:22:09 +00:00
claude-bot-andresmgsl
fa768510bf chore: retrigger checks — queued reconcile duplicate was cancelled
The labels/reconcile job on a17e497 was a queued duplicate cancelled by
its repo-global concurrency group (the sweep it duplicated passed seconds
earlier); GitHub refuses to rerun it, and the reconciler read the
cancellation as ci-red. Nothing in the tree changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 12:19:27 +00:00
codex-bot-andresmgsl
a02a538298 docs: record archived drill repository 2026-07-24 12:16:31 +00:00
claude-bot-andresmgsl
a17e49737f test: root-commit fixtures — (none) base, D2 loud-death pin, e2e chain
facts.test.sh: greenfield fixtures for all-zeros and empty event.before,
the bare root establishing labeled=no, and the D2 pin (an unresolvable
MERGE_SHA exits 128 and never reports base_ver=(none) — the test that
|| true would fail). release-chain.test.sh: chain() gains optional
repo/stub args; a -dev root commit is a green NOTICE ceremony=no, a bare
unlabeled root still refuses. Plus changelog.d/134.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 12:16:04 +00:00
claude-bot-andresmgsl
7477d3ec95 fix: facts.sh reads a parentless head as base_ver=(none) (#134)
A repository's first push to main is a branch-create push whose head is a
root commit: event.before is all-zeros and MERGE_SHA^1 does not exist, so
the fallback died at exit 128 before establishing a fact. The parent count
is now read via rev-list --parents (a fact, not an inferred failure), the
no-base path skips the belt-and-braces fetch and the base git show, and an
unresolvable MERGE_SHA still fails loudly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 12:14:07 +00:00
claude-bot-andresmgsl
ce24a1a3ba polish: unreadable base version reads 'unreadable' in the release-shape warning
Part of #130.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 12:07:57 +00:00
claude-bot-andresmgsl
d0b857eb10 docs: the additive-scope contract in CONSUMERS/LABELS; changelog fragment
Part of #130.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:57:10 +00:00
claude-bot-andresmgsl
16dfdb9a4f feat: reconciler warns on a release-shaped PR missing its release label
Bare X.Y.Z at the head where the base says otherwise, no release label,
not a draft: the sweep emits one :⚠️: per pass naming both
versions. A warning only — release is declared intent and the reconciler
never guesses intent (LABELS.md). Version read via the API, both
backends, jq not node; unreadable reads nag nobody. Plus the yq test
contract in CI and fixture tests for the guard matrix.

Part of #130.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:54:49 +00:00
codex-bot-andresmgsl
c6efd379f0 docs: make the release drill fork shape permanent 2026-07-24 11:51:55 +00:00
claude-bot-andresmgsl
53efbfba2a fix: scope job derives additively — labeler v5's PUT clobbered mid-job labels
actions/labeler@v5 writes the whole label set (setLabels PUT) even under
sync-labels: false, so a label applied between its read and its write is
silently removed — ceremony#128 lost `release` that way. v6/v7 write the
same way. Replace the step with actions/labels-scope: same labeler.yml
mapping (changed-files/any-glob-to-any-file subset, refused loudly
otherwise), changed paths via the API, and an additive POST as the only
write.

Part of #130.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:51:48 +00:00
codex-bot-andresmgsl
2aeace5356 release: record the 0.2.0 live drill 2026-07-24 11:39:25 +00:00
codex-bot-andresmgsl
b632c19e97 release: assemble 0.2.0 2026-07-24 11:21:26 +00:00
Daniel Marin
a602fd0a70
Merge pull request #125 from claude-bot-andresmgsl/build/117-changelog-d-flip
ceremony adopts changelog.d — the flag flip
2026-07-24 12:18:23 +01:00
claude-bot-andresmgsl
ae45cbd894 docs: split the two assembled-failure shapes in README
The changelog-armed 'Red means' list credited armed with catching a
release that publishes fewer entries than its consumed fragments — armed
cannot see consumed fragments; that is changelog-assembled's merge-base
replay. And the assembled narrative still said a fragment dropped from
the deletion leaves armed green; the trio rows record the opposite: a
surviving fragment reds armed too ('not consumed'), and assembled stands
alone only on the consumed-but-omitted and hand-edited shapes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:11:23 +00:00
claude-bot-andresmgsl
1e18e7fbe1 test: exercise release fixtures fragment-shaped; record the #115/#116 trio interaction
release-exercise.yml's fixture arms with changelog.d/ and stamps through
the real assembler (#112 D12). The assembled suite's trio row expected
changelog-armed green on the dropped-entry tree — true when #116 was
written, false since #115's fragment mode landed after it (main is red at
736733e on exactly this row); the row now asserts the interaction as it
stands, and the hand-edited tree carries the only-red claim.
2026-07-24 11:11:23 +00:00
claude-bot-andresmgsl
566712c690 docs: CONSUMERS and drills describe fragment-mode adoption
Bootstrap arms with a preamble plus changelog.d/, the guard list gains
changelog-assembled, the conversion checklist gains the fragment
conversion, the changelog rule is fragment-first with the legacy floor
kept for unconverted pins, and the assembly command block gives a reader
everything needed to produce a section (#112 D12, #117).
2026-07-24 11:11:23 +00:00
claude-bot-andresmgsl
a3e59241b5 docs: builder surfaces write fragments, never CHANGELOG.md
CONTRIBUTING's flow item, BUILDER.md's entry rule, and the PR template
checklist all point at changelog.d/<issue>.md; the insert-above warning
retires with its anchor while the monotonic guard keeps the case (#117).
2026-07-24 11:11:23 +00:00
claude-bot-andresmgsl
c6e9afee87 docs: README describes the assembled stamp and the four guards
Stamp 2 is one assembler-produced edit; changelog-armed's rule is stated
mode-first (fragment, then legacy, #112 D7/D8/D9); changelog-assembled
gets its operator section (#116); monotonic records D10; the two rewritten
error strings and the retired re-arm recovery follow (#117).
2026-07-24 11:11:23 +00:00
claude-bot-andresmgsl
819dc7d992 feat: convert Unreleased entries to changelog.d fragments
The flip's mechanical half (#117): the 26 entries under '## Unreleased'
move verbatim to changelog.d/<issue>.md, the heading is deleted, the
directory gains its marker README (#112 D1) and this PR's own fragment
(112.md), CHANGELOG.md's preamble describes fragments, and labeler.yml
maps changelog.d/** into scope:release-flow.
2026-07-24 11:11:23 +00:00
Daniel Marin
d84d8a5d31
Merge pull request #127 from codex-bot-andresmgsl/build/126-changelog-assembled-trio
test: correct changelog guard trio interaction
2026-07-24 12:05:58 +01:00
codex-bot-andresmgsl
e37c2dfeef test: correct changelog guard trio interaction 2026-07-24 10:36:30 +00:00
Daniel Marin
736733ebf8
Merge pull request #123 from codex-bot-andresmgsl/build/115-changelog-armed-fragment-mode
feat: arm changelogs in fragment mode
2026-07-24 11:11:40 +01:00
Daniel Marin
fc070ea896
Merge branch 'main' into build/115-changelog-armed-fragment-mode 2026-07-24 11:11:03 +01:00
Daniel Marin
beeac27db8
Merge pull request #119 from codex-bot-andresmgsl/build/113-directed-hold-park
docs: define directed-hold parked claims
2026-07-24 11:08:06 +01:00
Daniel Marin
531b8af54c
Merge pull request #124 from claude-bot-andresmgsl/build/116-changelog-assembled
actions/changelog-assembled — the release PR's section must be exactly the fragments it consumed
2026-07-24 11:07:43 +01:00
Daniel Marin
d1b1079feb
Merge pull request #107 from codex-bot-andresmgsl/build/105-missing-core-label-warning
feat: warn when core taxonomy labels are missing
2026-07-24 11:07:14 +01:00
Daniel Marin
be67b48ee9
Merge pull request #108 from claude-bot-andresmgsl/build/104-scope-table-delete
LABELS.md: delete the scope table — the per-repo set lives in labels.conf and the repo's CONTRIBUTING
2026-07-24 10:48:32 +01:00
claude-bot-andresmgsl
185fc39b96 ci: self-guards runs changelog-assembled; changelog entry 2026-07-24 09:31:49 +00:00
claude-bot-andresmgsl
c33567686b test: drive changelog-assembled against constructed histories 2026-07-24 09:30:58 +00:00
claude-bot-andresmgsl
499783e241 feat: changelog-assembled — the release section must be exactly the fragments it consumed 2026-07-24 09:29:07 +00:00
codex-bot-andresmgsl
22f1a0246f docs: explain core taxonomy bootstrap maintenance 2026-07-24 09:24:40 +00:00
codex-bot-andresmgsl
f95692091f docs: record directed-hold doctrine 2026-07-24 09:23:12 +00:00
codex-bot-andresmgsl
17e888a6b1 docs: define directed-hold parked claims 2026-07-24 09:23:01 +00:00
codex-bot-andresmgsl
edbf30867e feat: warn when core taxonomy labels are missing 2026-07-24 09:22:41 +00:00
codex-bot-andresmgsl
fffc95633e test: pin missing core label warning contract 2026-07-24 09:22:41 +00:00
codex-bot-andresmgsl
439b5cf8a5 docs: record fragment arming guard 2026-07-24 09:21:24 +00:00
codex-bot-andresmgsl
21492ffeb9 feat: arm changelogs in fragment mode 2026-07-24 09:20:27 +00:00
codex-bot-andresmgsl
386ce67bd1 test: specify changelog-armed fragment mode 2026-07-24 09:19:18 +00:00
Daniel Marin
6ec9aa24fa
Merge pull request #120 from claude-bot-andresmgsl/build/114-changelog-assemble
lib/changelog.sh + bin/changelog-assemble — read the fragments, assemble one section, consume them
2026-07-24 10:10:28 +01:00
claude-bot-andresmgsl
9d1eb81037 test: guard LABELS.md against any concrete scope name
The regression row grepped only the four current ceremony names, so a
future enumeration under new names stayed green. Widen the pattern to
scope:[a-z0-9] — any concrete label name, in any shape, re-reds the row,
while doctrine's bare `scope:` and wildcard scope:* stay allowed (#104).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:32:53 +00:00
claude-bot-andresmgsl
ee0aab1c69 docs: delete LABELS.md's scope table — the per-repo set lives in labels.conf and CONTRIBUTING
The mirror is byte-identical in every governed repo, so the four-row
enumeration was true at home and false in rig, box, cast and incubator —
14 of 16 vendored rows lied. The section keeps its doctrine and points at
the two places true wherever the reader stands; ceremony's own set is now
a pointer sentence in CONTRIBUTING, and a labels.test.sh row (red on main,
4 hits) keeps enumeration from returning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:32:53 +00:00
claude-bot-andresmgsl
4c0ecf10d8 test: lib fragment trio and the changelog-assemble CLI; changelog entry
test/changelog.test.sh drives changelog_fragments (order, marker, absent
dir), changelog_fragment_problem (every rule, file named each time), and
changelog_assemble (both shapes, canonical order, mixed-shape refusals).
test/changelog-assemble.test.sh drives the CLI against constructed trees:
exact-byte writes, provably read-only --check, every refusal from the
spec, the publisher/assembler round trip, and idempotence.

Closes #114.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:28:15 +00:00
claude-bot-andresmgsl
fffff75b80 feat: fragment reader, well-formedness predicate, assembler, and bin/changelog-assemble
lib/changelog.sh gains changelog_fragments (publication order: trailing
issue number descending, filename tie-break), changelog_fragment_problem
(the release-time rules moved onto the PR that writes the fragment, #112
D9), and changelog_assemble (canonical group order per D5, one shape per
repo per D4). bin/changelog-assemble folds changelog.d/ into one release
section, deletes exactly what it consumed, and --check proves the body
without touching the tree.

Part of #112. Closes #114 groundwork; tests follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:25:44 +00:00
Daniel Marin
d4e133bc1f
Merge pull request #106 from claude-bot-andresmgsl/build/101-degraded-read-reason
labels-reconcile: report why the degraded read degraded; blind-sweep warning stops asserting an unobserved cause
2026-07-24 09:24:00 +01:00
claude-bot-andresmgsl
09d2ea764f test: pin the two-line degrade, the bounded reason, and the demoted diagnosis
The unit block now feeds blind_sweep_warning a sampled reason and asserts
the new lead plus two must-fail guards: the disproven 'grant checks: read
and statuses: read' diagnosis stated as fact goes red, and so does any
drift in the counted line's whole-line shape (exactly the blind PRs match,
no more, no less — a reason line that matched would double-count, a folded
reason would undercount). read_failure_reason is covered pure: D4 wording
for empty stderr, multi-line collapse to one line, 400 chars truncated to
300 plus ellipsis within the 304-byte bound, 300 passing through whole.
blind_main_probe's gh pr view stub now fails with a denial on stderr, the
way real gh fails.

Part of #101.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 07:34:46 +00:00
claude-bot-andresmgsl
fbfe7dd1c1 feat: report why the degraded read degraded
The reconciler's mergeability/checks read kept its correct degrade but
threw the reason away: 2>/dev/null dropped gh's stderr, leaving a
permanent denial and a network hiccup byte-identical in the log (#95 had
to infer a cause from a control case, and the inference did not survive
incubator#48/#49). Capture stderr into a variable via a temp file (D2),
emit it as its own '#N: read failed: …' line beside the byte-identical
counted line (D1), collapsed and bounded by a pure helper (D3/D4), and
lead blind_sweep_warning with the sampled observed reason, demoting the
permissions hint from stated cause to named candidate (D5).

Part of #101 groundwork; tests and changelog follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 07:34:33 +00:00
Daniel Marin
2f58d9bf54
Merge pull request #110 from claude-bot-andresmgsl/build/109-fourth-parked-shape
BUILDER.md: the handed-off PR is the fourth parked shape; the handoff is its declaration
2026-07-24 08:31:13 +01:00
claude-bot-andresmgsl
2422604f50 docs: park the handed-off claim as the fourth shape
The slot rule counted work in flight but had no shape for its most common
wait: the round passed, state:needs-human set, the human's merge pending.
Shape 4 names it, the handoff round summary is its declaration, and shape
2 now covers the round awaiting its first verdicts so the live and passed
rounds are sequential and non-overlapping (#109).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 07:01:07 +00:00
Daniel Marin
ad04eaf990
Merge pull request #99 from codex-bot-andresmgsl/build/98-publishable-changelog-sections
feat: validate publishable changelog sections
2026-07-24 07:59:02 +01:00
codex-bot-andresmgsl
ce27861821 test: cover absent Unreleased section 2026-07-24 00:26:48 +00:00
codex-bot-andresmgsl
8ad68192e9 docs: seed grouped changelog re-arms 2026-07-23 23:45:04 +00:00
codex-bot-andresmgsl
69410723bd fix: reject entry-less release notes 2026-07-23 23:44:51 +00:00
codex-bot-andresmgsl
0e489438de feat: enforce publishable changelog entries 2026-07-23 23:44:51 +00:00
codex-bot-andresmgsl
73bee80faa feat: define publishable changelog sections 2026-07-23 23:44:51 +00:00
Daniel Marin
5af1538463
Merge pull request #96 from codex-bot-andresmgsl/build/95-labels-blind-sweep-warning
fix: surface wholly blind label sweeps
2026-07-24 00:43:17 +01:00
codex-bot-andresmgsl
1d3e98497d Merge remote-tracking branch 'origin/main' into build/95-labels-blind-sweep-warning
# Conflicts:
#	CHANGELOG.md
#	test/labels-reconcile.test.sh
2026-07-23 23:15:08 +00:00
codex-bot-andresmgsl
970d58b2c6 test: prove blind sweeps leave PRs untouched 2026-07-23 23:13:24 +00:00
Daniel Marin
6b127f1ba7
Merge pull request #94 from claude-bot-andresmgsl/build/93-retire-default-labels
labels-reconcile: retire the six GitHub default labels at bootstrap (#93)
2026-07-24 00:12:38 +01:00
codex-bot-andresmgsl
2c57216a56 fix: surface wholly blind label sweeps 2026-07-23 23:12:30 +00:00
claude-bot-andresmgsl
d76d3b6136 test: keep the bootstrap probes shellcheck-clean
CI's shellcheck gate treats info findings as red. The gh() stubs paired
with bootstrap_labels are reached only through run's "$@", which
shellcheck cannot trace (the older probes stub reconcile_pr, which calls
gh directly), so they carry reasoned SC2317 directives; the LABELS.md
doctrine parse carries SC2016 for its literal backticks. Probes now live
in named functions, matching the house *_probe() pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:18:46 +00:00
claude-bot-andresmgsl
019ed5b68e test: pin bootstrap retirement against absence, refusal and set -e
Registry-vs-LABELS.md identity, happy path, missing label, one-name 403,
DRY_RUN narration, and the executed-subprocess dispatch (#91's lesson: a
sourced probe cannot see set -e). Cron and pull_request_target delete
nothing. Verified red without the guard: the unguarded dispatch dies on
the FIRST absent label, one delete of six attempted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:13:07 +00:00
claude-bot-andresmgsl
27aa03464c feat: retire the six GitHub default labels at bootstrap
LABELS.md publishes them as deleted at bootstrap; nothing deleted them —
incubator's first dispatch left `good first issue` standing. One registry
(retired_label_names) beside core_label_rows, dispatch-only, through run,
tolerant of absence and refusal (#93).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:09:54 +00:00
Daniel Marin
b45202f6f4
Merge pull request #92 from claude-bot-andresmgsl/build/91-arrival-standdown
fix: a triage-authored issue arrival must not abort the sweep
2026-07-23 21:42:04 +01:00
claude-bot-andresmgsl
c39b78959d fix: stand down with exit 0 on non-mint issue arrivals
reconcile_opened_issue's two early exits were bare returns, which carry
the failed guard's status into the executed script's set -e — every
triage-authored mint killed the labels run before one issue was
reconciled (#91, 4/4 observed). The stand-downs now say return 0; a
genuine failure on the arrival path still aborts loudly.

The suite sources the script and takes the set -u-only branch, so it
was blind to this by construction. The new arrival section executes the
script as a subprocess behind a fixture-serving gh stub (the house
pattern from test/release-chain.test.sh) and covers all three arrival
outcomes plus the preserved loud-failure path; it fails against
bb37c15 with the production signature — exit 1, empty output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 20:16:20 +00:00
Daniel Marin
bb37c1567c
Merge pull request #89 from claude-bot-andresmgsl/build/86-attention-wake
docs: FLEET.md — the assignee's `attention` wake
2026-07-23 19:33:17 +01:00
claude-bot-andresmgsl
caf7e9842e docs: smooth the ack-contract sentence
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:18:46 +00:00
claude-bot-andresmgsl
08f8060679 docs: add the assignee's attention wake to FLEET.md
The shared, role-independent wake — an open issue assigned to me
carrying attention — stated once above the per-role lists, first in
priority, one acked session per demand, with the rejected mention-poll
and the #16 incident recorded and the on-paper caveat kept (#86).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:18:00 +00:00
Daniel Marin
bce09aa764
Merge pull request #88 from codex-bot-andresmgsl/build/85-attention-contract
docs: define the attention contract
2026-07-23 19:12:33 +01:00
codex-bot-andresmgsl
7791f2100e docs: define the attention contract 2026-07-23 17:52:14 +00:00
Daniel Marin
90a42b16b0
Merge pull request #87 from codex-bot-andresmgsl/build/84-attention-core-label-row
feat: add the attention core label row
2026-07-23 18:45:34 +01:00
codex-bot-andresmgsl
bcee821153 test: annotate nested attention assertion 2026-07-23 17:29:23 +00:00
codex-bot-andresmgsl
6572a6035d docs: document attention taxonomy flag 2026-07-23 17:28:31 +00:00
codex-bot-andresmgsl
cb5ae0a068 test: pin attention issueflow inertness 2026-07-23 17:28:02 +00:00
codex-bot-andresmgsl
f15cb8c0c9 feat: add attention core label row 2026-07-23 17:27:24 +00:00
Daniel Marin
87f243299d
Merge pull request #81 from claude-bot-andresmgsl/build/77-parked-claim
docs: the parked claim and the one-build-at-a-time rule
2026-07-23 17:50:56 +01:00
claude-bot-andresmgsl
cf2adb89f5 docs: the parked claim and the one-build-at-a-time rule
The one-issue-at-a-time bullet counted claims when it meant build work
in flight; the 2026-07-23 board (#15, #16 parked beside #73) proved the
count wrong. BUILDER.md now defines the three parked shapes, the
declared-never-inferred park comment, and unparking; TRIAGE.md names a
directed hold as a park (#77).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:37:53 +00:00
Daniel Marin
e1d50c90b9
Merge pull request #76 from codex-bot-andresmgsl/build/14-convert-box
docs: make consumer guard adoption tag-aware
2026-07-23 17:36:03 +01:00
Daniel Marin
30a818d25f
Merge pull request #80 from claude-bot-andresmgsl/build/74-fleet-ruling-notifier
docs: the operator notifier's needs-ruling queue and triage's past-24h wake (D16)
2026-07-23 17:35:46 +01:00
claude-bot-andresmgsl
ac6e98067c docs: the operator notifier's needs-ruling queue and triage's past-24h wake
FLEET.md's wake conditions gain #50 D16: notify.sh's second query (open
issues and PRs labelled needs-ruling), one tracked message per item edited
in place across the ladder's four rungs and removed on clearance, the
message's contents, triage's past-24h pickup, and the on-paper caveat.
Nothing box-side sets, clears or decides the flag.

Part of #50. Closes #74.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:16:16 +00:00
codex-bot-andresmgsl
0d74c827e7 docs: make consumer labels tag-aware 2026-07-23 16:09:42 +00:00
Daniel Marin
cb3d482b8b
Merge pull request #78 from claude-bot-andresmgsl/build/73-ruling-shape-ladder
feat: the escalation comment's shape and the ladder's rungs, mechanically observed
2026-07-23 17:05:49 +01:00
claude-bot-andresmgsl
a4918a5a46 test: cover the shape check and the ladder's rungs
Pure decisions (shape presence, rung boundaries, Default: parse for wording
only), sweep probes for every AC path (malformed-once, conforming silence,
rungs despite activity, cron progression, missed-moment skip, re-flag
episode, unreadable comment list, malformed+rung same pass), and the
existing nudge fixtures updated to conforming escalations with pre-seeded
rung markers so each probe observes one behavior alone.

Part of #73.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:49:31 +00:00
claude-bot-andresmgsl
831fe239d9 feat: observe the escalation shape and the ladder's rungs
lib/ruling.sh gains the mechanical half of #50 D12-D15: the comment body
rides the fetch as base64 (rows stay line-oriented), ruling_shape_decision
checks the four line-anchored bold-tolerant field labels,
ruling_deadline_decision derives the rung from the labeled epoch,
ruling_default_decision parses the Default: line for wording only, and
reconcile_ruling wires them with bare-stops-here exclusion — every write a
comment, one per marker per episode, both surfaces via the shared lib.

Part of #73.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:43:01 +00:00
codex-bot-andresmgsl
162ce6b817 docs: count every consumer workflow caller 2026-07-23 15:34:01 +00:00
codex-bot-andresmgsl
3e96d80389 docs: make consumer guards tag-aware 2026-07-23 15:20:29 +00:00
Daniel Marin
f6797d01bc
Merge pull request #75 from codex-bot-andresmgsl/build/72-escalation-contract
docs: define the ruling escalation contract
2026-07-23 16:15:19 +01:00
codex-bot-andresmgsl
043aeaf173 docs: define the ruling escalation contract 2026-07-23 14:57:51 +00:00
Daniel Marin
553409cad4
Merge pull request #71 from codex-bot-andresmgsl/build/69-offsite-stale-flag-nudge
feat: nudge resolved offsite claims
2026-07-23 14:43:06 +01:00
codex-bot-andresmgsl
5216369e7e test: cover offsite nudge integration 2026-07-23 13:19:11 +00:00
codex-bot-andresmgsl
bc3c1bd5cf feat: nudge resolved offsite claims 2026-07-23 13:17:39 +00:00
Daniel Marin
e9928f156f
Merge pull request #70 from codex-bot-andresmgsl/build/68-offsite-claim-exemption
feat: exempt offsite claims from reclaim clock
2026-07-23 14:07:44 +01:00
codex-bot-andresmgsl
4b7747a2f5 test: keep offsite fixture shellcheck-clean 2026-07-23 12:48:59 +00:00
codex-bot-andresmgsl
6a9b501e05 feat: exempt offsite claims from reclaim clock 2026-07-23 12:48:04 +00:00
Daniel Marin
dfcfd45563
Merge pull request #64 from claude-bot-andresmgsl/build/52-needs-ruling-sweep
feat(labels): needs-ruling sweep invariants — staleness skip, bare-flag check, 7-day nudge
2026-07-23 13:41:18 +01:00
claude-bot-andresmgsl
8203f081ea test: missing-fixture gh stub applies the caller's --jq; pin LC_ALL=C
Real 'gh api --jq .[].created_at' on an empty collection emits no lines;
the stub printed a literal '[]', which under byte-wise collation sorts
after ISO-8601 timestamps and poisoned the PR-surface probe's
last_activity. Route the synthesized empty array through the same jq
projection as a present fixture, and pin the test's collation so the
verdict cannot flip with the runner's ambient locale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:25:24 +00:00
claude-bot-andresmgsl
9ff049ae45 docs(labels): the ruling sweep behaviors in LABELS.md, CONSUMERS.md, CHANGELOG
LABELS.md's needs-ruling paragraph gains the bare-flag check and the
markerless 7-day nudge; CONSUMERS.md names them in the labels job and
states the caller stub is unchanged since #18 (a pin bump is the whole
upgrade); one CHANGELOG line under Unreleased (#52).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:25:24 +00:00
claude-bot-andresmgsl
c4079ea37c test(labels): surface-level ruling contracts on both reconcilers
Issue side: invariant-1 composition, the reclaim clock stopping under a
pending ruling (with a flag-free reclaim control), the stale heal, label
churn invisible to the activity clock, the surface-level nudge reset, and
no edit anywhere naming the flag. PR side: the wired nudge riding the
stale sweep's activity computation, one nudge across two sweeps, #51's
stale skip intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:25:05 +00:00
claude-bot-andresmgsl
a40e42544e feat(labels): wire the ruling pass into both reconcilers + lib test suite
Issue side: the claim-reclaim clock stops under a pending ruling (the
decision still sees an unassigned claim), an already-applied stale heals
off, and reconcile_ruling runs for any flagged issue on any queue state.
PR side: reconcile_ruling rides the (#51) stale section's real-activity
computation. test/ruling.test.sh pins the window boundaries, newest-event
anchoring, per-event marker scoping, the markerless nudge reset, the
unreadable-timeline rule, and that no scenario writes a label.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:25:05 +00:00
claude-bot-andresmgsl
6a1d7ddac0 feat(labels): lib/ruling.sh — shared needs-ruling sweep decisions (#52)
One implementation for both surfaces: the stale exemption, the bare-flag
mechanical proxy (15-minute back-window against the newest labeled event),
the marker-scoped idempotency, the 7-day nudge with no marker (the comment
is the activity that resets its own window), and the one impure
orchestrator both reconcilers will source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:25:05 +00:00
Daniel Marin
cf69d8ce9e
Merge pull request #60 from claude-bot-andresmgsl/build/58-runner-isolated
feat(guards): actions/runner-isolated — no pull_request-triggered job on a self-hosted runner
2026-07-23 13:24:41 +01:00
Daniel Marin
9c943f5f5d
Merge pull request #63 from codex-bot-andresmgsl/build/61-cross-repo-reference-guards
fix: guard cross-repo issue references
2026-07-23 13:24:21 +01:00
claude-bot-andresmgsl
44d0a79547 feat(guards): wire runner-isolated — self-guards step, consumer entry, changelog (#58) 2026-07-23 12:04:11 +00:00
claude-bot-andresmgsl
179cd0808b test(guards): runner-isolated fixture matrix — 12 spec rows plus the block-sequence cases (#58) 2026-07-23 12:03:55 +00:00
claude-bot-andresmgsl
fccdd409ff feat(guards): actions/runner-isolated — the scan, the two-condition rule, the exit codes (#58) 2026-07-23 12:03:55 +00:00
codex-bot-andresmgsl
3935bf82d9 test: pin cross-repo reconciliation contract 2026-07-23 11:40:55 +00:00
codex-bot-andresmgsl
bc099eb4e2 fix: guard cross-repo issue references 2026-07-23 11:38:14 +00:00
Daniel Marin
ca9a1a0bcd
Merge pull request #62 from codex-bot-andresmgsl/build/57-cross-repo-discovery-guards
docs: define cross-repo discovery guards
2026-07-23 12:37:31 +01:00
codex-bot-andresmgsl
5e283ec09e docs: define cross-repo discovery guards 2026-07-23 11:21:52 +00:00
Daniel Marin
66f1c08e1a
Merge pull request #32 from codex-bot-andresmgsl/build/18-issueflow-reconcile
feat: reconcile the issue work queue
2026-07-23 12:15:29 +01:00
codex-bot-andresmgsl
43092576ef fix: harden issueflow reconciliation edges 2026-07-23 10:49:36 +00:00
codex-bot-andresmgsl
70db91fa1a fix: wire dogfood issue reconciliation 2026-07-23 10:47:30 +00:00
codex-bot-andresmgsl
d0f1a43064 test: pin injected staleness boundary 2026-07-23 10:31:15 +00:00
codex-bot-andresmgsl
1030634b09 test: inject issue-flow staleness clock 2026-07-23 10:31:15 +00:00
codex-bot-andresmgsl
1532f22eb7 fix: parse live issue dependency shapes 2026-07-23 10:31:15 +00:00
codex-bot-andresmgsl
a811da8b4f fix: enforce triage author only on issue arrival 2026-07-23 10:31:15 +00:00
codex-bot-andresmgsl
cbb0fb5eee test: cover stale unassigned claims 2026-07-23 10:31:15 +00:00
codex-bot-andresmgsl
561b7a99ec fix: read linked issues through GraphQL 2026-07-23 10:31:15 +00:00
codex-bot-andresmgsl
8174080c9d feat: reconcile the issue work queue 2026-07-23 10:31:15 +00:00
Daniel Marin
2f0d3c65af
Merge pull request #54 from claude-bot-andresmgsl/build/51-needs-ruling
feat(labels): `needs-ruling` — the label, the doctrine, and the reconciler's exclusion rule (#51)
2026-07-23 02:23:03 +01:00
Daniel Marin
af2f83a2a7
Merge pull request #53 from claude-bot-andresmgsl/build/13-consumers-feedback
docs: CONSUMERS.md — the rig conversion's lessons (#13)
2026-07-23 02:01:47 +01:00
claude-bot-andresmgsl
ded7f9ac04 docs: needs-ruling doctrine — LABELS row + D5–D9 rationale, escalation mechanics in TRIAGE/BUILDER/REVIEWER, changelog (#51)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 00:49:21 +00:00
claude-bot-andresmgsl
6db45587ed test(labels): needs-ruling contract — exclusion not latch, never a blocker, sweep-proof, stale-exempt (#51)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 00:47:59 +00:00
claude-bot-andresmgsl
bd215b06f2 feat(labels): needs-ruling — bootstrap row, decide_state exclusion, staleness skip (#51)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 00:44:16 +00:00
claude-bot-andresmgsl
bd7670703a docs: CONSUMERS.md carries rig's conversion lessons — labels.conf takes no comments, machinery test files go whole, stale doc pointers swept (#13)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 00:34:26 +00:00
github-actions[bot]
4cfa3319ec chore: bump main to 0.1.1-dev — a dev install must not impersonate 0.1.0 2026-07-23 00:12:30 +00:00
108 changed files with 23564 additions and 842 deletions

View file

@ -1,19 +0,0 @@
# Light on purpose: discussions are where ambiguity is ALLOWED — a form
# that demands rigor at the door defeats the room's purpose (issue #24,
# decision 4). If these prompts fight the flow in practice, delete them
# before adding fields to them.
body:
- type: textarea
attributes:
label: What's the itch?
description: >-
Vague is fine — a bug, an idea, a "we should…". Triage turns this
into work (or an answer); you don't have to.
validations:
required: false
- type: textarea
attributes:
label: What would "done" feel like?
description: Optional — a sketch of the outcome, if you can already see it.
validations:
required: false

View file

@ -1,15 +0,0 @@
# Light on purpose — same reasoning as ideas.yml (issue #24, decision 4):
# optional prompts only, delete before hardening.
body:
- type: textarea
attributes:
label: What's the question?
description: Ask it plainly — context and links help, none are required.
validations:
required: false
- type: textarea
attributes:
label: What have you tried or read already?
description: Optional — saves the answerer a lap.
validations:
required: false

View file

@ -1,13 +1,12 @@
# The new-issue interception (issue #24, decision 1): interception over # The new-issue interception (issue #24, decision 1): interception over
# instruction — prose alone has already proven insufficient everywhere else # instruction — prose alone has already proven insufficient everywhere else
# in this org. With blank issues disabled and exactly one form, a "New # in this org. Blank issues stay disabled; the proposal contact link gives
# issue" click shows the discussion link first; that auto-suggest is native # non-triage filers a reachable route beside triage's work-order form. That
# GitHub behavior, no automation needed. # chooser is native forge behavior, no automation needed.
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: Ideas, bugs, questions — start a Discussion - name: Ideas, bugs, questions — file a Proposal
url: https://github.com/heavy-duty/ceremony/discussions url: https://forgejo.heavyduty.builders/heavy-duty/ceremony/issues/new?template=proposal.yml
about: >- about: >-
Humans (and agents) never file issues here — discussions are where Anyone may file a proposal. Triage converts it into a work issue or
intent lives. Triage converges every discussion to an outcome, and refuses it with reasons; only triage mints work issues (TRIAGE.md).
only triage mints issues (TRIAGE.md).

34
.github/ISSUE_TEMPLATE/proposal.yml vendored Normal file
View file

@ -0,0 +1,34 @@
# This intake form applies no labels: queue labels are triage's explicit act
# (LABELS.md), and the issue-flow sweep catches non-triage authors, so the form
# must not pre-judge the proposal's queue state (#24 D2).
name: Proposal (anyone)
description: >-
Share an idea, bug, question, or rough "we should…" for triage to convert
into work or refuse with reasons.
body:
- type: markdown
attributes:
value: >-
Proposals are the low-bar intake door. Say what you noticed and why it
might matter; triage will decide whether it becomes a work issue.
- type: textarea
id: noticed
attributes:
label: What did you notice?
description: A rough idea, bug, question, or "we should…" is enough.
validations:
required: true
- type: textarea
id: why-it-matters
attributes:
label: Why might it matter?
description: Optional — describe the impact or opportunity if you can.
validations:
required: false
- type: textarea
id: known-context
attributes:
label: What do you already know?
description: Optional — add links, examples, constraints, or prior art.
validations:
required: false

View file

@ -6,8 +6,8 @@
# that (issue #24, decision 2). # that (issue #24, decision 2).
name: Work order (triage only) name: Work order (triage only)
description: >- description: >-
The issue contract (TRIAGE.md) as a form. Only triage mints issues — The issue contract (TRIAGE.md) as a form. Only triage mints work issues —
everyone else starts a Discussion. Triage may still compose by hand when everyone else files a proposal. Triage may still compose by hand when
the form fights it: `gh issue create --body-file` bypasses forms and stays the form fights it: `gh issue create --body-file` bypasses forms and stays
legitimate for the triage identity. legitimate for the triage identity.
body: body:
@ -25,7 +25,7 @@ body:
attributes: attributes:
label: Context label: Context
description: >- description: >-
Why this exists, with links — the discussion it came from, the code Why this exists, with links — the proposal it came from, the code
it touches (permalinks at a pinned SHA, so line references cannot it touches (permalinks at a pinned SHA, so line references cannot
rot), prior art in sibling repos. rot), prior art in sibling repos.
validations: validations:

71
.github/labeler.yml vendored
View file

@ -1,10 +1,38 @@
# Path → scope:* mapping for the labels workflow's scope job # Path → scope:* mapping for the labels workflow's scope job
# (actions/labeler@v5; additive only — the reusable workflow keeps # (actions/labels-scope since #130: it reads this mapping at the BASE ref
# sync-labels off, so a hand-applied scope survives the machine). The scope # and its only write is an additive POST, so a label applied while the job
# runs survives the machine — its predecessor, actions/labeler@v5, PUT the
# whole set and clobbered ceremony#128's `release` mid-job). The scope
# taxonomy itself lives in .github/labels.conf; LABELS.md carries the table # taxonomy itself lives in .github/labels.conf; LABELS.md carries the table
# these globs implement. Scopes locate, they do not alert — a path that maps # these globs implement. Scopes locate, they do not alert — a path that maps
# to nothing is fine (labeler is advisory), so these rows chase the big # to nothing is fine (the mapping is advisory), so these rows chase the big
# surfaces, not every file. # surfaces, not every file.
#
# A row that matches everything is worse than a missing one: it costs the
# same silence and adds a wrong answer. `changelog.d/**` sat under
# scope:release-flow until #267 measured it — the last 20 PRs (#197#263)
# all carried scope:release-flow and 3 of them touched a release surface,
# because BUILDER.md makes every behavior change write a fragment, so the
# glob was "any PR that changes behavior" by doctrine. CHANGELOG.md stays:
# the same doctrine forbids editing it for an entry, so only the release PR
# does. The other rows #267 added — the issueflow reconciler, the three
# unmapped guard actions, RELEASES.md, README (which this tree spells
# README.md, so the old glob could match nothing) — are the same read of the
# same file, gaps rather than wrong answers.
#
# #302 is the same read once more, from #300's review: lib/attention.sh had
# #267 D4's premise exactly (both reconcilers source it, nothing release-side
# does) and was not in the rows — a wrong answer, not a gap. The sweep
# workflow pair joins beside its trigger pair: the sweeps detached in #209
# and took the reconcile jobs and the cron with them. Two asymmetries are
# deliberate, not drift: the TESTS of the shared lib/ files take
# scope:labels alone, because lib/ruling.sh and lib/read.sh wear
# scope:release-flow only through the lib/** glob being kept whole and a
# test file inherits no such glob; and there is still no test/** or
# .github/scripts/** catch-all, because both directories span all four
# scopes — a catch-all is the changelog.d/** defect again, 100% recall and
# no locating power. The enumeration is the price of a test locating its
# subject.
scope:release-flow: scope:release-flow:
- changed-files: - changed-files:
- any-glob-to-any-file: - any-glob-to-any-file:
@ -18,38 +46,73 @@ scope:release-flow:
- CHANGELOG.md - CHANGELOG.md
- drills/** - drills/**
- test/decide.test.sh - test/decide.test.sh
- test/preflight.test.sh
- test/facts.test.sh - test/facts.test.sh
- test/release-chain.test.sh - test/release-chain.test.sh
- test/version.test.sh - test/version.test.sh
- test/changelog.test.sh - test/changelog.test.sh
- test/self-ref.test.sh - test/self-ref.test.sh
- test/changelog-assemble.test.sh
- .github/scripts/release-path.sh
- test/release-path.test.sh
scope:guards: scope:guards:
- changed-files: - changed-files:
- any-glob-to-any-file: - any-glob-to-any-file:
- actions/changelog-armed/** - actions/changelog-armed/**
- actions/changelog-assembled/**
- actions/changelog-monotonic/** - actions/changelog-monotonic/**
- actions/docs-sync/**
- actions/drill-recorded/** - actions/drill-recorded/**
- actions/refs-not-closing/**
- actions/runner-isolated/**
- .github/workflows/refs-guard.yml
- test/changelog-armed.test.sh - test/changelog-armed.test.sh
- test/changelog-assembled.test.sh
- test/changelog-monotonic.test.sh - test/changelog-monotonic.test.sh
- test/docs-sync.test.sh
- test/drill-recorded.test.sh - test/drill-recorded.test.sh
- test/refs-not-closing.test.sh
- test/runner-isolated.test.sh
- .github/scripts/marker-check.sh
- test/marker-check.test.sh
- .github/scripts/vendored-check.sh
- test/vendored.test.sh
scope:labels: scope:labels:
- changed-files: - changed-files:
- any-glob-to-any-file: - any-glob-to-any-file:
- .github/workflows/labels.yml - .github/workflows/labels.yml
- .github/workflows/self-labels.yml - .github/workflows/self-labels.yml
- .github/workflows/labels-sweep.yml
- .github/workflows/self-labels-sweep.yml
- .github/labeler.yml - .github/labeler.yml
- .github/labels.conf - .github/labels.conf
- actions/issueflow-reconcile/**
- actions/labels-reconcile/** - actions/labels-reconcile/**
- actions/labels-scope/**
# shared by both reconcilers; lib/** keeps scope:release-flow too,
# and a mixed file honestly wears both labels (#267 D4, #302 D1)
- lib/read.sh
- lib/ruling.sh
- lib/attention.sh
- LABELS.md - LABELS.md
- test/issueflow-reconcile.test.sh
- test/labels.test.sh - test/labels.test.sh
- test/labels-reconcile.test.sh - test/labels-reconcile.test.sh
- test/labels-scope.test.sh
# tests of the shared lib/ files: scope:labels ALONE — a test
# inherits no lib/** glob, so its row is the one scope its subject
# actually locates (#302 D3)
- test/attention.test.sh
- test/ruling.test.sh
- test/labels-triggers.test.sh
scope:docs: scope:docs:
- changed-files: - changed-files:
- any-glob-to-any-file: - any-glob-to-any-file:
- README - README.md
- docs/** - docs/**
- AGENTS.md - AGENTS.md
- BUILDER.md - BUILDER.md
- RELEASES.md
- REVIEWER.md - REVIEWER.md
- TRIAGE.md - TRIAGE.md
- CONTRIBUTING.md - CONTRIBUTING.md

3
.github/labels.conf vendored
View file

@ -1,4 +1,5 @@
panel=claude-bot-andresmgsl codex-bot-andresmgsl grok-bot-andresmgsl kimi-bot-andresmgsl panel=codex-bot-andresmgsl glm-bot-andresmgsl claude-bot-andresmgsl kimi-bot-andresmgsl
triage-actors=claude-bot-andresmgsl
scope:release-flow|C5DEF5|The reusable release workflow, decide, the doors scope:release-flow|C5DEF5|The reusable release workflow, decide, the doors
scope:guards|C5DEF5|changelog-armed / changelog-monotonic / drill-recorded scope:guards|C5DEF5|changelog-armed / changelog-monotonic / drill-recorded
scope:labels|C5DEF5|The labels workflow, reconciler, the taxonomy scope:labels|C5DEF5|The labels workflow, reconciler, the taxonomy

View file

@ -11,10 +11,10 @@ to the issue for triage to amend, not silently unshipped. -->
## Changelog ## Changelog
- [ ] One line under `## Unreleased` — inserted **above** the heading below - [ ] One fragment, `changelog.d/<issue>.md` — the exact prose to publish,
it, never over it — or no behavior change, stated here. never an edit to `CHANGELOG.md` — or no behavior change, stated here.
## Round log ## Round log
<!-- Append each round's summary here: what changed, what was verified. <!-- The engine appends each whole-round reply here, newest last: what
Rounds are answered whole — one reply covering every point. --> changed and what was verified. Builders write the reply, not this section. -->

128
.github/scripts/marker-check.sh vendored Executable file
View file

@ -0,0 +1,128 @@
#!/usr/bin/env bash
# Availability-marker guard (issue #238). Five of five markers found by #221
# outlived the releases that shipped their machinery. A release candidate must
# therefore reject a marker its assembled changelog makes false, while every
# tree rejects an untraceable marker. Cross-repo citations are traceable but
# are not compared with this repository's changelog; a marker for this repo's
# own issue uses bare #N, never a self-qualified repository citation (#238 D8).
# CHANGELOG.md is the release oracle and immutable shipped prose, so it and the
# fragments that feed it are excluded from the documentation scan (#238 D5).
# A token inside inline code is a mention, not a marker; spans are stripped
# individually so unrelated backticks cannot hide a real marker (#238 D9).
#
# Usage: marker-check.sh [tree-dir] (default: the repository root)
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
tree="${1:-$ROOT}"
fail() {
printf '%s\n' "$@" >&2
exit 1
}
if ! git -C "$tree" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
fail "marker-check: $tree is not a Git work tree; tracked Markdown cannot be determined."
fi
marker_records="$(mktemp)"
trap 'rm -f "$marker_records"' EXIT
mapfile -d '' markdown_files < <(git -C "$tree" ls-files -z -- '*.md')
for relative in "${markdown_files[@]}"; do
case "$relative" in
CHANGELOG.md|changelog.d/*) continue ;;
esac
if ! awk -v file="$relative" '
function without_inline_code(text, before, after) {
while (match(text, /`[^`]*`/)) {
before = substr(text, 1, RSTART - 1)
after = substr(text, RSTART + RLENGTH)
text = before after
}
return text
}
{
lines[NR] = $0
scan_lines[NR] = without_inline_code($0)
}
END {
token = "**unreleased**"
citation_re = "^[[:space:]]*\\((([[:alnum:]_.-]+/)?[[:alnum:]_.-]+)?#[0-9]+\\)"
bad = 0
for (line_no = 1; line_no <= NR; line_no++) {
remaining = scan_lines[line_no]
offset = 0
while ((at = index(remaining, token)) != 0) {
rest = substr(remaining, at + length(token))
candidate = rest
next_line = line_no + 1
while (candidate ~ /^[[:space:]]*$/ && next_line <= NR) {
candidate = candidate " " scan_lines[next_line]
next_line++
}
if (match(candidate, citation_re)) {
citation = substr(candidate, RSTART, RLENGTH)
sub(/^[[:space:]]*\(/, "", citation)
sub(/\)$/, "", citation)
printf "%s\t%d\t%s\n", file, line_no, citation
} else {
printf "marker-check: %s:%d: %s\n", file, line_no, lines[line_no] > "/dev/stderr"
printf "marker-check: every **unreleased** marker must be immediately followed by an issue citation such as (#238), (crew#293), or (owner/repo#293).\n" > "/dev/stderr"
bad = 1
}
offset += at + length(token) - 1
remaining = substr(scan_lines[line_no], offset + 1)
}
}
exit bad
}
' "$tree/$relative" >>"$marker_records"; then
exit 1
fi
done
version=""
if [ -f "$tree/VERSION" ]; then
IFS= read -r version <"$tree/VERSION" || true
fi
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
[ -f "$tree/CHANGELOG.md" ] || \
fail "marker-check: bare VERSION '$version' requires CHANGELOG.md for the release-marker check."
shipped_issues="$(awk '
$1 == "##" && $2 ~ /^[0-9]+\.[0-9]+\.[0-9]+$/ {
if (in_section) exit
in_section = 1
next
}
in_section && /^##[[:space:]]/ { exit }
in_section {
text = $0
while (match(text, /(^|[^[:alnum:]_./-])#[0-9]+/)) {
issue = substr(text, RSTART, RLENGTH)
sub(/^.*#/, "", issue)
print issue
text = substr(text, RSTART + RLENGTH)
}
}
' "$tree/CHANGELOG.md" | sort -u)"
while IFS=$'\t' read -r file line citation; do
case "$citation" in
\#*)
issue="${citation#\#}"
if printf '%s\n' "$shipped_issues" | grep -qxF "$issue"; then
fail "marker-check: $file:$line: **unreleased** (#$issue) is false on release candidate $version; CHANGELOG.md's top release section cites #$issue, so clear the marker in this release PR."
fi
;;
esac
done <"$marker_records"
fi
echo "marker-check: availability markers agree with the tree."

25
.github/scripts/release-path.sh vendored Executable file
View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
# The release doors' executable path (discussion #217; issue #237). The
# 0.5.0 record had to explain why lib/ruling.sh changed without changing a
# door. Keep this list executable so a doors-unchanged record measures the
# workflow and only the scripts it actually runs, while the test catches a
# new or removed dependency before a record can silently omit it.
#
# On this tree the doors also speak the forge shim: #191 ported lib/facts.sh
# and release.yml off `gh` so a Forgejo consumer can publish at all, which
# makes lib/forge.sh part of the doors' executable path here. The backends it
# loads (lib/forge-github.sh, lib/forge-forgejo.sh) are sourced through
# $FORGE_LIB_DIR at run time rather than by a literal `.` line, so the
# transitive scan cannot see them and listing them would read as a stale
# path; lib/forge.sh standing for the trio is the honest entry (#198).
set -euo pipefail
printf '%s\n' \
.github/workflows/release.yml \
bin/ \
lib/version.sh \
lib/decide.sh \
lib/preflight.sh \
lib/facts.sh \
lib/changelog.sh \
lib/forge.sh

234
.github/scripts/vendored-check.sh vendored Executable file
View file

@ -0,0 +1,234 @@
#!/usr/bin/env bash
# The vendored-manifest self-guard (issue #251; #248's near-miss). Consumers
# mirror the agent-facing doc set declared by docs/VENDORED.txt, and
# actions/docs-sync already enforces "manifest .ceremony/, nothing else"
# on the CONSUMER side. Nothing enforced the other end: a new doctrine file
# could land at ceremony's root and nobody add it to the manifest, and the
# miss is SILENT — docs-sync asserts byte-identity for the files the
# manifest names, so a doc it omits is never checked and every consumer
# drifts doctrine-blind with green guards. #248 (RELEASES.md) nearly shipped
# that way, caught only by a hand-written `grep -Fx RELEASES.md` row in
# test/docs-sync.test.sh — the hardcoded list this guard abolishes, one
# layer down. That row is deleted; this script carries its intent.
#
# Two directions, two mechanisms, because only one of them can be a scan
# (#251 D2):
#
# * MANIFEST → TREE is a scan: every entry resolves to a regular,
# non-empty, tracked file at the declared path — no symlink (PR #43:
# a symlink read as doctrine while staying invisible), no directory,
# no `../` escape.
# * TREE → MANIFEST cannot scan, because nothing in the tree answers
# "which files are vendorable" — the manifest is the only
# machine-readable notion of it. So it gets a CLOSED-WORLD RULE
# instead: every `*.md` at the repository ROOT is either in the
# manifest or in the exemption list below. Adding a root doc then
# forces a one-line decision — vendor it or exempt it — and the
# refusal names the file and both fixes.
#
# The rule is ROOT-LEVEL `*.md` ONLY. It does not walk docs/, actions/ or
# drills/: those hold no agent-facing doctrine, and a recursive version
# would grow the exemption list past the length at which a reviewer still
# reads it — which is the failure this rule is shaped against.
#
# The exemption list lives HERE, in the script. CONTRIBUTING.md's
# vendored-set sentence is documentation, never an input: two declarations
# of the same set is the drift the manifest exists to prevent.
#
# Usage: vendored-check.sh [tree-dir] (default: the repo root — the CI
# step; tests point it at fixture trees)
set -euo pipefail
MANIFEST="docs/VENDORED.txt"
tree="${1:-.}"
# The root docs that are deliberately ceremony-only. Each carries the reason
# it is not vendored, because the reason is what lets the next reviewer
# judge the next addition. Prints the reason and returns 0 when exempt.
exempt_reason() {
case "$1" in
README.md)
echo "ceremony's own front page — a consumer's router is AGENTS.md, not this repo's README"
;;
CONTRIBUTING.md)
echo "repo-specific facts (this repo's roster, scopes and conventions); every governed repo writes its own"
;;
CHANGELOG.md)
echo "ceremony's own release history; a consumer keeps its own"
;;
FLEET.md)
echo "the operator's fleet map — about running the fleet, not about how a governed repo works"
;;
*) return 1 ;;
esac
}
die() {
printf 'vendored-check: %s\n' "$@" >&2
exit 1
}
manifest_file="$tree/$MANIFEST"
[ -f "$manifest_file" ] || die \
"no $MANIFEST under $tree — the manifest is the sole declaration of the" \
" vendored doc set, and this guard has nothing to guard without it."
# Blank lines are skipped, exactly as actions/docs-sync reads it: the guard
# and the tool must accept the same file, or one of them is the bug.
mapfile -t manifest < <(grep -v '^[[:space:]]*$' "$manifest_file" || true)
[ "${#manifest[@]}" -gt 0 ] || die \
"$MANIFEST is empty — an empty doctrine set is a ceremony bug, not a repo" \
" with no rules."
# Whether the tracked-file assertion can bind: only when the tree IS a git
# work tree root. Fixture trees are plain directories, and asserting
# tracked-ness against an enclosing repository would be asserting about the
# wrong tree.
#
# When it cannot bind, SAY SO. This guard's whole argument is that a silent
# miss is worse than a loud one, and a guard that quietly stops asserting one
# of its four properties is exactly that shape — so the skip is announced on
# every run, green or red, rather than inferred from the absence of a
# refusal (#251 round 1).
tracked_check=no
tracked_note="tracked-ness NOT asserted: $tree is not a git work tree root, so
'is this file in the tag's tree' cannot be answered about THIS tree. The
other three manifest assertions (regular file, non-empty, no
symlink/dir/escape) still bind."
if command -v git >/dev/null 2>&1; then
toplevel="$(git -C "$tree" rev-parse --show-toplevel 2>/dev/null || true)"
if [ -n "$toplevel" ] && [ "$toplevel" = "$(cd "$tree" && pwd -P)" ]; then
tracked_check=yes
tracked_note=""
fi
fi
# Every refusal is collected and reported together, one multi-line string
# per offending file: a guard that stops at the first problem makes a
# builder pay one CI round per file.
problems=()
# --- manifest → tree ---------------------------------------------------------
for entry in "${manifest[@]}"; do
case "$entry" in
/* | *..*)
problems+=("$(
printf '%s\n' \
"$MANIFEST names '$entry' — an absolute path or a '..' escape. The" \
" mirror writes only inside a consumer's .ceremony/, so a path that" \
" leaves it is never vendorable." \
" Fix: name the path relative to the repository root, with no '..'."
)")
continue
;;
esac
path="$tree/$entry"
# -L before -f: `[ -f ]` follows the link, so a symlink to a real file
# would otherwise pass as a regular one.
if [ -L "$path" ]; then
problems+=("$(
printf '%s\n' \
"$MANIFEST names '$entry', which is a SYMLINK. A symlink vendors as" \
" doctrine while its content lives somewhere the mirror never checks" \
" (PR #43's round: it read as doctrine and stayed invisible)." \
" Fix: make '$entry' a regular file, or drop the entry from $MANIFEST."
)")
continue
fi
if [ -d "$path" ]; then
problems+=("$(
printf '%s\n' \
"$MANIFEST names '$entry', which is a DIRECTORY. The manifest declares" \
" files, one per line — a directory entry vendors nothing." \
" Fix: name each file under '$entry' on its own line, or drop the entry."
)")
continue
fi
if [ ! -f "$path" ]; then
problems+=("$(
printf '%s\n' \
"$MANIFEST names '$entry' but the tree has no such file. Every consumer" \
" mirroring this ref would fail on it." \
" Fix: add '$entry' to the tree, or remove it from $MANIFEST."
)")
continue
fi
if [ ! -s "$path" ]; then
problems+=("$(
printf '%s\n' \
"$MANIFEST names '$entry', which is EMPTY. An empty file vendors as" \
" doctrine that says nothing, and is read as doctrine anyway." \
" Fix: write '$entry', or remove it from $MANIFEST."
)")
continue
fi
if [ "$tracked_check" = yes ] &&
! git -C "$tree" ls-files --error-unmatch -- "$entry" >/dev/null 2>&1; then
problems+=("$(
printf '%s\n' \
"$MANIFEST names '$entry', which is not TRACKED. A file absent from the" \
" tag's tree cannot be fetched by a consumer syncing at that tag," \
" however present it is on this machine." \
" Fix: git add '$entry', or remove it from $MANIFEST."
)")
continue
fi
done
# --- tree → manifest: the closed world over root `*.md` ----------------------
in_manifest() {
local p
for p in "${manifest[@]}"; do
[ "$p" = "$1" ] && return 0
done
return 1
}
shopt -s nullglob
exempted=()
vendored=()
for path in "$tree"/*.md; do
doc="${path##*/}"
if in_manifest "$doc"; then
vendored+=("$doc")
continue
fi
if reason="$(exempt_reason "$doc")"; then
exempted+=("$doc$reason")
continue
fi
problems+=("$(
printf '%s\n' \
"'$doc' is a root doc in NEITHER list. Every root *.md is either vendored" \
" doctrine — mirrored into every governed repo at .ceremony/ — or" \
" deliberately ceremony-only, and nothing in the tree says which, so the" \
" decision has to be written down. Fix, one of:" \
" * add '$doc' to $MANIFEST, if it is agent-facing doctrine that every" \
" governed repo must carry;" \
" * add '$doc' to the exemption list in" \
" .github/scripts/vendored-check.sh, with the reason it stays" \
" ceremony-only."
)")
done
shopt -u nullglob
if [ "${#problems[@]}" -gt 0 ]; then
{
printf 'vendored-check: %d problem(s) — docs/VENDORED.txt and the tree disagree.\n\n' \
"${#problems[@]}"
printf '%s\n\n' "${problems[@]}"
[ -z "$tracked_note" ] || printf 'vendored-check: %s\n' "$tracked_note"
} >&2
exit 1
fi
printf 'vendored-check: %d manifest entries resolve; %d root docs vendored, %d exempt.\n' \
"${#manifest[@]}" "${#vendored[@]}" "${#exempted[@]}"
[ -z "$tracked_note" ] || printf 'vendored-check: %s\n' "$tracked_note"
[ "${#vendored[@]}" -eq 0 ] || printf ' vendored: %s\n' "${vendored[@]}"
[ "${#exempted[@]}" -eq 0 ] || printf ' exempt: %s\n' "${exempted[@]}"

View file

@ -15,6 +15,18 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
# GitHub-hosted ubuntu-latest ships shellcheck; the Forgejo runner image
# this instance uses (ghcr.io/catthehacker/ubuntu:act-22.04) does not.
# actionlint already self-installs below — the same for shellcheck, so a
# green head is reachable once a ceremony runner is online (#188).
- name: Install shellcheck
env:
SHELLCHECK_VERSION: 0.10.0
run: |
curl -fsSLo shellcheck.tar.xz \
"https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz"
tar -xJf shellcheck.tar.xz "shellcheck-v${SHELLCHECK_VERSION}/shellcheck"
sudo install "shellcheck-v${SHELLCHECK_VERSION}/shellcheck" /usr/local/bin/shellcheck
- name: Shellcheck - name: Shellcheck
run: bash .github/scripts/shellcheck-all.sh run: bash .github/scripts/shellcheck-all.sh
- name: Install actionlint - name: Install actionlint
@ -31,12 +43,37 @@ jobs:
# The pin rules (issue #9; #1 D3): a stale CEREMONY_SELF_REF fails # The pin rules (issue #9; #1 D3): a stale CEREMONY_SELF_REF fails
# CI here, not a consumer's release. # CI here, not a consumer's release.
run: bash .github/scripts/self-ref-check.sh run: bash .github/scripts/self-ref-check.sh
- name: Documentation availability markers
# Five stale markers survived the tags that shipped their machinery
# (#221); #238 makes the release candidate reject that drift.
run: bash .github/scripts/marker-check.sh
- name: Vendored manifest
# The manifest rules (issue #251; #248's near-miss): a doctrine file
# at the root that nobody added to docs/VENDORED.txt is invisible to
# every consumer's docs-sync, so it fails CI here instead.
run: bash .github/scripts/vendored-check.sh
- name: Fetch the recorded upstream commit
# test/upstream-delta.test.sh REFUSES when the recorded object is
# absent rather than calling it unverifiable (#200). "Runs offline"
# means the test reads local evidence — it does not mean CI may omit
# the evidence and pass. This step supplies it; the test never reaches
# the network itself.
run: |
ref="$(grep -vE '^[[:space:]]*(#|$)' .upstream-ref | head -n1)"
git fetch --no-tags --depth=1 \
https://github.com/heavy-duty/ceremony.git "$ref" || {
echo "::error::could not fetch the recorded upstream commit $ref" >&2
exit 1
}
- name: Tests - name: Tests
env: env:
# The npm-backed version_write case may skip locally when npm is # The npm-backed version_write case may skip locally when npm is
# absent; in CI a skip must be a failure, or the case could # absent; in CI a skip must be a failure, or the case could
# quietly stop running (issue #3's test contract). # quietly stop running (issue #3's test contract).
CEREMONY_REQUIRE_NPM: 1 CEREMONY_REQUIRE_NPM: 1
# Same contract for the yq-backed labeler.yml parse cases
# (#130): yq is preinstalled on ubuntu-latest, optional locally.
CEREMONY_REQUIRE_YQ: 1
run: bash test/run.sh run: bash test/run.sh
# The release exercise (issue #9's scratch caller) on every PR, so the # The release exercise (issue #9's scratch caller) on every PR, so the
@ -52,8 +89,9 @@ jobs:
uses: ./.github/workflows/release-exercise.yml uses: ./.github/workflows/release-exercise.yml
# The self-guards (issue #11): this repo eats exactly what it serves. The # The self-guards (issue #11): this repo eats exactly what it serves. The
# three guard actions run against the REAL tree — VERSION, CHANGELOG.md, # guard actions run against the REAL tree — VERSION, CHANGELOG.md,
# drills/ — through the same `uses:` steps every consumer's CI carries. # drills/, .github/workflows/ — through the same `uses:` steps every
# consumer's CI carries.
# These steps are also the composite-action wiring proof (issue #5's # These steps are also the composite-action wiring proof (issue #5's
# acceptance criterion: action.yml resolving, $GITHUB_ACTION_PATH, the # acceptance criterion: action.yml resolving, $GITHUB_ACTION_PATH, the
# relative lib sourcing) that action-exercise carried with scratch files # relative lib sourcing) that action-exercise carried with scratch files
@ -72,7 +110,9 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- uses: ./actions/changelog-armed - uses: ./actions/changelog-armed
- uses: ./actions/changelog-monotonic - uses: ./actions/changelog-monotonic
- uses: ./actions/changelog-assembled
- uses: ./actions/drill-recorded - uses: ./actions/drill-recorded
- uses: ./actions/runner-isolated
# Exercises changelog-monotonic the way a consumer does, against a # Exercises changelog-monotonic the way a consumer does, against a
# CONSTRUCTED history. The self-guards job above runs the same action on # CONSTRUCTED history. The self-guards job above runs the same action on

145
.github/workflows/labels-sweep.yml vendored Normal file
View file

@ -0,0 +1,145 @@
name: labels-sweep
# Reusable sweep half of the labels automation — the reconcile + issueflow
# jobs that rode labels.yml until #209. Triggers and permissions live in the
# caller; docs/CONSUMERS.md carries the complete caller stub
# (workflow_dispatch plus the hourly cron, which relocated here with the
# sweep). Issue events and same-repository PR events still yield a sweep within
# seconds: labels.yml's trigger job dispatches this workflow's caller on those
# events. Fork-headed PR events carry a read-only token on this Forgejo, so
# state, blocker, and handoff reconciliation waits for the caller's scheduled
# cadence; the sweep does not apply path-derived scope labels (#241).
#
# Detached on purpose (#209): every sweep covers every open PR and all
# sweeps serialize through ONE shared concurrency group, so GitHub's
# one-running-plus-one-pending queue records every extra run as CANCELLED.
# That displacement is semantically lossless — the surviving sweep does the
# displaced run's work — but while the sweep rode pull_request_target runs
# the ❌ landed on that PR's checks and read as red CI, with no manual
# escape hatch: GitHub refuses to rerun a queue-displaced run (crew#250).
# And displacement is the steady state of a working fleet, not a spike —
# one panel request emits one review_requested event per reviewer, so
# every review round over-fills the one-running-plus-one-pending queue.
# Here a displaced run attaches to no PR: the cancellations live on the
# Actions tab only.
#
# Bootstrap semantics: a manual dispatch of the caller bootstraps the
# taxonomy (its `bootstrap` input defaults to "yes"), exactly what
# dispatching the labels caller did before the split. The trigger job's
# dispatches carry bootstrap=no — ~20 label upserts per sweep is too chatty
# for every issue and same-repository PR wake, the same reason cron runs never
# bootstrapped.
#
# This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not
# create workflow runs from GITHUB_TOKEN-raised events (the trigger's
# workflow_dispatch is one of the two documented exemptions; this workflow
# dispatches nothing). Agent writes use a PAT and therefore do trigger —
# exactly the asymmetry wanted.
on:
workflow_call:
inputs:
bootstrap:
description: >-
Bootstrap the label taxonomy before sweeping. The CALLER passes
this through from its own workflow_dispatch input. The measured
invariant (ceremony#215): the value must be DECLARED here and
EXPLICITLY passed — on this instance the called workflow did not
see the caller's event inputs as an implicit substitute (runs
459/523 bootstrapped on a bootstrap=no dispatch) while the
top-level caller received the value in both contexts (probe runs
6/7). Absent means "no": an event- or cron-woken sweep must never
re-upsert ~20 labels.
type: string
required: false
default: "no"
pr_workflow_name:
description: >-
The `name:` of the consumer's PR-facing labels caller, exported
to the reconcile step as SELF_WORKFLOW so the sweep can leave
the label machinery's own check entries (scope, trigger) out of
its CI verdict: a red trigger means "fix the caller", which no
PR edit can do, so it must never count toward blocker:ci-red.
Read by the #208 reconciler; harmless to earlier ones.
type: string
required: false
default: labels
env:
# A called workflow arrives without its repository. Keep this literal pin
# aligned with the ceremony release consumed by callers (issue #9 D3).
CEREMONY_SELF_REF: "0.6.3"
jobs:
reconcile:
runs-on: ubuntu-latest
# ONE shared group: every reconcile sweeps every open PR, so cron and
# dispatched runs must serialize or two sweeps race the same PR's labels
# and both pass the request-the-human-once guard.
concurrency:
group: labels-reconcile
cancel-in-progress: false
steps:
# No PR code is ever checked out or executed: the sweep checks out
# the consumer's default branch and the pinned ceremony
# implementation only. Keep it that way.
- uses: actions/checkout@v4
with:
repository: ${{ github.repository }}
ref: ${{ github.event.repository.default_branch }}
- uses: actions/checkout@v4
# The self-consumption bypass — release.yml's twin, and load-bearing
# for the same reason (#11): ceremony's own labels bootstrap must
# run BEFORE any release tag exists for this checkout to fetch — the
# release label the merge door reads is created by that dispatch, so
# without the bypass the first release deadlocks on its own pin. The
# base-branch checkout above already IS ceremony on the dogfood
# path.
if: github.repository != 'heavy-duty/ceremony'
with:
repository: heavy-duty/ceremony
ref: ${{ env.CEREMONY_SELF_REF }}
path: .ceremony-src
# Two steps, mutually exclusive `if:`s, because a `uses:` path must be
# a literal — the same fork release.yml's CEREMONY_DIR env line
# papers over for `run:` steps, which composite `uses:` has no
# equivalent of.
#
# bootstrap: read from the DECLARED workflow_call input and nothing
# else. The old gate read `github.event.inputs.bootstrap` from inside
# this called workflow, and on this instance that context arrived
# empty (runs 459/523: every dispatch-woken sweep bootstrapped on a
# bootstrap=no body) while the top-level caller received the value in
# both contexts (probe runs 6/7) — ceremony#215. The reliable channel
# is declare-and-pass, so that is the only one used. The caller passes
# the value through `with.bootstrap`; anything not exactly yes|no is
# fed through for labels-reconcile's own validation to judge, so a
# typo refuses loudly instead of silently bootstrapping.
- name: reconcile state + stale
if: github.repository != 'heavy-duty/ceremony'
uses: ./.ceremony-src/actions/labels-reconcile
with:
bootstrap: ${{ inputs.bootstrap }}
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SELF_WORKFLOW: ${{ inputs.pr_workflow_name }}
- name: reconcile state + stale (dogfood — the workspace IS ceremony)
if: github.repository == 'heavy-duty/ceremony'
uses: ./actions/labels-reconcile
with:
bootstrap: ${{ inputs.bootstrap }}
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SELF_WORKFLOW: ${{ inputs.pr_workflow_name }}
- name: reconcile issue flow
if: github.repository != 'heavy-duty/ceremony'
uses: ./.ceremony-src/actions/issueflow-reconcile
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
- name: reconcile issue flow (dogfood — the workspace IS ceremony)
if: github.repository == 'heavy-duty/ceremony'
uses: ./actions/issueflow-reconcile
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}

View file

@ -2,96 +2,222 @@ name: labels
# Reusable half of the labels automation. Triggers and permissions live in # Reusable half of the labels automation. Triggers and permissions live in
# the caller; docs/CONSUMERS.md carries the complete caller stub. # the caller; docs/CONSUMERS.md carries the complete caller stub.
# #
# The caller uses pull_request_target, not pull_request: every PR in this # The caller uses pull_request_target, not pull_request, so same-repository PRs
# family arrives from a fork, where pull_request runs with a READ-ONLY token # keep the base repository's write token without running PR code. On this
# and cannot label anything. _target is safe in this workflow because no PR # Forgejo, unlike GitHub, fork-headed _target runs still receive a read-only
# code is ever checked out or executed — labeler reads changed paths via the # token. Those runs therefore attempt no writes. The scheduled sweep later
# API, and reconcile checks out the BASE branch only. Keep it that way. # reconciles state, blockers, and handoff, but it does not apply path-derived
# scope labels; consumers that require those labels on fork heads apply them
# manually. The explicit fork_head job below records that disposition as a
# successful check. Both write paths execute only for same-repository heads.
# Scope reads changed paths and the path mapping through the API and checks out
# only the ceremony implementation. Keep it that way (#241).
# #
# There is no pull_request_review_target, so a review landing cannot wake this # The reconcile sweep lived here until #209. Riding the PR-triggered run
# workflow directly — and the */15 cron is advisory: GitHub deprioritises # meant every displacement in the sweep's shared concurrency queue recorded
# short intervals hard enough that a quiet repo goes hours between ticks. The # a CANCELLED `reconcile` check on some PR — read as red CI by every human
# handoff wakes the sweep itself: the author sets state:needs-human, and the # and agent, though the surviving sweep does the displaced run's work. Two
# caller's `labeled` event confirms or corrects that optimistic write within # field facts made that untenable (crew#250): a displaced run cannot be
# seconds. The cron stays as the last resort for a forgotten handoff. # rerun — `gh run rerun`, `--failed`, and `--job` all refuse — so a victim
# PR has no manual escape hatch; and the displacing burst is deterministic,
# one `review_requested` event per panelist per request, so every review
# round displaces runs and the rate scales with panel size. The
# sweep now lives in labels-sweep.yml behind its own caller, and the
# trigger job below is its instant wake: it fires on every issue event and
# same-repository PR event this caller subscribes to, preserving that part of
# the surface that used to run reconcile directly. Same-repository wake latency
# (#137) remains seconds-scale, while a displaced sweep cancels on the Actions
# tab, attached to no PR. Fork-headed runs cannot dispatch with their read-only
# token, so state, blocker, and handoff reconciliation waits for the scheduled
# sweep; path-derived scope labels are not applied to fork heads. PR checks show
# scope + trigger for same-repository heads, or fork_head for fork heads.
# #
# This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not # This cannot loop: the trigger's dispatch and the reconciler's label
# create workflow runs from GITHUB_TOKEN-triggered events. Agent writes use a # writes both use GITHUB_TOKEN. GitHub does not create workflow runs from
# PAT and therefore do trigger — exactly the asymmetry wanted. # GITHUB_TOKEN-raised events — workflow_dispatch and repository_dispatch
# are the two documented exemptions, which is exactly why the trigger can
# wake the sweep with no PAT anywhere in the path — and the sweep itself
# dispatches nothing. Agent writes use a PAT and therefore do trigger —
# exactly the asymmetry wanted.
on: on:
workflow_call: workflow_call:
inputs:
sweep_workflow:
description: >-
Filename of the consumer's sweep caller — the workflow that
calls labels-sweep.yml (docs/CONSUMERS.md carries the stub).
The trigger job dispatches it by this name. Override it only
when the caller file is not named labels-sweep.yml (ceremony's
own dogfood names it self-labels-sweep.yml).
type: string
required: false
default: labels-sweep.yml
env: env:
# A called workflow arrives without its repository. Keep this literal pin # A called workflow arrives without its repository. Keep this literal pin
# aligned with the ceremony release consumed by callers (issue #9 D3). # aligned with the ceremony release consumed by callers (issue #9 D3).
CEREMONY_SELF_REF: "0.1.0" CEREMONY_SELF_REF: "0.6.3"
jobs: jobs:
scope: scope:
# Not on labeled/unlabeled: those events change no paths, so labeler has # Not on labeled/unlabeled: those events change no paths, so scope has
# nothing new to derive — and label churn is precisely what they are. # nothing new to derive — and label churn is precisely what they are.
# review_requested/review_request_removed likewise change no paths — they
# exist to wake the sweep (#137) — and running labeler on them widens
# exactly the window #130 documents, where a label written during a
# scope run is clobbered.
if: >- if: >-
github.event_name == 'pull_request_target' && github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.event.action != 'labeled' && github.event.action != 'labeled' &&
github.event.action != 'unlabeled' github.event.action != 'unlabeled' &&
github.event.action != 'review_requested' &&
github.event.action != 'review_request_removed'
runs-on: ubuntu-latest runs-on: ubuntu-latest
concurrency: concurrency:
group: labels-scope-${{ github.event.pull_request.number }} group: labels-scope-${{ github.event.pull_request.number }}
cancel-in-progress: true cancel-in-progress: true
steps: steps:
- uses: actions/labeler@v5 # actions/labeler@v5 held this seat until #130. Even with
with: # sync-labels: false it wrote the WHOLE label set — PUT of
# labeler reads the consumer's .github/labeler.yml via the API # (labels-fetched-at-job-start derived) — so a label applied while
# additive only — a hand-applied scope must survive the machine # the job ran was silently removed: ceremony#128 lost its `release`,
sync-labels: false # the merge door's declared-intent read, two seconds after the
# builder set it. v6/v7 write the same way, so the step was replaced
reconcile: # rather than repinned. labels-scope reads the consumer's
runs-on: ubuntu-latest # .github/labeler.yml and the changed paths via the API, and its
# ONE shared group: every reconcile sweeps every open PR, so cron and # only write is an additive POST of the derived scopes: a label
# PR-event runs must serialize or two sweeps race the same PR's labels # applied mid-job survives by construction.
# and both pass the request-the-human-once guard. #
concurrency: # Still no PR code: both checkouts below fetch the ceremony
group: labels-reconcile # implementation only. The dogfood checkout rides github.sha — the
cancel-in-progress: false # base-branch commit the workflow file itself came from, so the
steps: # script and workflow can never skew — and doubles as the #11
# pull_request_target is required for fork PR write permission. It is # bootstrap: ceremony's own labels must work before any release tag
# safe here because no PR code is ever checked out or executed: labeler # exists for the pinned checkout to fetch.
# reads paths via the API, and reconcile checks out the BASE branch only.
# Keep it that way.
- uses: actions/checkout@v4 - uses: actions/checkout@v4
if: github.repository == 'heavy-duty/ceremony'
with: with:
repository: ${{ github.repository }} repository: ${{ github.repository }}
ref: ${{ github.event.repository.default_branch }} ref: ${{ github.sha }}
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# The self-consumption bypass — release.yml's twin, and load-bearing
# for the same reason (#11): ceremony's own labels bootstrap must
# run BEFORE any release tag exists for this checkout to fetch — the
# release label the merge door reads is created by that dispatch, so
# without the bypass the first release deadlocks on its own pin. The
# base-branch checkout above already IS ceremony on the dogfood
# path.
if: github.repository != 'heavy-duty/ceremony' if: github.repository != 'heavy-duty/ceremony'
with: with:
repository: heavy-duty/ceremony repository: heavy-duty/ceremony
ref: ${{ env.CEREMONY_SELF_REF }} ref: ${{ env.CEREMONY_SELF_REF }}
path: .ceremony-src - uses: ./actions/labels-scope
# Two steps, mutually exclusive `if:`s, because a `uses:` path must be
# a literal — the same fork release.yml's CEREMONY_DIR env line
# papers over for `run:` steps, which composite `uses:` has no
# equivalent of.
- name: reconcile state + stale
if: github.repository != 'heavy-duty/ceremony'
uses: ./.ceremony-src/actions/labels-reconcile
with:
bootstrap: ${{ github.event_name == 'workflow_dispatch' && 'yes' || 'no' }}
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
- name: reconcile state + stale (dogfood — the workspace IS ceremony) PR_NUMBER: ${{ github.event.pull_request.number }}
if: github.repository == 'heavy-duty/ceremony' # the BASE branch commit — a PR must not label itself by editing
uses: ./actions/labels-reconcile # the mapping it is judged by
with: CONFIG_REF: ${{ github.sha }}
bootstrap: ${{ github.event_name == 'workflow_dispatch' && 'yes' || 'no' }}
trigger:
# The sweep's instant wake (#209) keeps the whole non-PR event surface and
# same-repository PRs. Fork-headed PRs are excluded because this Forgejo
# gives their pull_request_target run a read-only token (#241); fork_head
# records which reconciliation waits for the sweep and that path-derived
# scope labels are not applied there. Non-PR events include workflow_dispatch:
# excluding it would make a dispatched labels caller silently do nothing.
#
# LOUD on failure — never `|| true`: a red trigger is the
# misconfiguration alarm. A consumer that bumps the pin without adding
# the sweep caller (workflow-not-found), without its declared
# `bootstrap` input (unexpected input), or without `actions: write`
# on this caller (permission denied) fails HERE, visibly on the PR,
# instead of silently never sweeping again.
if: >-
github.event_name != 'pull_request_target' ||
github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- name: dispatch the sweep
env: env:
GH_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }} SWEEP_WORKFLOW: ${{ inputs.sweep_workflow }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
# REST, not `gh` (#205). The workflow-dispatch endpoint has the SAME
# shape on both forges —
# POST {api}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches
# {"ref": "<branch>", "inputs": {...}} -> 204, empty body
# — so this step no longer decides a forge at all. That is why the
# `CEREMONY_FORGE_CLIENT: gh` declaration and both inline refusals are
# gone rather than ported: there is nothing left to refuse. Measured
# on this instance (Forgejo 8.0.3+gitea-1.22.0) and published in its
# own swagger; run 459 was raised this way.
#
# STILL LOUD on failure, per this job's contract: a consumer missing
# the sweep caller, its `bootstrap` input, or `actions: write` must
# fail HERE and visibly, not sweep silently never again.
# NEVER "probably github" (lib/forge.sh). Defaulting an unset
# GITHUB_API_URL to api.github.com would send this forge's dispatch
# to GitHub and report success — the same unset-environment guess
# #201 just refused for docs-sync. The API root is injected by the
# forge running us; if it is absent we do not know where we are, and
# a guess is worse than a red trigger
# (@codex-reviewer-andresmgsl, #205 review).
api="${GITHUB_API_URL:-}"
if [ -z "$api" ]; then
echo "::error::labels: the sweep was NOT woken — GITHUB_API_URL is unset, so the forge's API root is unknown. Refusing to guess a forge."
exit 1
fi
# `gh workflow run` defaulted the ref to the repository's default
# branch; REST has no default and 400s without one. Prefer the event
# payload, fall back to an API read: on a `pull_request_target` run
# GITHUB_REF_NAME is `<n>/merge`, which is not a branch and would
# dispatch nothing.
branch="${DEFAULT_BRANCH:-}"
if [ -z "$branch" ]; then
branch="$(curl -fsS -H "Authorization: Bearer $GITHUB_TOKEN" \
"$api/repos/$GITHUB_REPOSITORY" | jq -r '.default_branch // empty')"
fi
if [ -z "$branch" ]; then
echo "::error::labels: the sweep was NOT woken — could not determine the default branch to dispatch $SWEEP_WORKFLOW on."
exit 1
fi
out="$(mktemp)"
err="$(mktemp)"
trap 'rm -f "$out" "$err"' EXIT
# A transport failure is named, not merely propagated. Letting `set
# -e` carry curl's own exit code out of the assignment DID fail the
# job — the invariant holds — but it failed with a bare status and no
# sentence, which is the opposite of this step owning its diagnostic.
if ! code="$(curl -sS -o "$out" -w '%{http_code}' -X POST \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H 'Content-Type: application/json' \
-d "$(jq -nc --arg ref "$branch" '{ref: $ref, inputs: {bootstrap: "no"}}')" \
"$api/repos/$GITHUB_REPOSITORY/actions/workflows/$SWEEP_WORKFLOW/dispatches" \
2>"$err")"; then
echo "::error::labels: the sweep was NOT woken — the request to $api never completed: $(tr -d '\n' <"$err")"
exit 1
fi
if [ "$code" != "204" ]; then
# Own the diagnostic rather than pass the status through. This
# Forgejo answers an unknown workflow name — and a bare ref that
# does not resolve — with `500` and an EMPTY body, so the raw
# status alone sends the reader looking for a server fault that is
# not there.
echo "::error::labels: the sweep was NOT woken — POST $api/repos/$GITHUB_REPOSITORY/actions/workflows/$SWEEP_WORKFLOW/dispatches (ref=$branch) returned HTTP $code: $(tr -d '\n' <"$out")"
echo "::error::labels: check that $SWEEP_WORKFLOW exists on $branch, declares a \`bootstrap\` workflow_dispatch input, and that this caller grants \`actions: write\`. An empty 500 body from Forgejo means the workflow name or the ref did not resolve."
exit 1
fi
echo "labels: sweep dispatched — $SWEEP_WORKFLOW on $branch (bootstrap=no)"
fork_head:
# This Forgejo keeps pull_request_target read-only for fork heads (#241),
# so name the deliberately unsupported scope write as well as the deferred
# state machine instead of letting a green no-op promise full labelling.
if: >-
github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-latest
steps:
- name: explain deferred fork labels
run: >-
echo "labels: fork head has a read-only token; state, blocker, and handoff reconciliation deferred to the scheduled sweep; path-derived scope labels are not applied to fork heads"

34
.github/workflows/refs-guard.yml vendored Normal file
View file

@ -0,0 +1,34 @@
name: Refs guard
on:
# Body edits are load-bearing: #200 gained its accidental closing keyword
# after the PR opened, with no new commit to wake ordinary CI (#218).
pull_request:
types: [opened, edited, reopened, synchronize]
permissions:
contents: read
pull-requests: read
jobs:
refs-not-closing:
# The action is portable (#199): its gather is two REST reads through the
# shim plus this repo's own closing-keyword parser, so it produces a real
# verdict on either forge. It still refuses by name when it cannot read —
# that is its contract, and its contract test.
#
# #199 removed the forge gate that used to sit here. While the action's
# only gather was GraphQL it could do nothing but refuse on Forgejo, and
# scheduling a permanently red required check would have blocked every
# merge on this forge; a skipped check is a green head, an invented
# verdict is not. The gather is REST now, so the job RUNS here and
# produces verdicts again.
#
# Deleting the action's client declaration without deleting this gate
# would have left it portable and never scheduled — a guard that passes
# by never running, which is this repo's blind-sweep shape wearing a
# different hat (@kimi-reviewer-andresmgsl, #198).
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./actions/refs-not-closing

View file

@ -87,16 +87,32 @@ jobs:
echo "RELEASE_ASSETS_DIR=$RUNNER_TEMP/release-assets" >> "$GITHUB_ENV" echo "RELEASE_ASSETS_DIR=$RUNNER_TEMP/release-assets" >> "$GITHUB_ENV"
- name: construct the fixture consumer tree and the gh stub - name: construct the fixture consumer tree and the gh stub
# The fixture release.yml's steps run against (below): a base at # The fixture release.yml's steps run against (below): a base at
# 0.6.9-dev with an armed changelog, then the ceremony merge — # 0.6.9-dev armed the fragment way (#112) — changelog.d/ with its
# VERSION bumped bare, Unreleased stamped. Same shape as # marker and one fragment — then the ceremony merge: VERSION bumped
# test/release-chain.test.sh. The gh stub answers the one API fact # bare and the section stamped by the REAL assembler, the command
# the ceremony path consults (the merged release-labeled PR) so # the real ceremony PR runs by hand (#112 D12), so the exercise
# nothing here talks to GitHub. # consumes the tool end to end instead of hand-writing its output.
# Same shape as test/release-chain.test.sh. The gh stub answers the
# one API fact the ceremony path consults (the merged
# release-labeled PR) so nothing here talks to a forge.
#
# The stub is gh-shaped, so the facts step below pins
# CEREMONY_FORGE=github: since #191 facts.sh selects a backend, and
# on a Forgejo runner it would otherwise pick the forgejo backend,
# which speaks curl and would walk straight past this stub to the
# real instance — reading the exercise's fixture SHA against the
# live repository and refusing it. The exercise rehearses the
# WIRING; which backend answers is lib/forge.sh's own contract,
# covered in test/forge*.test.sh.
run: | run: |
mkdir -p "$RUNNER_TEMP/stub" mkdir -p "$RUNNER_TEMP/stub"
cat > "$RUNNER_TEMP/stub/gh" <<'EOF' cat > "$RUNNER_TEMP/stub/gh" <<'EOF'
#!/usr/bin/env bash #!/usr/bin/env bash
if [ "$1" = api ]; then echo true; exit 0; fi # The label read is GET commits/{sha}/pulls — a JSON array (#191).
if [ "$1" = api ]; then
echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}]'
exit 0
fi
echo "gh stub: unexpected call: gh $*" >&2 echo "gh stub: unexpected call: gh $*" >&2
exit 97 exit 97
EOF EOF
@ -110,31 +126,18 @@ jobs:
cat > CHANGELOG.md <<'EOF' cat > CHANGELOG.md <<'EOF'
# Changelog # Changelog
## Unreleased
- The entry this release ships.
## 0.6.8 — 2026-07-01 ## 0.6.8 — 2026-07-01
- An older entry. - An older entry.
EOF EOF
git add VERSION CHANGELOG.md mkdir changelog.d
printf '# changelog.d/ — assembled at release (heavy-duty/ceremony#112); the marker keeps the directory tracked.\n' > changelog.d/README.md
printf -- '- The entry this release ships (#42).\n' > changelog.d/42.md
git add VERSION CHANGELOG.md changelog.d
git commit -qm "base" git commit -qm "base"
printf '0.7.0\n' > VERSION printf '0.7.0\n' > VERSION
cat > CHANGELOG.md <<'EOF' bash "$CEREMONY_DIR/bin/changelog-assemble" 0.7.0 2026-07-21
# Changelog git add -A
## Unreleased
## 0.7.0 — 2026-07-21
- The entry this release ships.
## 0.6.8 — 2026-07-01
- An older entry.
EOF
git add VERSION CHANGELOG.md
git commit -qm "release: 0.7.0" git commit -qm "release: 0.7.0"
echo "FIXTURE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" echo "FIXTURE_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
- name: gather the facts — version, base version, released, labeled - name: gather the facts — version, base version, released, labeled
@ -146,6 +149,9 @@ jobs:
# back to the merge commit's first parent (#1 constraint 10). # back to the merge commit's first parent (#1 constraint 10).
EVENT_BEFORE: "" EVENT_BEFORE: ""
VERSION_SOURCE: file VERSION_SOURCE: file
# The stub above is gh-shaped; pin the backend that uses it.
CEREMONY_FORGE: github
GITHUB_REPOSITORY: fixture/fixture
# release.yml's step verbatim — same invocation, same # release.yml's step verbatim — same invocation, same
# $GITHUB_OUTPUT plumbing — cwd'd at the fixture instead of the # $GITHUB_OUTPUT plumbing — cwd'd at the fixture instead of the
# workspace (the one thing a replay cannot inherit). # workspace (the one thing a replay cannot inherit).
@ -171,12 +177,32 @@ jobs:
run: | run: |
# shellcheck source=/dev/null # shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/changelog.sh" . "$CEREMONY_DIR/lib/changelog.sh"
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md" if ! diagnosis="$(changelog_section_problem CHANGELOG.md "$VER")"; then
if [ ! -s "$RUNNER_TEMP/notes.md" ]; then
echo "CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release" >&2 echo "CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release" >&2
printf '%s\n' "$diagnosis" >&2
exit 1 exit 1
fi fi
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
cat "$RUNNER_TEMP/notes.md" cat "$RUNNER_TEMP/notes.md"
- name: an entry-less stamped fixture is refused by the notes predicate
working-directory: ${{ runner.temp }}/fixture
env:
VER: ${{ steps.facts.outputs.ver }}
run: |
cp CHANGELOG.md "$RUNNER_TEMP/CHANGELOG.good.md"
awk -v ver="$VER" '
/^## / { in_section = ($2 == ver) }
in_section && /^[[:space:]]*[-*][[:space:]]/ { next }
{ print }
' "$RUNNER_TEMP/CHANGELOG.good.md" > CHANGELOG.md
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/changelog.sh"
if diagnosis="$(changelog_section_problem CHANGELOG.md "$VER")"; then
echo "entry-less stamped section unexpectedly passed" >&2
exit 1
fi
printf '%s\n' "$diagnosis" | grep -F "section '$VER' has no entries"
cp "$RUNNER_TEMP/CHANGELOG.good.md" CHANGELOG.md
- name: the chain must land where the fixture says it lands - name: the chain must land where the fixture says it lands
env: env:
CEREMONY: ${{ steps.decide.outputs.ceremony }} CEREMONY: ${{ steps.decide.outputs.ceremony }}

View file

@ -54,7 +54,7 @@ name: release
# branches: [main] # branches: [main]
# permissions: # permissions:
# contents: write # tag ref create + release create + the bump push # contents: write # tag ref create + release create + the bump push
# pull-requests: write # the label read; the bump-fallback `gh pr create` # pull-requests: write # the label read; the bump-fallback PR
# issues: write # --label on that fallback PR rides the issues API # issues: write # --label on that fallback PR rides the issues API
# jobs: # jobs:
# release: # release:
@ -87,7 +87,7 @@ name: release
# ## The artifact hook (#1 D4) # ## The artifact hook (#1 D4)
# #
# If the consumer carries .github/actions/release-artifact/action.yml, both # If the consumer carries .github/actions/release-artifact/action.yml, both
# doors invoke it — after the tag exists, before `gh release create` — with # doors invoke it — after the tag exists, before the publish — with
# `version` as input and RELEASE_ASSETS_DIR exported; every file the hook # `version` as input and RELEASE_ASSETS_DIR exported; every file the hook
# drops there is uploaded as a release asset. Exit non-zero to abort the # drops there is uploaded as a release asset. Exit non-zero to abort the
# release. No hook → no assets. # release. No hook → no assets.
@ -129,7 +129,7 @@ env:
# `ref:` accepts ${{ env }}; `uses:` strings do not — which is why the # `ref:` accepts ${{ env }}; `uses:` strings do not — which is why the
# shared logic arrives as script files via checkout, not as inner `uses:` # shared logic arrives as script files via checkout, not as inner `uses:`
# references. # references.
CEREMONY_SELF_REF: "0.1.0" CEREMONY_SELF_REF: "0.6.3"
VERSION_SOURCE: ${{ inputs.version-source }} VERSION_SOURCE: ${{ inputs.version-source }}
jobs: jobs:
@ -198,30 +198,47 @@ jobs:
run: | run: |
# shellcheck source=/dev/null # shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/changelog.sh" . "$CEREMONY_DIR/lib/changelog.sh"
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md" if ! diagnosis="$(changelog_section_problem CHANGELOG.md "$VER")"; then
if [ ! -s "$RUNNER_TEMP/notes.md" ]; then
echo "CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release" >&2 echo "CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release" >&2
printf '%s\n' "$diagnosis" >&2
exit 1 exit 1
fi fi
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
cat "$RUNNER_TEMP/notes.md" cat "$RUNNER_TEMP/notes.md"
- name: nothing may exist yet — no tag, no release (re-runs refuse loudly) - name: preflight — resume this merge, refuse every other collision
id: preflight
if: steps.decide.outputs.ceremony == 'yes' if: steps.decide.outputs.ceremony == 'yes'
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
VER: ${{ steps.facts.outputs.ver }} VER: ${{ steps.facts.outputs.ver }}
# What makes a re-run of a completed ceremony refuse instead of MERGE_SHA: ${{ github.sha }}
# clobber, and what catches a manual tag racing the merge. # The pure table in lib/preflight.sh distinguishes a stranded run of
# this door from a completed release or a tag at another commit (#273).
run: | run: |
if git ls-remote --exit-code origin "refs/tags/$VER" >/dev/null 2>&1; then tag_read_rc=0
echo "tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing." >&2 tag_refs="$(git ls-remote --exit-code origin "refs/tags/$VER" "refs/tags/$VER^{}")" || tag_read_rc=$?
exit 1 case "$tag_read_rc" in
fi 0) tag_shas="$(printf '%s\n' "$tag_refs" | awk 'NF { print $1 }')" ;;
if gh release view "$VER" -R "$GITHUB_REPOSITORY" --json name >/dev/null 2>&1; then 2) tag_shas="" ;;
echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2 *)
echo "could not read tag '$VER' from origin (git ls-remote exit $tag_read_rc) — refusing rather than assuming it does not exist." >&2
exit 1
;;
esac
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
if ! released="$(forge_release_exists "$VER")"; then
echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2
exit 1 exit 1
fi fi
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/preflight.sh"
out="$(TAG_SHAS="$tag_shas" RELEASED="$released" release_preflight)"
printf '%s\n' "$out"
printf '%s\n' "$out" | grep '^resume=' >> "$GITHUB_OUTPUT"
- name: tag the merge commit — same job as the publish, on purpose - name: tag the merge commit — same job as the publish, on purpose
if: steps.decide.outputs.ceremony == 'yes' if: steps.decide.outputs.ceremony == 'yes' && steps.preflight.outputs.resume != 'yes'
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
VER: ${{ steps.facts.outputs.ver }} VER: ${{ steps.facts.outputs.ver }}
@ -230,8 +247,10 @@ jobs:
# the tag door cannot double-fire off this tag — and this job is # the tag door cannot double-fire off this tag — and this job is
# the only chance to publish (the sources' central comment). # the only chance to publish (the sources' central comment).
run: | run: |
gh api "repos/$GITHUB_REPOSITORY/git/refs" \ # shellcheck source=/dev/null
-f "ref=refs/tags/$VER" -f "sha=$MERGE_SHA" . "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
forge_tag_create "$VER" "$MERGE_SHA"
- name: artifact hook — the consumer's own release-artifact action - name: artifact hook — the consumer's own release-artifact action
# Runs after the tag exists, before the publish (#1 D4). The local # Runs after the tag exists, before the publish (#1 D4). The local
# path resolves in the consumer checkout at the workspace root — # path resolves in the consumer checkout at the workspace root —
@ -252,9 +271,10 @@ jobs:
for f in "$RELEASE_ASSETS_DIR"/*; do for f in "$RELEASE_ASSETS_DIR"/*; do
if [ -e "$f" ]; then assets+=("$f"); fi if [ -e "$f" ]; then assets+=("$f"); fi
done done
gh release create "$VER" --verify-tag --title "$VER" \ # shellcheck source=/dev/null
--notes-file "$RUNNER_TEMP/notes.md" -R "$GITHUB_REPOSITORY" \ . "$CEREMONY_DIR/lib/forge.sh"
"${assets[@]}" forge_select ""
forge_release_create "$VER" "$VER" "$RUNNER_TEMP/notes.md" "${assets[@]}"
# The post-release bump, folded into the release act (the sources' # The post-release bump, folded into the release act (the sources'
# operator decision: a mechanical one-liner deserves no PR of its # operator decision: a mechanical one-liner deserves no PR of its
# own). X.Y.(Z+1)-dev is arithmetic, not judgment (version_next_dev # own). X.Y.(Z+1)-dev is arithmetic, not judgment (version_next_dev
@ -293,10 +313,13 @@ jobs:
echo "direct push refused (branch protection?) — opening the bump PR instead" >&2 echo "direct push refused (branch protection?) — opening the bump PR instead" >&2
git checkout -b "chore/bump-$next" git checkout -b "chore/bump-$next"
git push origin "chore/bump-$next" git push origin "chore/bump-$next"
gh pr create -R "$GITHUB_REPOSITORY" --head "chore/bump-$next" \ # shellcheck source=/dev/null
--title "chore: bump main to $next" \ . "$CEREMONY_DIR/lib/forge.sh"
--body "The post-release re-arm, opened by release.yml because the direct push was refused. One version bump, nothing else — never leave main armed to impersonate $VER." \ forge_select ""
--label release forge_pr_create "chore/bump-$next" main \
"chore: bump main to $next" \
"The post-release re-arm, opened by release.yml because the direct push was refused. One version bump, nothing else — never leave main armed to impersonate $VER." \
release
fi fi
release-on-tag: release-on-tag:
@ -342,12 +365,29 @@ jobs:
run: | run: |
# shellcheck source=/dev/null # shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/changelog.sh" . "$CEREMONY_DIR/lib/changelog.sh"
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md" if ! diagnosis="$(changelog_section_problem CHANGELOG.md "$VER")"; then
if [ ! -s "$RUNNER_TEMP/notes.md" ]; then echo "CHANGELOG.md has no '## $VER' section — run changelog-assemble in the release PR before tagging; refusing to publish an empty release" >&2
echo "CHANGELOG.md has no '## $VER' section — stamp the Unreleased section in the release PR before tagging; refusing to publish an empty release" >&2 printf '%s\n' "$diagnosis" >&2
exit 1 exit 1
fi fi
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md"
cat "$RUNNER_TEMP/notes.md" cat "$RUNNER_TEMP/notes.md"
- name: no published release may exist
env:
GH_TOKEN: ${{ github.token }}
VER: ${{ steps.assert.outputs.ver }}
run: |
# shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh"
forge_select ""
if ! exists="$(forge_release_exists "$VER")"; then
echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2
exit 1
fi
if [ "$exists" = yes ]; then
echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2
exit 1
fi
- name: artifact hook — the consumer's own release-artifact action - name: artifact hook — the consumer's own release-artifact action
# After the tag exists (it fired this door), before the publish — # After the tag exists (it fired this door), before the publish —
# the same contract as the merge door's twin step. # the same contract as the merge door's twin step.
@ -364,6 +404,7 @@ jobs:
for f in "$RELEASE_ASSETS_DIR"/*; do for f in "$RELEASE_ASSETS_DIR"/*; do
if [ -e "$f" ]; then assets+=("$f"); fi if [ -e "$f" ]; then assets+=("$f"); fi
done done
gh release create "$VER" --verify-tag --title "$VER" \ # shellcheck source=/dev/null
--notes-file "$RUNNER_TEMP/notes.md" -R "$GITHUB_REPOSITORY" \ . "$CEREMONY_DIR/lib/forge.sh"
"${assets[@]}" forge_select ""
forge_release_create "$VER" "$VER" "$RUNNER_TEMP/notes.md" "${assets[@]}"

58
.github/workflows/self-labels-sweep.yml vendored Normal file
View file

@ -0,0 +1,58 @@
name: labels-sweep
# Ceremony's own sweep caller (#209) — self-labels.yml's detached half,
# wearing the same local-`uses:` deviation and the same warning: consumers
# must NEVER copy the local form (it rides main, unpinned — correct only
# for the repo that IS the source). Consumers write:
# uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@<pinned-tag>
on:
# The consumer owns this cadence (#203). Hourly is the recommended default
# when no other engine drives board state: the cron is then the sweep's ONLY
# wake for a review verdict landing (there is no
# pull_request_review trigger on the labels caller), blocker:ci-red set or
# cleared (no check_suite/check_run/workflow_run), a blocker:conflict when
# ANOTHER PR merges under this one, and the time-based stale / 48h
# claim-reclaim, plus every state, blocker, and handoff transition for a
# fork-headed PR on this Forgejo because its pull_request_target token is
# read-only (#241). The sweep never applies path-derived scope labels. Issue
# events and same-repository PR events carry the rest in seconds, one
# trigger-job dispatch away. Hourly trades ≤1h of latency on the scheduled
# classes while cutting nominal scheduled sweeps from four an hour to one at
# GitHub's 1-minute billing floor. Do not delete the cron: it is their
# discovery path. If another engine writes some of those transitions, only
# the classes with no other writer bound the cadence; relax it only as that
# list shrinks.
schedule: [{cron: "0 * * * *"}]
# A manual full-board sweep. A bare dispatch (input default "yes") also
# bootstraps the taxonomy on a fresh repo — what dispatching the labels
# caller did before #209. The reusable's trigger job wakes this workflow
# with bootstrap=no on every issue and same-repository PR event — an
# event-woken sweep must not re-upsert ~20 labels each time — so declaring
# this input is part of the
# caller contract: a dispatch naming an undeclared input is refused, and
# the trigger job goes loudly red.
workflow_dispatch:
inputs:
bootstrap:
description: Bootstrap the label taxonomy before sweeping
type: choice
options: ["yes", "no"]
default: "yes"
permissions:
contents: read
checks: read # mergeability/check-rollup read for PR state
statuses: read # commit-status rollup read for PR state
issues: write
pull-requests: write
jobs:
sweep:
# pr_workflow_name keeps its default: ceremony's PR-facing caller is
# named `labels` (self-labels.yml).
uses: ./.github/workflows/labels-sweep.yml
with:
# The dispatch input crosses the workflow_call boundary HERE, or not
# at all: on this instance the called workflow did not see this
# caller's event inputs implicitly (ceremony#215), so declare-and-pass
# is the only channel used. On `schedule` the top-level context is
# empty, and empty maps to "no" EXPLICITLY — a cron that bootstraps is
# the failure kimi named before it could exist.
bootstrap: ${{ inputs.bootstrap || 'no' }}

View file

@ -4,15 +4,54 @@ name: labels
# same warning: consumers must NEVER copy the local form (it rides main, # same warning: consumers must NEVER copy the local form (it rides main,
# unpinned — correct only for the repo that IS the source). Consumers write: # unpinned — correct only for the repo that IS the source). Consumers write:
# uses: heavy-duty/ceremony/.github/workflows/labels.yml@<pinned-tag> # uses: heavy-duty/ceremony/.github/workflows/labels.yml@<pinned-tag>
#
# Since #209 this caller carries the PR/issue event surface only. The
# reconcile sweep no longer rides these runs — the reusable's trigger job
# dispatches the sweep caller (self-labels-sweep.yml here), which owns the
# hourly cron and the manual/bootstrap workflow_dispatch. Issue events and
# same-repository PR events below still yield a sweep within seconds, one
# dispatch hop later. Fork-headed PRs carry a read-only token on this Forgejo,
# so their successful labels run leaves state, blocker, and handoff
# reconciliation to the hourly sweep; path-derived scope labels are not
# applied to those heads (#241).
on: on:
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake # Narrowed (#199) to the actions that carry a queue-state change the hourly
workflow_dispatch: # bootstraps missing labels on a fresh repo # cron cannot wait one cadence for — dropping only labeled/unlabeled/assigned/
# unassigned, which feed validation and the 48h claim clock (caught within one
# cadence) and were the dominant issues-churn source. Kept: `opened` (the
# mint→needs-triage check, issueflow's opened-only path), `closed` (the
# blocker-closes→ready self-heal, crew#96/#98), `edited` (a body rewrite of the
# `Blocked by #N` declaration the sweep parses — issueflow-reconcile.sh:179),
# `reopened` (a closed issue re-entering the queue wearing labels derived when
# it closed). The must-fail in #199 is exactly "a queue-state transition waits
# on the schedule when an event could have carried it", so edited/reopened stay
# on events. The PR handoff wake is pull_request_target:labeled, NOT issues, so
# this does not touch the handoff.
issues:
types: [opened, closed, edited, reopened]
pull_request_target: pull_request_target:
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled] # These carry the head/draft/review facts the sweep derives state:* from.
# Same-repository heads wake that sweep in seconds; fork heads cannot write
# with this Forgejo's read-only token, so state, blocker, and handoff
# reconciliation waits for the scheduled cadence. The sweep does not apply
# path-derived scope labels to those heads (#241).
# labeled/unlabeled are the same-repository handoff wake — the author's
# optimistic state:needs-human write, confirmed or corrected here in
# seconds (#11); synchronize re-derives on every push; review_requested/
# review_request_removed clear (or restore) blocker:unrequested on that
# same instant path (#137).
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
permissions: permissions:
contents: read contents: read
checks: read # mergeability/check-rollup read for PR state
statuses: read # commit-status rollup read for PR state
actions: write # the trigger job's dispatch of the sweep caller (#209, #205)
issues: write issues: write
pull-requests: write pull-requests: write
jobs: jobs:
labels: labels:
uses: ./.github/workflows/labels.yml uses: ./.github/workflows/labels.yml
with:
# Dogfood filename deviation only — consumers keep the default,
# labels-sweep.yml, and pass nothing.
sweep_workflow: self-labels-sweep.yml

7
.upstream-ref Normal file
View file

@ -0,0 +1,7 @@
# The upstream commit this tree carries (docs/UPSTREAM-SYNC.md).
# Full 40-char SHA, immutable: captured at fetch, merged, then recorded —
# NOT re-read from gh/main later, which moves. Read by
# test/upstream-delta.test.sh, which REFUSES when the object is absent
# rather than calling it unverifiable.
# github.com/heavy-duty/ceremony
8c3a4d1dee2bdb5ac06a632a285bb65ab2615214

View file

@ -14,7 +14,7 @@ reviewer here"). That one word is your whole onboarding:
| you are the… | read | your job in one line | | you are the… | read | your job in one line |
|---|---|---| |---|---|---|
| **triage** agent | [TRIAGE.md](TRIAGE.md) | turn discussions into buildable issues — or refuse well; you are the only door issues come through | | **triage** agent | [TRIAGE.md](TRIAGE.md) | turn proposals into buildable work issues — or refuse well; you are the only door work issues come through |
| **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria | | **builder** agent | [BUILDER.md](BUILDER.md) | turn one `ready` issue into one PR that meets its acceptance criteria |
| **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human | | **reviewer** agent | [REVIEWER.md](REVIEWER.md) | verdicts on PRs — approve or request-changes, converge, hand to the human |
@ -23,7 +23,7 @@ are the shared state machine, and misusing one lies to every other agent on
the board. the board.
**Not told a role?** Infer it from the task: asked to review a PR → reviewer; **Not told a role?** Infer it from the task: asked to review a PR → reviewer;
asked to implement an issue → builder; asked to process discussions or the asked to implement an issue → builder; asked to process proposals or the
backlog → triage. Still ambiguous → ask before acting. Do not free-lance backlog → triage. Still ambiguous → ask before acting. Do not free-lance
across roles in one session: a builder reviewing its own PR, or a reviewer across roles in one session: a builder reviewing its own PR, or a reviewer
pushing fixes, breaks the separation the pipeline depends on. pushing fixes, breaks the separation the pipeline depends on.
@ -31,13 +31,13 @@ pushing fixes, breaks the separation the pipeline depends on.
## The pipeline you are part of ## The pipeline you are part of
``` ```
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release proposal ──▶ triage ──▶ work issue ──▶ build ──▶ review ──▶ human merge ──▶ release
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony) (anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
``` ```
Two rules bind every role: Two rules bind every role:
- **Only triage mints issues.** Found work? Open or extend a discussion. - **Only triage mints work issues.** Found work? File or extend a proposal.
- **Only humans merge.** Convergence ends at `state:needs-human`, never at - **Only humans merge.** Convergence ends at `state:needs-human`, never at
a merge button. a merge button.

View file

@ -6,71 +6,304 @@ triage bug, and the move is to say so on the issue, not to guess.
## Picking ## Picking
- Pick from issues labeled **`ready`** — never `blocked`, never `claimed`, - Pick from issues labeled **`ready`** — never `blocked`, `claimed`, or an
never an `epic` (epics organize; their children are the work). `epic` (epics organize; their children are the work). Inside an epic take
- Respect dependency order: inside an epic, take the earliest unblocked the earliest unblocked unclaimed child, otherwise the issue that unblocks
unclaimed child. Between epics and strays, prefer the issue that unblocks the most work; where a repo adopts version epics,
the most other work. [RELEASES.md](RELEASES.md) governs among window members.
- **One issue at a time.** Finish or release your claim before taking - **Your own red head outranks a new claim**: repair a failing check at your
another. PR's head before claiming another issue (#163). Red and green here are the
review round's ruled terms: cancelled, stale, or unreported — every entry
at the head cancelled — is not green; skipped or neutral is. Record the
check and its failure class; rerun a clearly retryable infrastructure
failure unchanged; treat a branch failure as an ordinary fix round,
worklog and all; leave evidence where a rerun cannot start or the cause is
unclear; never rerun a deterministic failure without a corrective commit;
hand off once green with current-head approvals. Such a PR is **never
parked**, whatever the verdict state says; how the engine detects a red
head is crew's to describe.
- **One build at a time**: one issue on which you are writing or revising a
deliverable, finished or released before you start more. The rule counts
work in flight, not claims — a **parked** claim, whose next move is
someone else's, does not hold the slot. Five shapes park:
1. `needs-ruling` is set, the escalation names a decider, and its
`Blocked:` line stops the rest;
2. a **live** review round holds it, every outstanding verdict someone
else's — awaiting first verdicts, or answered whole with the owed
re-requests posted, by head and not by verdict (steps 12). A red check
at the head takes it out of this shape: the next move is yours;
3. every remaining acceptance criterion is operator-owned, stated so by
triage on the issue. **An operator-owned remainder parks the claim and
never the handoff**: this shape is reached only from the far side of
shape 4, because it is the state finishing the work puts you in and
would otherwise excuse the handoff it should follow (#336);
4. it is **handed off** — round passed, no `blocker:*` standing,
`state:needs-human` set per Handoff, the merge the human's. Shapes 2
and 4 are sequential and never overlap;
5. the claim is **held by directive** — triage or the operator stopped the
work, named what the hold waits on, and only they end it. A hold ends
as it started, **on the labels**: where labels and prose disagree, the
most recent queue-label event by the hold's owner governs, and an
operator may lift by label alone (#149, #151). So read the label events
(`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not just the
comments, before standing down *or* up, and say in the claim which you
read, their timestamps and their actor. Where they do not resolve the
contradiction, say so and take the next `ready` issue; refusing is no
resting place.
Not parked: waiting on yourself, on CI (a red head is yours; a pending one
resolves without you), or for a good moment. An issue you stopped working
on is abandoned — unassign and restore `ready`. Parked claims are held
beside the one active build (#15, #16, #73).
## Claiming ## Claiming
- Assign yourself, swap `ready``claimed`, and comment that you are - Assign yourself, swap `ready``claimed`, and comment that you are
starting. The claim is a promise of a draft PR soon — a claim with no PR starting. The claim promises a draft PR soon: a claim with no PR and no
and no activity is what the staleness sweep reclaims. activity is what the staleness sweep reclaims, unless `offsite` records
- **Abandoning is fine; ghosting is not.** If you stop, say where you got to, that its PR lives in another repo.
push the branch if it holds anything useful, unassign, and restore - **A park is declared, never inferred.** Comment naming what the claim
`ready`. waits on and who owns the next move — no new label; the comment is the
activity the reclaim clock reads, as for `needs-ruling` (#52) and
`offsite` (#68). Shape 4 is exempt: the handoff comment and
`state:needs-human` already say both.
- **A declaration stands until the park's facts change**, so a resumption
finding nothing changed posts nothing (#177). Each change owes one comment
— the wait resolves or changes hands, the shape changes, the claim
unparks. A parked claim with **no open PR** still feeds the 48-hour
reclaim clock, so refresh the declaration before it closes; that is a
park's only repeat.
- **Pick up `attention` before anything else**: post a short pickup comment
and remove the label, which is the ack. A demand on a parked claim is
usually its unpark, so take the slot back — unless the demand *is* the
park, the pickup comment then doubling as the declaration.
- **A directed hold keeps its bookkeeping visible.** The PR carries
`blocked` with a comment naming what it waits on; the issue stays
`claimed` and carries `attention` until the builder acks. Nobody unassigns
it, and the 48-hour reclaim does not fire while the claim has an open PR.
- **Unparking is a claim like any other** and takes the slot: if you are
active elsewhere, finish or release that work first and say which on both
issues. No machinery counts claims per builder, and none should be built
expecting this section to have specified one.
- **Abandoning is fine; ghosting is not.** Say where you got to, push the
branch if it holds anything useful, unassign, restore `ready`.
## Building ## Building
- Branch per issue; open the PR **as a draft early**, `Closes #N` in the - Branch per issue; open the PR **as a draft early**, `Closes #N` in the
body. Drafts are invisible to the reviewer panel on purpose — the draft body. Drafts are invisible to the panel on purpose: that phase is yours.
phase is yours. - **`Closes #N` does not cross repos.** A PR in a different repo from its
- **The issue's acceptance criteria are your definition of done.** Reproduce issue says `Part of <owner>/<repo>#N`, sets `offsite`, and comments the
them as a checklist in the PR body and check them honestly as you go. If draft link on that issue in the same step; triage closes that issue by
one turns out to be wrong or unreachable, say so on the issue and get it hand once its criteria are met, the builder reporting there whether the PR
amended by triage — do not silently ship less than the issue says. merged or closed and clearing `offsite` in the same comment. The
- Every behavior change adds one line to `CHANGELOG.md` under cross-repo merge never closes the authorizing issue (#13, #16).
`## Unreleased` — insert **above** the heading below it, never over it - **`Closes #N` does not survive a post-merge criterion.** Where the issue
(the monotonic guard's whole reason to exist). body says a criterion can only be checked after the merge — a workflow
trigger proved live, a released artifact, anything whose subject does not
exist until the change is on the base branch — the same-repo PR says
`Refs #N`; the issue goes `post-merge` at the merge, the builder walks
away, and triage owns verification and closure on the evidence, returning
the issue to `ready` or minting a fresh one where corrective work is
needed — claimable by any builder from current `main`, the original having
no special standing. The issue body says so — you never judge which
qualify — and absent it `Closes #N` is the default (#151).
- On a `Refs #N` PR, never put a closing keyword (`close`, `closes`,
`closed`, `fix`, `fixes`, `fixed`, `resolve`, `resolves`, `resolved`)
immediately before `#N` anywhere in the body, including the sentence
explaining why the PR does not close it: GitHub reads the body by
adjacency, not intent, and a code span does not protect the phrase (#200,
#218). Put the number first (`#N is closed by hand`) or omit it.
- **The issue's acceptance criteria are your definition of done**: reproduce
them as a checklist in the PR body and check them honestly. One that turns
out wrong or unreachable goes back to triage to be amended, never silently
shipped short.
- **Every behavior change writes one fragment**, `changelog.d/<issue>.md`
named for the authorizing issue (`<repo>-<issue>.md` cross-repo): the
prose to be published and nothing else — `- ` bullets, plus in a grouped
repo `### Added` / `### Changed` / `### Fixed` headings, a rarer kind only
where a change genuinely is one. An entry is at most 300 characters, so a
long change ships several short ones (wrapping over continuation lines is
free), and it **ends with its issue citation**: a parenthesised group of
`#N`, `repo#N` or `owner/repo#N` separated by `, `, then the final `.` and
nothing after — `(#262).`, `(#236, #250).` — which need not name the
fragment's own issue, the filename carrying it. The guard reds a long
entry (#167) and an uncited one (#262). Never edit `CHANGELOG.md`: the
release PR assembles the section from fragments (#112), and the monotonic
guard refuses anything deleting a shipped heading.
- Follow the repo's conventions file and match the code you touch. Tests are - Follow the repo's conventions file and match the code you touch. Tests are
not optional: the issue's test plan is the floor, not the ceiling. not optional: the issue's test plan is the floor, not the ceiling.
- **A write-capable job gets a repo-owned script, not a third-party
action.** Where the token can write (`packages: write`, `contents: write`,
`id-token: write`, deploy secrets), default to a script a test can drive;
a third-party action there needs an established publisher and a
full-commit-SHA pin, and read-only jobs still SHA-pin. The full rule and
its red-flag profile are in REVIEWER.md §What you review against, item 2
(#216).
- **Scope discipline: the PR does the issue — whole, and nothing else.** - **Scope discipline: the PR does the issue — whole, and nothing else.**
Adjacent problems you discover go to a **discussion** (or a comment on the Adjacent problems go to a proposal, or a comment on the relevant issue;
relevant issue), where triage will do its job. You do not mint issues — you do not mint work issues — nobody but triage does — and you do not fix
nobody but triage does — and you do not fix drive-by findings in the same drive-by findings in the same PR.
PR; a reviewer cannot converge on a moving, widening target.
## The review round ## The review round
(If you are reading this as `.ceremony/BUILDER.md` in a governed repo: the (In a governed repo this file is `.ceremony/BUILDER.md`: repo-specific facts
panel roster and any repo-specific flow notes live in that repo's own such as the panel roster live in that repo's own CONTRIBUTING.)
CONTRIBUTING; everything below is the shared flow.)
1. Mark ready-for-review; request **the whole panel** (the roster is in the 1. Mark ready-for-review; request **the whole panel**: the PR repo's
repo's CONTRIBUTING). `panel[<your-login>]=` line if it defines one, else its `panel=` line,
minus the author (#224) — never the roster of the repo the issue is in.
That repo's `.github/labels.conf` governs over its CONTRIBUTING roster,
being what the state machine reads; where it names no roster, ask triage
on the authorizing issue rather than guess. An off-panel reviewer may be
requested, said to be advisory and not required.
**A review request requires a green check at the head**, whether or not
an engine enforces it: a red check is the author's own signal, so fix it
and push, then request. The one exception is a failure genuinely outside
the PR — a runner outage, a flaky dependency, a failure already on the
default branch — and only where the request says so and names the
evidence ("the same job fails identically on `origin/main` at `<sha>`");
silence about a red check is what is prohibited, and an argued exception
shifts the burden to the author.
*Green* is a ruled term (operator, 2026-07-27), read in two steps.
**First take the check's word at this head**: its newest entry by start
time — not completion, a cancelled run outliving its replacement's start
— and never a `CANCELLED` entry while the same check has a non-cancelled
one there. A check whose entries at the head are all cancelled has not
reported at all and is not green — a collapse, not a new class, and the
gate partitions alike, dropping a cancelled entry only where a
non-cancelled survivor remains and leaving an all-cancelled context
blocking (#139, #276). **Then classify that entry by `conclusion`, never
`status`**, which can disagree with it (#259). No conclusion is not
green: a configured run in progress is waited on, and waiting is
compliance, not a stall — the wait is the **request's**, and never a
reason to withhold the declaration that a round was answered (step 2).
Cancelled or stale is not green, *stale* being a
superseded head's check, which a head-scoped rollup never shows. Skipped
or neutral is green, those being deliberate "passed / not applicable"
conclusions. No checks configured is green — the third ruled case, not an
argued exception, so the request goes out at once with no evidence owed;
that never covers nothing-answered-yet, and the machine partitions alike,
admitting the ask on `SUCCESS` and `NONE` (#236). The costs behind the
line are asymmetric: a false green spends a three-reviewer round, a false
red one author session. What the machine drops from the rollup before
grading is crew's to describe.
2. **Wait for every verdict, then answer the round whole** — one reply 2. **Wait for every verdict, then answer the round whole** — one reply
covering every point, then push the fixes, then re-request exactly the covering every point, stating what changed and what was verified. That
reviewers who did not approve. Prefer verification over argument: when a reply is the written record: the engine mirrors it under the PR body's
reviewer doubts behavior, add the test that settles it. **Round log**, newest last and marked with the round's head, which makes
a retry a no-op; you owe the reply and no body edit, and a round answered
without one is recorded as such and never blocks handoff. Then push the
fixes and re-request **by head, not by verdict**. A push makes every
approval stale — an approval is of a specific tree, and the handoff
predicate counts only approvals at the current head — so **every panelist
is re-requested, approvers included**; one left un-re-requested can never
approve the tree you shipped (#26, #39). Only where the head did not move
— answered with argument or evidence, nothing pushed — do you re-request
just the non-approvers; the engine absorbs a re-request at an unchanged
head, and its mechanism is crew's to describe (#94). **The re-request
carries the same green-check-at-head precondition**, argued exception
included: a fix push whose check comes up red is your next fix, not the
panel's. **Where an engine mediates the request, that precondition binds
the engine's act and not yours**: declaring a round answered is not
requesting the panel, so declare it as soon as the round's fixes are
pushed and stop. The engine holds the request while the head is pending
or red, so an early declaration cannot produce an early request while a
withheld one is indistinguishable from a session that died (#330).
**Never wait on an event you have no wake for** — where the engine is
what observes the check settling, the wait is the engine's to keep
(#330). **Never block on a producer you cannot prove alive either**:
where a job signals its own completion, that signal is the wake and the
finished output is read afterwards, because a follow on a file nothing is
writing cannot tell *not yet* from *never* (#336). Prefer verification
over argument — add the test that settles the doubt.
3. Never dismiss a review, never merge, never mark your own work as passed. 3. Never dismiss a review, never merge, never mark your own work as passed.
A blocking point you disagree with is answered with evidence or escalated A blocking point you disagree with is answered with evidence or escalated
in the PR — a maintainer can be asked for a ruling; silence and in the PR; silence and force-forward are not options, and a panel
force-forward are not options. deadlock is one kind of human-owned decision (#50 D11).
**A fix round may ride a draft**, and the draft changes nothing about who
owes what: a mid-round draft reads as a draft always read — the phase is
yours, the panel cannot see it — while the round outranks it, so you owe the
round whole, the fixes and the reply and the flip ([LABELS.md](LABELS.md)'s
`state:building` row, #205). **Ready-for-review is the act that ends the
round, and it is the builder's alone**: the flip asserts the round was
answered whole, the one judgement its author cannot delegate, so an engine
may draft a PR but only the builder undrafts it. **Where a draft suppressed
the checks, green is proven at the flip and the request still follows it** —
marking ready runs the checks the draft held back, so the order is flip, let
the head answer, then request, step 1's precondition and not a second one.
Waiting there is compliance — again the request's wait, not the
declaration's — and `blocker:unrequested` does not fire while a head's
checks are pending or red (#236).
## The ruling ask
Set `needs-ruling` whenever a decision belongs to a human: org policy,
published artifacts, secrets, prod, or any choice whose cost lands outside
the PR — a panel deadlock is one instance, not the definition. The builder
is the PR's accountable flag-setter and consolidates the decision into one
comment rather than forwarding several reviewers' phrasings (#50 D11).
Keep at most these five lines above the fold, all other analysis inside it.
The field labels are fixed because the ruling machinery checks for them (#50
D12):
```text
🧭 needs-ruling — <the decision, one line>
Options: A — <one clause> B — <one clause>
Recommend: A, because <one clause>.
Blocked: <what stops; what continues meanwhile>
Default: <A at 2026-07-23T21:00Z if no ruling> | none — hard block
<details><summary>Analysis</summary>…everything else…</details>
```
The options must be exhaustive and mutually exclusive; more than three means
the question is not ready. `Recommend:` is mandatory — omitting it hands the
whole problem to the human. `Blocked:` names both what stops and what
continues. Write a timed `Default:` only when affirmatively confident the
decision is reversible inside the PR before merge; unsure is not a tie but a
hard block, as published artifacts, secrets, prod and org policy are by
construction (#50 D12D13).
The ladder is anchored to the current episode's `needs-ruling` **`labeled`
event**, not its `Default:` deadline or the last activity (#50 D13D14):
- **012h:** proceed when a still-clear, reversible default expires, saying
out loud that you did; a hard block waits.
- **at 12h:** do not fire a stale default — re-read it against what has
landed, and where doubt has appeared, make it a hard block.
- **at 24h:** proceed regardless, **as a PR**: pick an option and say in the
body which way you went and what doubt remains. Nothing merges by this;
the human still gates the merge.
- **past 24h:** hand the choice to triage, which picks the option, records
it as a decision, and stays accountable; the operator can overturn it at
merge.
A re-flag starts a fresh ladder, which applies whatever `Default:` says,
hard block included, and an active back-and-forth still climbs it — unlike
the 7-day nudge, which resets on real activity. The machine observes both
clocks but never sets, clears, or decides `needs-ruling`. The label stays
until agreement is *reached*, not until the maintainer replies: the setter
records the ruling, removes the label, and returns the item to its flow in
the same comment ([LABELS.md](LABELS.md)).
## Handoff ## Handoff
When the round passes — every panel verdict approves the **current head**, When the round passes — every panel verdict approving the **current head**,
and no `blocker:*` stands (conflicts rebased, CI green, drill recorded if no `blocker:*` standing (conflicts rebased, CI green, drill recorded if this
this is a release PR) — hand it to the human, in order: is a release PR) — the engine does these steps for the builder, in order:
1. post the round summary (what changed per round, what was verified); 1. request the human's review;
2. request the human's review; 2. set `state:needs-human`;
3. set `state:needs-human` yourself. 3. post the engine-rendered handoff comment: approvals at the current head,
the head SHA, and a pointer to the PR body's **Round log**.
The label write is optimistic — the reconciler validates it, and takes it The builder composes no new summary: the authored record already lives in
back if the PR is not actually mergeable-right-now. Then stop: the PR is the the Round log, mirrored from each whole-round reply. The label write is
human's. Address what comes back (`state:addressing`) and re-hand-off the optimistic — the reconciler validates it and takes it back if the PR is not
same way. mergeable-right-now. Then stop: the PR is the human's, and the claim parks
as shape 4 (Picking, above), that comment its declaration and your slot
free. Address what comes back (`state:addressing`) and re-hand-off the same
way.

View file

@ -2,9 +2,992 @@
The curated history of the ceremony itself. Each release's section is The curated history of the ceremony itself. Each release's section is
published verbatim as that release's body (lib/changelog.sh extracts it), published verbatim as that release's body (lib/changelog.sh extracts it),
so entries say what changed, cite the issue, and stop. so entries say what changed, cite the issue, and stop — at most 300
characters each, guard-enforced on the PR that writes the fragment (#167);
a genuinely long change ships several short entries, never one long one.
The citation is guard-enforced too, and it closes the entry: one `(#N)`
group, then the final `.` and nothing after it (#262). Sections published
before that rule keep their prose; the guard reads fragments only.
Entries arrive as fragments — one `changelog.d/<issue>.md` per PR, never
an edit to this file — and the release PR assembles them into the next
section here (`bin/changelog-assemble`, #112).
## Unreleased This tree is `heavy-duty/ceremony` on
`forgejo.heavyduty.builders`, and it tracks the upstream tree's version
numbers (#197 D2). Two trees therefore answer to the same number, differing
by the forge-compatibility delta. **This tree carries upstream through
`8c3a4d1`** (upstream `0.6.0`, merged by #198); the `0.4.1` section below is
this forge's own release, not upstream's, and upstream `0.6.1` through `0.6.3`
were adopted by port rather than merge. Upstream's separate `0.4.1` section
is deliberately not carried — the tag published here is the one this section
is the body of. Each sync updates this line (docs/UPSTREAM-SYNC.md, #200).
## 0.6.3 — 2026-08-26
### Changed
- The shipped 0.6.2 changelog section now carries #238's entry, which its release PR's merge base could not see; the published 0.6.2 release body is left as tagged, so tree and publication differ by that one line (#238, #231).
- Replace the unavailable intake rule with a proposal form that triage converts into work or refuses with reasons (#247).
### Fixed
- The `needs-triage` label now directs untriaged issues toward work normalization or a reasoned refusal instead of an unavailable discussion path (#265).
- Release checks now refuse a target-head fragment that the candidate did not consume, preventing late merges from misattributing shipped changes (#253).
- Kept drill doctrine's release-path instructions aligned with the executable manifest by removing its duplicate path list (#251).
- Preserve Forgejo workflow names in status rollups so the label reconciler excludes only its own checks. (#243).
- Fork-headed label runs stay green without attempting forbidden writes, while same-repository heads keep instant scope and reconciliation wakes (#241).
- Read Forgejo timelines to exhaustion so busy issues retain their newest label events despite dishonest total-count headers (#240).
- Refs-based issue-flow transitions now bind each declaration to its immediately following reference token, so later issue prose cannot release or preserve unrelated claims (#234).
## 0.6.2 — 2026-08-24
### Changed
- `upstream-0.6.1` was ported in #229: CONTRIBUTING routes the vendored set
through `docs/VENDORED.txt` — upstream#316 and upstream#311; BUILDER.md
scopes the green-check precondition to its act, upstream#330; RELEASES.md
adds the post-merge gate-member split, upstream#329 (#246).
- `upstream-0.6.2` was ported in #229: BUILDER.md orders parked claims so an
operator-owned remainder parks the claim, never the handoff — upstream#336
(#246).
- `upstream-0.6.3` was ported in #230: release windows read `## Members` with
no gate fallback, carriers leave their own gates, stale board flags stay
silent — upstream#343 and upstream#327 — and the parser accepts CommonMark
rows (#246).
- Upstream logic was ported onto this forge's Forgejo-adapted issue-flow
reconciler, test, and CONTRIBUTING; those files were never overwritten with
upstream bytes (#246).
- Upstream's drill-record fixes and the upstream `0.7.0``0.7.4` line are
deferred to the next sync campaign (#246).
- No upstream ancestry moves in this release: `.upstream-ref` remains
`8c3a4d1` from upstream `0.6.0`, merged by #198; `upstream-0.6.3` is the
content baseline, not a merge-base (#246).
- Release windows now read membership from a dedicated `## Members` record, with CommonMark-bounded rows and no fallback to predecessor gates (#230).
- Forge consumers now receive the upstream 0.6.1 and 0.6.2 doctrine for vendored-set routing, review-round signalling, operator-owned remainders, producer liveness, and post-merge release edges (#229).
- `docs/RUNNER-PROBES.md` records the delivered 0.6.1 consumer exercise in the standing Forgejo runner venue (#217).
### Fixed
- Review-round state now reads each forge's live review-request set directly, so stale Forgejo approvals no longer hand an in-progress fix round back to the panel (#238).
- Forgejo drafts and fast-forward conflict-check windows no longer surface as merge conflicts when the API has not distinguished one (#236).
- Forgejo review requests no longer count as verdicts, while its blocking and comment states now grade like their GitHub equivalents (#235).
## 0.6.1 — 2026-08-09
### Added
- `test/labels-bootstrap.test.sh` pins the bridge at every hop: the declared
boundary, both gate sites as the identity, no expression reading
`github.event.inputs`, and the caller and stub pass-throughs
byte-exact (#215).
- The same test drives the four value paths — schedule-empty, `no`, `yes`,
invalid — through the shipped expressions into the action's real
validator (#215).
- The taxonomy bootstrap keys on the `BOOTSTRAP` input, never the event name.
It tested `GITHUB_EVENT_NAME = workflow_dispatch` — correct while an
operator's manual dispatch was the only dispatch there was, inert-by-
construction from #209 on, when every machine wake became a dispatch
event (#215).
- The venue drill caught that: with the bridge delivering `no` perfectly,
drill runs 16/17 still bootstrapped, because the script never read the
input the whole chain existed to deliver (#215).
- `test/labels-reconcile.test.sh` pins the regression pair exactly: a
`workflow_dispatch` event with `BOOTSTRAP=no` (or unset) creates and
deletes nothing; only `BOOTSTRAP=yes` bootstraps (#215).
- A gather-level case drives the real board read against a Forgejo-shaped
fixture — every entry carrying the key. The existing discriminator cases
assert `jq` expressions in isolation and passed throughout this regression
(#210).
- A source pin forbids `has("pull_request")` on this surface, because the rule
was stated in a comment and violated forty lines below it. It strips comments,
so the #188 warning that explains the trap is allowed to stay (#210).
- All three sites are covered behaviourally, not only by the pin: the board
gather, the release-body gather through an observable window flag, and the
per-issue payload check (#210).
- `test/forge-backends.test.sh` pins each backend's path **and** field, because
a stubbed `forge_api` cannot catch a wrong path — which is how this shipped
and why a live sweep was what found it (#209).
- `test/labels-dispatch.test.sh` extracts the shipped step and executes it
against a recording stub, asserting the method, endpoint, ref and
`inputs.bootstrap` actually sent (#205).
- That test also drives the failure path: any non-204 still fails the job, so
the misconfiguration alarm the trigger exists to be cannot decay into a
warning (#205).
- An unset `GITHUB_API_URL` refuses before any request instead of defaulting
to `api.github.com`. Guessing sent this forge's dispatch to GitHub and
reported success — the "Never 'probably github'" rule, and the same
unset-environment refusal #201 established for docs-sync (#205).
- A dispatch that never reaches the forge names the failure. Letting `set -e`
carry curl's exit code out did fail the job, but with a bare status and no
sentence (#205).
- `docs/CONSUMERS.md` and both caller comments describe the REST dispatch, and
the manual bootstrap command carries a forge-neutral form beside the `gh`
one — a cross-forge runbook that directs this forge to a missing binary is
wrong even where the surrounding prose is right (#205).
- `docs/RUNNER-PROBES.md` documents the standing runner-probe venue,
`heavy-duty/ceremony-runner-probe` — the place runner-only facts are measured
on demand, ruled as option A by the operator (#202).
- `drills/README.md` cross-links it beside the disposal rule, so the exception
is visible where the dangerous habit lives (#202).
- The runbook states that the drill disposal rule does **not** apply to it.
Archiving it defeats its purpose, and that is exactly how the three existing
drill repos each became unavailable (#202).
- It records that a probe must run as an Actions job under the workflow token:
the same call answers 500 there and 204 under a PAT, so a probe run any other
way produces a confident wrong answer (#202).
- Creating the repo is recorded as the operator's step, measured rather than
assumed: a fleet identity gets 403 on org repo creation and 201 in its own
namespace (#202).
- It carries an executable two-layer arming procedure: an immutable candidate
code SHA and an armed workflow commit on top of it. A single layer is
self-referential — rewriting a workflow makes a new commit, and a commit
cannot contain its own object ID (#202).
- Callers are pinned by layer: composite actions to the candidate code SHA,
reusable workflows to the armed SHA, which is the only revision whose inner
checkout points at the fork (#202).
- The arming gate asserts what each carrier IS, not only that the old literal
is gone: every `repository:` equals the fork, every `CEREMONY_SELF_REF` value
equal the candidate code SHA, and callers match the layer they belong to
(#202).
- It enumerates the carriers from the tree rather than encoding a count, and
distinguishes ceremony's internal self-checkouts from the consumer checkouts
that must stay `${{ github.repository }}` (#202).
- Both published snippets are ShellCheck-clean when extracted and linted
directly, not merely as part of the repository sweep (#202).
- The checker validates the MANIFEST against the target it was given, so a
manifest that describes a wrong arming consistently — wrong fork, or the
armed SHA where the candidate belongs — refuses instead of matching a tree
rewritten to the same wrong value (#202).
- The manifest is generated from the PRE-arming tree, which is the only order
that enumerates the carriers that must change (#202).
- Both published snippets were driven against a constructed candidate/probe
pair: deletion, both role swaps, wrong owner, wrong
SHA, wrong path, a deleted caller class and an extra carrier all refuse, and
the armed control passes (#202).
- The manifest records complete caller coordinates, so a path swapped under the
right owner and SHA is caught (#202).
- Generator and checker share one domain — ceremony callers — so a third-party
`actions/checkout` is neither manifested nor reported as unrecognised (#202).
- Probe results are written to an issue in the probe repo and carried to the
ceremony issue by a human, so the probe holds no path that can write to the
live board (#202).
- `test/docs-sync.test.sh` drives the fetch path, which had no coverage at all:
every existing row passes `--source`, which overrides the fetch entirely
(#201).
- A stubbed `curl` records the requested URL and serves a tarball, so which
forge a pin resolves against is a tested decision rather than plumbing (#201).
- `docs/UPSTREAM-SYNC.md` — the recurring upstream sync as a runbook: the
standing resolutions, which side wins each and the issue that decided it
(#200).
- It names the step the 0.6.0 sync nearly shipped without: auditing what the
merge brought in that did **not** conflict. `git merge` asks no question
about a function upstream added to a file this tree owns (#200).
- It records that the same mechanic applies to state, not just to call sites: a
resolved region can silently remove a producer whose consumers auto-merged,
and every one of those consumers degrades to empty rather than erroring
(#200).
- It says to verify with the runner's tooling, because "green locally" was
wrong three times in one sync — untracked files, a pinned linter, and a
pinned `jq` whose empty-input exit code differs (#200).
- It says every branch open across a sync is stale afterwards — Forgejo never
re-tests an open PR when main moves, so a prior approval is evidence about a
tree that no longer exists (#200).
- It says to audit post-merge runs by executed steps rather than colour, and to
inventory what the sync changed about workflow triggers and jobs first (#200).
- `.upstream-ref` records the upstream commit this tree carries, in
machine-readable form beside the CHANGELOG's prose (#200).
- `test/upstream-delta.test.sh` fails the PR that scatters a forge decision
into a file the inventory does not name. Discovery is derived from the tree,
so a composite `action.yml` or a `.yaml` workflow is seen without anyone
remembering to add a glob (#200).
- Discovery is git's, not the filesystem's: `ls-files`, so the tarballs `ci.yml`
extracts into the checkout and any developer cache are not parsed as source
(#200).
- It refuses when the recorded commit is missing, absent from the object store,
or not an ancestor — three distinct refusals, none of them a skip. `ci.yml`
fetches that exact object so the test reads local evidence without CI
omitting it (#200).
- Its mutation cases drive the real check against a constructed tree, so
replacing the guard with `return 0` reds five of them (#200).
- `docs/CONSUMERS.md` states that two ceremonies answer to the same version
number, and how a consumer says which one it pinned (#200).
- `lib/issue_references.sh` — the LOCAL/CROSS classifier, moved out of
`actions/issueflow-reconcile`'s executable so a second caller can use it
without sourcing a reconciler, which would run one (#199, #61).
- `test/refs-not-closing.test.sh` drives the action's boundary on **both**
backends with stubs at the transport, proving one fixture yields the same
verdict on each — including a closing keyword that appears only in a commit
message (#199).
- This tree carries upstream ceremony through `8c3a4d1` (upstream `0.6.0`):
`lib/attention.sh`, `lib/read.sh`, `actions/refs-not-closing`, the guarded
reads, and the ruling and window rules (#198).
- `test/no-runtime-gh.test.sh` — the forge-portability guard: no runtime `gh`
outside `lib/forge-github.sh` unless the file declares
`CEREMONY_FORGE_CLIENT=gh` (#198).
- `CHANGELOG.md` names the upstream commit this tree carries, so a drill
record can say which `0.6.0` it exercised (#197, #198).
- `test/labels.test.sh` holds the conf's roster and `CONTRIBUTING.md`'s roster
table to the same set, in both directions, so a roster edit that touches one
file and not the other goes red instead of drifting quietly (#195).
- `test/forge-backends.test.sh` pins the replacement contract: preserve
unrelated labels across a combined add+remove, an absent removal as a
successful no-op, the empty set as a full clear, and `forge_labels_add`
still `POST`-only, per ceremony#128 (#192).
- `test/labels-reconcile.test.sh` drives a failing write through `main()` — the
swallow was in the loop, where a fixture-level probe cannot reach (#192).
### Changed
- The review panel restores `kimi-reviewer-andresmgsl` alongside GLM. The
four-identity panel now requires three cross-vendor approvals after the PR
author recuses (#224).
- The review panel names `glm-reviewer-andresmgsl` in place of
`kimi-reviewer-andresmgsl` while that identity is unavailable. The panel
stays three, so a PR still requires two cross-vendor approvals (#222).
- `test/labels.test.sh`'s table-side roster mutation names an identity the
table actually carries. Naming a departed one would mutate nothing and the
case would pass while proving nothing — #195's rot class one layer down
(#222).
- This forge's release line runs `0.4.1 → 0.6.1`: versions 0.5.0 and 0.6.0
arrived here by merge from the read-only upstream and were never released
on this forge (#220).
- The `## 0.6.0` section this changelog carries is upstream's — its entries
describe upstream's work under upstream's issue numbers. The forge port's
own work ships first in 0.6.1 (#220).
- `docs/RUNNER-PROBES.md` records the venue's first delivered drills — the
#192 asymmetry re-observed on demand under the workflow token, the dispatch
route's 204 under both identities, and #215's boundary finding — each with
the probe-issue URL it is recorded in (#202).
- Two venue lessons join the runbook where the next probe author will look:
findings must be written to issues because the venue's log route 404s for
non-admin reads, and report content sent to the forge must never contain a
credential expression or value (#202).
### Fixed
- The sweep's `bootstrap` value crosses the `workflow_call` boundary as a
declared input, explicitly passed by the caller — the one channel measured
to work; the called workflow did not see the caller's event inputs as an
implicit substitute on this instance (#215).
- Before the bridge, `github.event.inputs` was empty inside the called
workflow, so every dispatch-woken sweep bootstrapped: ~20 label upserts on
each board event (#215).
- The caller maps an empty top-level value to `no` explicitly, so a
cron-woken sweep can never bootstrap; the declared input also defaults to
`no`, so a consumer that passes nothing gets the safe path (#215).
- The gate feeds the declared input to `labels-reconcile` unchanged, so an
invalid value meets the action's own `yes|no` refusal instead of being
silently coerced (#215).
- `docs/CONSUMERS.md`'s published sweep stub carries the same pass-through —
without it every consumer inherits the defect ceremony fixed for
itself (#215).
- `issueflow-reconcile` sees this forge's issues again. The board gather used
`has("pull_request")`, and every Forgejo entry carries that key — so it
selected zero rows on every sweep while printing `reconciled.` (#210).
- Three sites take `.pull_request == null`, the discriminator the file's own
comment already specified and that one of its four call sites already used
(#210).
- `post-merge` transitions can fire again: they could not, because the sweep
saw no issues to transition (#210).
- `blocker:unrequested` is judged on this forge again. The head-commit date was
read from `repos/{o}/{r}/commits/{sha}`, which Forgejo answers **404** — so
every sweep degraded and left the blocker unjudged (#209).
- `forge_commit_at` is a verb on both backends: GitHub serves a single commit at
the bare path with the date nested, Forgejo at `git/commits/{sha}` with it
under `.created`. The caller asks for one timestamp and knows neither shape
(#209).
- `.github/workflows/labels.yml` wakes the sweep over REST instead of
`gh workflow run`, so a board event reconciles within seconds on any forge
rather than waiting up to an hour for the scheduled sweep (#205).
- The workflow-dispatch endpoint has the same shape on both forges, so that
step no longer decides one: the `CEREMONY_FORGE_CLIENT=gh` declaration and
both inline refusals are gone rather than ported (#205).
- The dispatch supplies its `ref` explicitly, because REST has no default
branch where `gh workflow run` had one, and refuses without it (#205).
- It takes that ref from the repository, never from `GITHUB_REF_NAME` — on a
`pull_request_target` run that is `<n>/merge`, which is not a branch (#205).
- A failed dispatch names the endpoint, the ref and the status, and says that
an empty `500` body from Forgejo means the workflow name or the ref did not
resolve — a bare status sends the reader after a server fault that is not
there (#205).
- `actions/docs-sync` fetches the doctrine mirror from the forge named by
`GITHUB_SERVER_URL` instead of a hard-coded `github.com` (#201).
- The same pin ref names a different tree on each forge, so a consumer's mirror
was verified against a tree it never pinned — and with HTTP 200, so `--check`
reported drift that could not be fixed (#201).
- A fetch that cannot name its forge now refuses instead of guessing: no
`GITHUB_SERVER_URL` and no `--source` exits naming the variable, having
reached for no network (#201).
- A failed fetch names the URL it actually tried, and asks whether the ref
exists on that forge rather than in the abstract (#201).
- `actions/refs-not-closing` gathers over REST through the forge shim instead
of one GraphQL query, so it produces a real verdict on Forgejo — which
serves no GraphQL surface at all — rather than refusing (#199).
- The closing set is parsed by `lib/closes_references.sh` over the PR body
**and** every commit message, unioned. Forgejo honours closing keywords in
commit messages, so a body-only port would miss a PR that closes an issue
from a commit subject (#199).
- The `hasNextPage` refusal is carried onto the paginated commit read: an
incomplete gather refuses instead of returning a partial verdict, reusing
the backend's `x-total-count` completeness proof (#199).
- A failed read never reaches the parser. An unread body parses to an empty
closing set, which is a passing verdict the action never earned (#199).
- `.github/workflows/refs-guard.yml` no longer gates the job on the forge.
A portable action behind a github-only gate is a guard that passes by never
running (#199, #198).
- The `CEREMONY_FORGE_CLIENT=gh` declaration is gone, and the contract test
asserts its absence: an opt-out with no `gh` behind it is a standing
permission slip (#199).
- Eight runtime `gh` call sites arrived with the merge outside every conflict
hunk, in functions upstream added to files this tree already owned. Seven
are ported onto the shim; the eighth is named with its reason (#198).
- The open-PR gather reads `Refs`, not only closing keywords. Reading one side
for closing links and the other for `Refs` is what released a live claim in
crew#321, and this tree carried that shape (#198).
- The merged record gains `merged_at`, so `post_merge_pr_for_issue` answers
the PR that merged last rather than the highest-numbered one. Without the
column every sort key ties and the old order returns silently (#198).
- The open gather feeds `open_pr_issues` one record per physical body line. A
whole decoded body as one record loses every declaration including the
first, and reclaims a claim a live PR was holding (#198).
- The post-merge nudge links the issue on the forge in play rather than a
hard-coded `github.com` (#198).
- `actions/refs-not-closing` reports and skips on a forge it cannot speak,
naming the client and #199, instead of standing red on every PR. It reaches
the forge zero times, so no verdict is produced either way (#198).
- `.github/workflows/labels.yml`'s sweep dispatch declares the client it
speaks and decides the FORGE before the binary, so a Forgejo runner that
happens to ship `gh` cannot dispatch against a forge that cannot serve it.
#205 ports it to REST (#198).
- `actions/refs-not-closing` fails closed on a forge it cannot speak, and
`.github/workflows/refs-guard.yml` carries the scheduling decision — the
action never reports a success it did not earn (#198).
- `issue_payload_valid` refuses an empty payload on jq 1.6 as well as 1.7.
`jq -e` exits 4 on empty input under 1.7 and **0** under 1.6, and this
instance's runner carries 1.6 — so the guard #247 D3 added to refuse an
unreadable read was accepting one here (#198).
- The post-merge nudge strips a trailing slash from the server URL, so a forge
URL carrying one does not render `//owner/repo` (#198).
- `.github/scripts/release-path.sh` names `lib/forge.sh`: #191 put the shim on
the release doors' executable path here, so a doors-unchanged record that
omitted it was measuring the wrong set (#198).
- `.github/labels.conf` names identities that exist on the forge this repo
lives on. All five it named before were absent, so `panel=` could never
converge a review round and `triage-actors=` made every issue a stray mint
nobody could normalize (#195).
- `CONTRIBUTING.md`'s roster table matches the conf: three identities, the
human row is `andres`, and the approval count states what panel-minus-author
actually resolves to on this roster rather than a stale three (#195).
- Label removal on Forgejo is a full-set `PUT`, not a per-label `DELETE`. The
workflow token gets HTTP 500 on every `DELETE .../labels/{id}` on this
instance, so the state machine could only ever ADD labels (#192).
- Every `state:*` transition that needs the previous state cleared, and every
`blocker:*` that should lift, can now actually clear. They were inert (#192).
- A label edit that fails is fatal to `labels-reconcile`, matching
`issueflow-reconcile`. One cause had two contradictory policies (#192).
- A failed write reaches the sweep's exit code: per-PR tolerance is kept for
READS, but a sweep that could not write exits non-zero and its output carries
no `reconciled.` token at all (#192).
- Every label mutation goes through one checked helper, so clearing
`merge-next` or either `stale` edit fails the sweep too — not only the
primary state edit (#192).
- A preserved label keeps the id the issue payload already carried, so
preservation does not depend on a repository-wide list that has nothing to do
with the issue (#192).
- A removal that changes nothing writes nothing, rather than replacing the set
with itself and opening a race for no state change (#192).
- Every failure diagnostic on the forgejo backend names the verb as well as the
path and the status. A read used to say `HTTP 500 from 'repos/…'`, which
cannot be told from a failed write of the same path (#192).
- The diagnostic names what was attempted and that it did not happen, instead
of blaming a missing label and telling the operator to bootstrap — a cause it
had not established (#192, #101).
- An add-label the repo does not carry refuses before any write, so a
replacement `PUT` can never drop a label nobody asked to remove (#192).
## 0.6.0 — 2026-08-05
### Added
- The issue-flow sweep's `claimed`-branch ruling pre-read is pinned: an
unassigned claim under `needs-ruling` must draw its board diagnostic and
its ruling nudge in one sweep, so a read that drifts below the diagnostic
reds instead of silently costing the escalation 7 days (#284, #307).
- The issue-flow sweep now flags a collision the board never declared: two
open, unblocked issues whose titles name one deliverable draw a comment
naming the newer's owed `Blocked by` edge. Keys normalize, so
`actions/x` and `x` are one deliverable (#288).
- The sweep now flags an unblocked non-member during a standing release
window, naming the window's invariant. `claimed` counts, PR in flight or
not. The gate is read from the release issue's own `Blocked by`
declarations, and an emptied gate leaves it dormant (#292).
- Both flags are advisory: comments only, no label write and no state
change, deduped against each family's last word on the thread so a
standing state re-sweeps silently (#293).
- The fragment guard now requires each entry to end with its issue
citation: one `(#N)` group — local, `repo#N` or `owner/repo#N`
references separated by `, ` — then the final `.` and nothing after it
(#262).
- The refusal distinguishes an entry carrying no reference at all from one
whose reference is present but not terminal, and names the shape to
write in both (#262).
- The 300-character bound still outranks the citation across the whole
fragment, and the outranked problem stays out of the message it lost
to: one fragment, one diagnosis, wherever in the file it sits (#262).
- BUILDER.md now describes a fix round that rides a draft: the draft phase
stays the builder's, ready-for-review is the builder's own act, and where a
draft suppressed the checks green is proven at the flip (#258).
- REVIEWER.md now reads a draft carrying `state:addressing` as a fix round in
progress rather than abandonment (#258).
- A `post-merge` item with no comment for 7 days now draws one nudge from the
issue sweep: the wake evidence is owed. A starving criterion used to be
found only when someone happened to run the right read (#254).
- Label churn does not reset that clock, and neither does an assignment: on
`post-merge` an assignee is an invalid composition, not activity, and it
must not buy the item another 7 days of silence (#254).
- The nudge names the triage actor from `triage-actors=`, not the human
reviewer: `post-merge` is triage's completion queue, so the starved wake
condition is triage's to answer (#254).
- It links the item and parses nothing from the body — which criterion
starved is prose, and the machine never judges prose (#254).
- Like the ruling nudge it carries no idempotency marker on purpose: the
comment is itself activity, so the rule self-rate-limits to one nudge per 7
quiet days. Comment-only — no path here writes a label (#254).
- Release epics now announce release initialization when their declared dependency gates clear (#253).
- The issue sweep now echoes an issue's parsed `Blocked by` set as a comment
whenever that set changes, so a readable-but-wrong declaration is visible in
one sweep instead of days later, when a human happens to run the parser by
hand (#252).
- The echo's marker carries the parsed set itself: an unchanged parse never
re-posts on a 15-minute cron, and a changed one always speaks. Comment-only
— no path here writes a label (#252).
- CI now refuses a root `*.md` declared in neither `docs/VENDORED.txt` nor the
guard's short exemption list, so a new doctrine file can no longer reach a
tag undeclared and stay invisible to every consumer's `docs-sync` (#251).
- The same guard reads the manifest the other way: every entry must resolve to
a regular, non-empty, tracked file — no symlink, no directory, no `../`
escape (#251).
- Document the optional, operator-ruled release-epic flow for governed repositories. (#248).
- Guard documentation availability markers against missing issue citations
and release candidates that already ship the cited work (#238).
- The label and issue-flow sweeps now comment once per episode when
`attention` targets a pull request or an unassigned issue, without
retargeting the demand or changing labels or assignees (#232).
- Pull requests that promise `Refs #N` now fail a read-only, body-edit-aware
guard if GitHub would close N through a keyword or sidebar link (#218).
### Changed
- `README.md` is rewritten whole from the current tree: the front page names
the governance repo ceremony now is, routes to `docs/CONSUMERS.md`,
`AGENTS.md`, `LABELS.md` and `RELEASES.md` rather than restating them, and
keeps the operator's release runbook as its core, re-measured (#311).
- Standing release windows are dependency DAGs: every mint is placed in the window or behind it, and only current sources are `ready` (#292).
- TRIAGE.md now requires unconditional collision-edge chains when open issues
carry the same deliverable, keeping the ready queue concurrently claimable
(#288).
- TRIAGE.md now states its rules with bare record cites: the label-race and
lifted-hold incident narratives leave the normative text while their
operational rules remain complete (#282).
- `BUILDER.md` states its rules and cites their record bare: the incident
narratives, the links into issue comments and the cross-repo issue cites
leave the normative text, which no rule leaves with them (#281).
- CONTRIBUTING.md now keeps vendored doctrine self-contained: state the rule,
retain at most one sentence of why, cite the local record bare, and leave the
incident narrative in that record (#280).
- BUILDER.md's green ruled term now says which entry to read before it says
what an entry means: a check's word at a head is its newest entry by start
time, and a cancelled entry is not that word while the same check carries a
non-cancelled one at that head (#276).
- A check whose every entry at the head is cancelled is unchanged — nothing
survived to be its word, so it never reported and is not green — and the
collapse mirrors `checks_state`'s carve-out rather than adding a class
(#276).
- BUILDER.md's step 1 now rules the checkless head: no checks configured is
nothing to wait for, and the request goes out straight away — stated once,
in the ruled-term paragraph, with the draft-round restatement removed
(#272).
- `README.md` and `RELEASES.md` derive `scope:docs`, and the
`changelog-assembled`, `docs-sync` and `runner-isolated` actions and tests
derive `scope:guards`; all five were mapped nowhere. The docs block matched
a literal `README`, which this tree does not carry (#267).
- `lib/read.sh` and `lib/ruling.sh` derive `scope:labels` beside
`scope:release-flow`. Both reconcilers share them, and a mixed file wears
both labels rather than `lib/**` being re-carved into a row per file (#267).
- TRIAGE.md now tells every epic author to put its progress checklist under
the literal `## Task list` heading, because any other heading is silently
invisible to the completion sweep (#266).
- TRIAGE.md now scopes the no-assignee board bug to flagging an unassigned
issue, while still directing triage to repair ownership instead (#264).
- `BUILDER.md` and `CHANGELOG.md` state the citation as guard-enforced
rather than as house style, beside the 300-character bound it now sits
next to (#262).
- Four fragments in flight gained a terminal citation; published sections
are untouched, so no shipped prose is re-opened (#262).
- BUILDER.md's green ruled term now names its field: greenness is read from
each check's `conclusion`, never its `status`, and *stale* means a check
of a superseded head — not a same-head node whose `status` lags its own
conclusion (#260).
- Consumer guidance: re-vendor tooling reads the pin's `docs/VENDORED.txt`,
never a hardcoded list, so a new doctrine file propagates at the next
ordinary pin bump with zero list edits (#251).
- Define the doors-unchanged drill record and an executable release-path list,
so a release may reuse live evidence only when its door bytes are unchanged
since the last rehearsed tag (#237).
### Fixed
- A roster edit no longer reds the whole suite: the labels-reconcile
state-machine fixtures name their own panel instead of binding
`.github/labels.conf` by slot (#304).
- Shrinking `panel=` to three had left that binding's third slot unbound, and
`set -u` aborted the file before its first assertion — 217 assertions
became 0, on `main` and on every branch cut from it (#304).
- The one case still reading the shipped roster asserts a property, not a
size: it parses, and each member is recused from its own panel. Any
`panel=` of one or more members leaves `test/run.sh` green (#304).
- `lib/attention.sh` locates as label machinery beside its two shelf-mates —
`[scope:release-flow]` alone was a wrong answer of the class #267 measured
— and the map learns the sweep workflow pair, the shared-lib tests, and
seven enumerated test/guard surfaces (#302).
- Claiming a `needs-ruling` issue no longer buys its escalation another 7
quiet days: the issue-side ruling clock reads comments alone — an
assignment is the claim clock's fact — and LABELS.md now names what each
surface's clock reads (#284).
- `scope:release-flow` no longer rides every pull request: `changelog.d/**`
is out of its path map. Doctrine makes every behavior change write a
fragment, so the glob labelled 20 of the last 20 PRs while 3 touched a
release surface. `CHANGELOG.md` stays, as only the release PR edits it
(#267).
- The issue-flow reconciler and its test now derive `scope:labels`, the scope
that already names the taxonomy they reconcile (#267).
- Abort issue-flow reconciliation when the board read fails instead of reporting a complete pass over an empty or partial result (#257).
- The issue sweep no longer derives label writes from a read that failed. An
HTTP 504 whose body is GitHub's JSON error object passed every guard and
emptied the label set, so a healthy epic was written `needs-triage` and the
pass reported success (#247).
- A failed comments read no longer reclaims a live claim. Swallowed, it dated
the issue by `created_at` and unassigned the builder under a comment
asserting 48 hours of silence about an issue commented on seconds earlier
(#247).
- A failed comments read no longer reads as "no marker", which re-posted the
comment the marker exists to suppress (#247).
- Every read inside the per-issue subshell is checked explicitly, on its
status and on its payload shape; the issue is left exactly as it is and the
sweep continues. A partial pass names its skipped issues after
`reconciled.` (#247).
- A per-issue pass is now atomic: its writes and its log lines commit only
once the pass completes. A skip could previously land after an earlier
mutation, reporting an issue as untouched when a label had already been
written or removed (#247).
- The issue-flow sweep now reads an issue's deliverable as the `Refs` PR that
merged last, not the one numbered highest — merge order is not number order,
and the old rule spent the transition marker on the wrong PR (#242).
- Preserve active claims when an open local pull request links them with `Refs #N`. (#241).
- `blocker:unrequested` no longer fires while a head's checks are pending or
red: the review round forbids requesting there, so the one blocker that
demanded an act flagged builders for complying. Pending is CI's move, red is
`blocker:ci-red`'s (#236).
- `blocker:unrequested` now waits for the round to settle — the head and the
newest verdict must have stood for `RECONCILE_UNREQUESTED_GRACE` (default
300s) — so a sweep landing between a push and its re-request no longer flags
a round in motion (#236).
- LABELS.md no longer claims nothing in `actions/` clears or reads
`attention`: the reconciler has done both since the derived `claimed`
`post-merge` transition shipped. The amended text keeps the hand-set rule
and admits the one clear and the diagnostic read (#231).
- Triage now puts `attention` on the assigned issue that owns a claim, never
on its pull request, and treats an unassigned issue as a board bug rather
than a demand (#230).
## 0.5.0 — 2026-08-03
### Added
- `labels.conf` accepts optional `panel[<login>]=` rows: the required set for
a PR authored by that login is the row minus the author; other authors keep
`panel=`. Consumers gain the row at their next pin bump — adding it before
that bump is a parse failure that takes the label board down (#224).
### Changed
- Doctrine: third-party actions never hold a write-capable token by default —
repo-owned scripts in write-capable jobs, established publisher plus
full-SHA pin for the exception, SHA pins everywhere. Canonical in
REVIEWER.md, short form in BUILDER.md; consumers adopt at the pin bump
(#216).
### Fixed
- `ruling_escalation_row` selects the setter's best-shaped in-window comment,
ties broken to the earliest, instead of the earliest outright — a whole-round
reply landing seconds before the escalation is no longer graded in its place
(crew#293).
- The escalation selector and `ruling_shape_decision` share one field-presence
matcher, and an undecodable body column scores 0 instead of erroring the
sweep.
- Five stale **unreleased** markers in `docs/CONSUMERS.md` now name their
tags: fragment mode, `changelog-assembled` and `runner-isolated` at
`0.2.0`; the additive labeler at `0.3.0`; the two-caller split at `0.4.1`
(#221).
- The marker convention now names its clearing owner: the release PR that
ships machinery clears, in that same PR, every marker its assembled
section makes false (#221).
- A standing non-approving verdict now outranks draft in `decide_state`: a
re-drafted PR mid-round reads `state:addressing`, a live panel request on a
draft surfaces as `state:bots-reviewing`, and a draft with no round history
still reads `state:building` (#205).
## 0.4.1 — 2026-08-04
### Added
- `forge_release_exists`, `forge_commit_pulls`, `forge_tag_create`,
`forge_release_create` and `forge_pr_create` on both backends, so the
release path names no client (#191).
- The forgejo backend serves one PR object at `/commits/{sha}/pull` where
GitHub serves an array at `/pulls`; both verbs emit the array shape, so
the call site carries one expression (#191).
- Forgejo creates tags at `POST /tags` — it serves `/git/refs` GET-only,
so GitHub's ref-POST would have 404'd there forever (#191).
- `forgejo_api_base` refuses when `REPO` is empty. Every verb interpolates
it and every call reaches the network through there, so `repos//…`
whose 404 reads as "no release" and "no PRs" — is now impossible (#191).
- Release asset names are percent-encoded. The hook contract permits any
filename, and the name travels as a query value: a space made curl reject
the URL and `&`/`#`/`+`/`%` silently renamed the asset (#191).
- `lib/forge.sh` — the forge selector: `forge_detect` names the forge from
the runner's own environment, `forge_client` names the client it needs, and
`forge_preflight` refuses loudly before any sweep when the two disagree
(#188).
- The reconcilers and `labels-scope` run that preflight first, so a
GitHub-shaped client on a Forgejo instance is a named refusal instead of a
sweep that reads nothing and reports success (#188).
- `lib/closes_references.sh` — the closing-keyword parser, sibling of
`refs_references`, so "which issues does this PR close" is answered from a
PR body rather than from GitHub's GraphQL API (#188).
- `lib/forge-github.sh` and `lib/forge-forgejo.sh` — one call surface, two
backends, selected by `forge_select`; no forge branching at the call sites
(#188).
- The forgejo backend proves each paginated gather complete against the
server's `x-total-count` and refuses loudly when it cannot — a missing
header is a refusal, not a pass (#188).
### Changed
- `docs/CONSUMERS.md`'s artifact-hook recovery no longer tells operators to
run `gh release create` by hand — on a Forgejo runner there is no `gh`.
It names the forge-neutral tag-door path first, with both clients shown
(#191).
- `issueflow-reconcile` gathers open and merged PRs over REST instead of
`gh api graphql`. Forgejo serves no GraphQL at all, so the two queries were
replaced rather than translated; both forges return `number` and `body`
from `/pulls` in the same shape (#188).
- `forge_api` owns the page size, because each forge silently ignores the
other's parameter: `per_page=100` reads 30 items on Forgejo and `limit=100`
reads 30 on GitHub, both HTTP 200. No call site names one (#188).
- Outstanding review requests are derived from the reviews on the current head
rather than from `requested_reviewers`, which Forgejo never clears — read
raw there, a PR would sit at `state:bots-reviewing` forever (#188).
### Fixed
- The release doors run on a Forgejo consumer. `lib/facts.sh` and
`release.yml` gathered and published through `gh`, which the runner image
does not ship, so the merge door read `labeled=no` for a correctly
labeled ceremony PR and the tag door died at the publish (#191).
- A release fact that could not be read is no longer reported as a definite
`no`. A completed read finding no label is still `no` and still
fail-closed; a read that did not complete refuses and emits no fact
(#191).
- `labels-scope` no longer fails to compile its jq program on jq 1.6, which
the Forgejo runner image ships: `label` is a reserved word in jq's grammar,
so `$label` is a syntax error there and every scope derivation died before
reading the config (#188).
- `labels-reconcile` and `labels-scope` no longer exit 0 on a Forgejo
consumer having read zero facts — measured on `heavy-duty/rig`, where the
sweep printed `reconciled.` over an empty PR list and scope reported "no
labeler.yml" for a file that exists (#188).
- `forge_timeline` projects Forgejo's label events (`.type` / `.body` /
`.user.login`) into the GitHub shape (`.event` / `.actor.login`) so the
ruling ladder reads the same board on both forges (#188).
- `forge_pr_activity` no longer calls `/pulls/{n}/comments` on Forgejo
(HTTP 404); inline review comments come from reviews with
`comments_count > 0` (#188).
- CI installs shellcheck before linting, matching actionlint — the Forgejo
runner image does not ship it (#188).
## 0.4.0 — 2026-07-29
### Added
- `changelog.d/shape` — an optional one-line sentinel, `flat` or `grouped`,
that pins the fragment set's shape and outranks the newest-published-section
inference; absent, the inference binds unchanged (#182).
- Add `post-merge` issue state for merged `Refs` work awaiting triage-owned verification.
- `changelog_fragment_problem` bounds every entry at 300 normalized
characters, red on the PR that writes the fragment; the armed guard and
the assembler inherit the one definition (#167).
- BUILDER.md and CHANGELOG.md state the bound and the split rule: a long
change ships several short entries, never one long one (#167).
### Changed
- Labels automation docs now make sweep cadence a consumer-owned tradeoff,
retain hourly as the engine-less default, and document manual dispatch as
the operator's immediate full-board sweep (#203).
- `labels` — the reconcile cron relaxes from `*/15` to hourly (#199), cutting a
private consumer's schedule-triggered full-board sweeps ~4× at GitHub's
1-minute billing floor.
- `labels` — the hourly cron is the sweep's only wake for transitions no
subscribed event carries — a verdict landing, blocker:ci-red, a
blocker:conflict when another PR merges, the time-based stale/reclaim — so it
bounds their latency to ≤1h, delaying no event-carried transition (#199).
- `labels` — the caller's `issues:` trigger narrows to
`[opened, closed, edited, reopened]` (#199), the actions that carry a
queue-state change the cron cannot wait a cadence for. The churn/validation
actions — labeled/unlabeled/assigned/unassigned — come off; the PR handoff
wake is unaffected.
- `labels` — each caller trigger now carries a comment saying why it is
subscribed, and reconcile keeps `cancel-in-progress: false` (#199) —
cancelling a sweep mid-board is the race that guard exists to prevent.
- `CONTRIBUTING.md` now points to `BUILDER.md` for the shared PR flow instead
of restating doctrine that can drift, while retaining ceremony's roster and
other repo-specific facts (#198).
- Builder doctrine makes each whole-round reply the durable Round log record
mirrored by the engine, leaving handoff as a mechanical facts-only step
instead of a newly composed summary (#196).
- `FLEET.md` removes its duplicate bench roster, records crew as a general
operator-configured tool, and advances its whole-file audit stamp to
`crew@eaeb302` with every surviving crew link re-pinned (#193).
- `FLEET.md` keeps the registry's authorization rule and its crew#16/crew#66
provenance, while replacing duplicated mechanism and path claims with a
pinned pointer to crew's registry header (#192).
- `BUILDER.md` gates both review-request points on a green check at the
head, carries crew#45's argued exception for failures outside the PR,
and states the ruled classification: cancelled and stale are not a
green head; skipped and neutral are (#189).
- `BUILDER.md` documents CI-red recovery in pickup precedence: a red head
of your own PR is picked up before claiming another issue, is never a
parked claim, and follows crew#17's recovery path (#189).
- `FLEET.md` writes the ci-red wake into the duty order between resume
and build, now as deployed engine rather than on paper: the
reconciliation stamp advances to the crew SHA carrying crew#64 (#189).
- `FLEET.md` describes the build wake's check gate as the engine
implements it: a green head, or one with no checks configured, opens a
round; a red head and an unfinished one are held and reported
separately (#189).
- `FLEET.md` corrects the attention wake to the crew#66 ruling: the query
is cross-repo, the action is registry-bounded, and an out-of-scope
demand is reported and escalated to the operator rather than worked. It
no longer claims attention is exempt from the registry (#189).
- `FLEET.md` distinguishes an attention session that dies before acking,
which relaunches, from one that completes without acking, which is a
decline a ledger keeps from re-firing (#189).
- `BUILDER.md` re-requests by head, not by verdict: a push while
answering a round stales every approval, so every panelist is
re-requested; only an unchanged head re-requests the non-approvers
alone (#190).
- FLEET.md's duty-loop mechanism is a pointer to crew's shared engine; the
wake lists follow the engine's duty order, the roster keeps the as-built
bench beside `fleet.roster`'s target, and the reconciliation stamp names
crew@`01fb49c` (#187).
- Ceremony's changelog is grouped from this release forward: the pending
fragments carry `### ` headings under a `grouped` sentinel (#182).
- BUILDER.md: a park declaration stands until its facts change — a
nothing-changed resumption posts nothing; only a no-open-PR park owes a
refresh, inside the 48-hour reclaim window (#178).
- Private-repository label callers document `actions: read` alongside checks and statuses for workflow-run check-rollup nodes (#173).
### Fixed
- FLEET.md no longer says a review request outside the registry is
authorization: `repos.txt` is the scope for the review queue, out-of-scope
requests are logged and never acted on, and the attention wake is stated
as the one registry-independent exception, by design (#187).
- `blocked_reference_records` unions every `Blocked by` clause in the body
instead of binding to the first marker occurrence — a repeated declaration
no longer promotes on its first sentence alone, and earlier prose that
merely mentions being blocked no longer hijacks the parse (#184).
- `decide_state()` refuses `state:needs-human` while the hand-set `blocked`
label stands — the PR falls to `state:addressing`, exactly parallel to the
`needs-ruling` exclusion; never emitted by `blockers()` (#180).
## 0.3.0 — 2026-07-24
- Make `changelog-armed` reject fragment shape drift on the PR that introduces it.
- A directive hold now has a written ending, not just a beginning: BUILDER.md's shape 5 says the hold ends where it began — on the labels — with the hold owner's most recent queue-label event governing over any stale prose, the timeline read (`gh api .../issues/{n}/timeline`) named as the move before standing down or up on a hold, a claim against stale prose required to cite the events it read, and a refused claim given its two exits. TRIAGE.md now requires re-reading label events before asserting label-borne state in prose, and makes correcting a lifted hold's stale body header triage's move in the same tick. On 2026-07-24 the unranked signals split two builders reading one board (#149, #151); both acted defensibly — the doctrine, not the builders, lacked the rule (#154).
- Doctrine names the second `Closes #N` exception: a same-repo PR whose
authorizing issue marks an acceptance criterion post-merge uses `Refs #N`,
and triage closes the issue by hand on the evidence — merging #143
auto-closed #137 with exactly such a criterion unmet, and no role had been
told otherwise. TRIAGE.md now requires a post-merge criterion to carry its
own mechanism (post-merge, triage closes, `Refs #N`), REVIEWER.md lists
`Refs #N` beside `Closes #N` and `Part of <owner>/<repo>#N` and stops
treating the reference-only PR as a defect, and CONTRIBUTING.md points at
BUILDER.md as the rule's one home (#151).
- FLEET.md — the Reviewers wake describes the deployed sweep, not the `gh search` trigger the bench replaced: the pulls-API `requested_reviewers` sweep across the org plus the named bot forks is source 1, the `repos.txt`/search poll an adds-only backstop, and the two are merged and deduplicated by (repo, PR) before acting. Only the notifier's `needs-ruling` queue remains on paper; `repos.txt` is the registry only on the triage box; and the Status block now stamps the crew ref the file was last reconciled against (#149).
- REVIEWER.md now carries the review mechanics every box had been re-deriving from an incident: the queue comes from the API and not the search index, every write is one-shot per (reviewer, PR, head), heads are reviewed in throwaway checkouts, a pinned consumer's config is verified at its pin, and a verdict names the checks its box could not run (#145).
- The `docs/CONSUMERS.md` labels-caller stub lists the same `issues:` types
as ceremony's own caller — `edited` and `reopened` included — so a consumer
adopting the stub wakes when an issue body's `Blocked by #N` declaration is
edited, and when a closed issue re-enters the queue wearing labels derived
at close. The two lists drifted apart inside PR #32; a parity test now pins
them together, red if either file drops a type or the lists diverge.
Adopting the widened list is a stub edit riding the pin bump to the first
tag carrying this change (#144).
- `labels-reconcile` — a queue-cancelled duplicate check is discarded when its context holds a real verdict, so a sibling PR's eviction no longer reds a green PR; an all-cancelled context still blocks (#139).
- `blocker:unrequested` now clears the moment the panel is asked: the labels
caller (and the `docs/CONSUMERS.md` stub) listens on `review_requested` and
`review_request_removed`, so the one event that falsifies the label — or
makes it true again — wakes the reconcile sweep instead of waiting for an
unrelated push or the advisory cron. The `scope` job skips both events:
they change no paths, and running the labeler on them widens the #130
clobber window. Adopting the new triggers is a stub edit riding the pin
bump to the first tag carrying this change (#137).
- `drills/README.md` no longer tells the builder to delete the scratch repo —
a step no fleet identity can perform, because `delete_repo` is deliberately
absent from bot tokens. The builder's end state is **archive**
(`archived: true`, inside the `repo` scope); the delete is the operator's,
and cleanup gates nothing — not ready-for-review, not the panel, not the
merge. The drill record now names the scratch repo by `owner/name` and
states the disposal its author actually observed, never one that has not
happened: both 0.2.0 drills hit the missing-scope wall independently, one
stalling a release draft on an impossible 403, the other shipping a record
asserting a delete that never ran (#135).
- `lib/facts.sh` — a repository's first push to `main` (a root commit with no first parent) now reads `base_ver=(none)` and lets decide's table govern, instead of dying at exit 128 before establishing a fact; the no-base path skips the base fetch and `git show`, and an unresolvable head still fails loudly (#134).
- The changelog rule now explains why release PRs write no fragment and how entry-worthy changes land instead (#131).
- `actions/labels-scope` replaces `actions/labeler@v5` in the labels workflow's scope job: labeler wrote the whole label set (`PUT`) even under `sync-labels: false`, silently removing any label applied while it ran — #128 lost its `release` that way — so the scope job now derives from the same `.github/labeler.yml` mapping (the `changed-files`/`any-glob-to-any-file` shape, block or flow; anything else refuses loudly) and its only write is an additive `POST`. The reconcile sweep also warns — never sets — when a non-draft PR is release-shaped (bare version differing from its base) but carries no `release` label (#130).
## 0.2.0 — 2026-07-24
- `test/changelog-assembled.test.sh` — keep the trio interaction aligned with fragment mode: a dropped entry makes armed red too, while a hand-edited section leaves assembled as the sole red (#126).
- `actions/changelog-assembled` — a release PR's stamped section must be byte-for-byte what the fragments it consumed assemble to, replayed from the merge base; inapplicable trees pass with a NOTICE (#116).
- `changelog-armed` — treat `changelog.d/` as the arming, validate every development fragment, and require bare releases to consume the directory into their exact publishable section (#115).
- `lib/changelog.sh` + `bin/changelog-assemble` — read the `changelog.d/` fragments, assemble one release section (canonical group order, one shape per repo), and consume exactly what was published (#114).
- BUILDER.md — the directed hold is the parked claim's fifth shape, its attention demand is acknowledged in the declaration comment, and its board bookkeeping covers in-flight work; TRIAGE.md no longer excludes it (#113).
- Ceremony adopts `changelog.d/` — a PR writes one fragment per issue instead of editing `CHANGELOG.md`, the release PR assembles the section, and `## Unreleased` is gone (#112).
- BUILDER.md — the handed-off PR is the parked claim's fourth shape, its handoff is its declaration, and shape 2 covers the round awaiting its first verdicts (#109).
- `labels-reconcile` — warn once per sweep when a repository lacks labels declared by the pinned core taxonomy (#105).
- `LABELS.md` — drop the vendored scope-table enumeration; the per-repo set lives in `.github/labels.conf` and the repo's own CONTRIBUTING (#104).
- `labels-reconcile` — a degraded mergeability/checks read now logs gh's actual stderr (collapsed, bounded) beside the byte-identical counted line, and the blind-sweep warning leads with the observed reason instead of asserting the permissions cause (#101).
- Changelog publication — count entries instead of bytes, refuse dangling grouped headings, and seed grouped re-arms with Added/Changed/Fixed (#98).
- `labels-reconcile` — grant callers private-repo check reads and warn when an entire PR sweep is blind (#95).
- `labels-reconcile` — the bootstrap now retires the six GitHub defaults `LABELS.md` publishes as deleted, tolerating both an already-absent label and a refused delete (#93).
- `issueflow-reconcile` — a triage-authored issue arrival stands down with exit 0 instead of killing the run before the sweep (#91).
- FLEET.md — the assignee's `attention` wake: one role-independent trigger ahead of every per-role list, one acked session per demand; a spec on paper until `duty.sh` polls it (#86).
- `attention` doctrine — define its assignee-owned pickup, ack, queue and clock semantics across labels, triage, and builder roles (#85).
- `attention` — add the issue-only, hand-set assignee-demand flag to the core label taxonomy (#84).
- One issue at a time counts build work in flight: the parked claim's three shapes, its declared-never-inferred comment, and triage's duty to name a directed hold as a park (#77).
- FLEET.md — the operator notifier's `needs-ruling` queue (one tracked message per item, edited in place across the rungs) and triage's past-24h wake condition; a spec on paper until an operator updates the box (#74).
- The sweep observes the escalation contract: a malformed escalation is named field-by-field, and the ladder's 12h/24h rungs each draw one comment to the flag-setter — comment-only, per-episode, both surfaces (#73).
- Ruling doctrine — define every human-owned trigger, the fixed escalation shape, and the 024h builder-to-triage ladder (#72).
- `issueflow-reconcile` — nudge once when an `offsite` flag outlives every visible cross-referenced PR (#69).
- `offsite` — protect claimed issues whose PR lives in another repository from the claim-reclaim clock (#68).
- `issueflow-reconcile` — keep cross-repo references out of local dependency decisions and require triage to resolve cross-repo blockers by hand (#61).
- `actions/runner-isolated` — a `pull_request`-triggered job may never run on a self-hosted runner (#58).
- Cross-repo doctrine: the panel is the PR's repo's roster, a review request is authorization but not panel membership, and `Part of <repo>#N` replaces the `Closes #N` that cannot cross repos (#57).
- The sweep's `needs-ruling` invariants, one implementation for both surfaces: the issue-side staleness exemption, the bare-flag check (comment-only, the label is never removed), and the 7-day nudge to the decider (#52).
- `needs-ruling` — the cross-cutting flag for a pending human decision, excluded from `state:needs-human` and from the staleness sweep (#51).
## 0.1.0 — 2026-07-22 ## 0.1.0 — 2026-07-22

View file

@ -10,22 +10,22 @@ the two is a bug.
Work moves through one pipeline, and every stage has an owner: Work moves through one pipeline, and every stage has an owner:
``` ```
discussion ──▶ triage ──▶ issue ──▶ build ──▶ review ──▶ human merge ──▶ release proposal ──▶ triage ──▶ work issue ──▶ build ──▶ review ──▶ human merge ──▶ release
(anyone) (agent) (queue) (agent) (agents) (human) (ceremony) (anyone) (agent) (queue) (agent) (agents) (human) (ceremony)
``` ```
- **Discussions are where intent lives.** Anyone — human or agent — who has an - **Proposals are where intent lives.** Anyone — human or agent — who has an
idea, a bug, a question, or a "we should…" opens a **discussion**, not an idea, a bug, a question, or a "we should…" files a **proposal**, not a work
issue. Discussions are allowed to be vague; that is what they are for. issue. Proposals are allowed to be vague; that is what they are for.
- **Issues are minted only by triage.** Nobody else writes issues — not - **Work issues are minted only by triage.** Nobody else writes work issues —
humans, not builders, not reviewers. An issue is a work order with a quality not humans, not builders, not reviewers. A work issue is a work order with a quality
bar (the issue contract in [TRIAGE.md](TRIAGE.md)), and the bar holds bar (the issue contract in [TRIAGE.md](TRIAGE.md)), and the bar holds
because exactly one role is accountable for it. An issue that appears because exactly one role is accountable for it. An issue that appears
through any other door gets `needs-triage` and is normalized or converted through any other door gets `needs-triage` and is normalized or converted
back into a discussion. back into a proposal.
- **Builders turn one issue into one PR.** [BUILDER.md](BUILDER.md). - **Builders turn one issue into one PR.** [BUILDER.md](BUILDER.md).
- **Reviewers converge on a verdict.** [REVIEWER.md](REVIEWER.md). - **Reviewers converge on a verdict.** [REVIEWER.md](REVIEWER.md).
- **Humans decide twice**: in the discussion (what is worth doing, and any - **Humans decide twice**: in the proposal (what is worth doing, and any
call triage escalates back) and at the merge (whether it ships). Everything call triage escalates back) and at the merge (whether it ships). Everything
between those two points is agent work by default. between those two points is agent work by default.
- **Merging a release PR ships it** — the release ceremony this repo's - **Merging a release PR ships it** — the release ceremony this repo's
@ -35,56 +35,51 @@ Who may set which label is [LABELS.md](LABELS.md)'s contract.
## The PR flow ## The PR flow
The same flow the sibling repos run, and the part of this pipeline that is PRs move through review rounds that builders answer whole, and only a human
already proven: merges. [BUILDER.md](BUILDER.md) is the shared flow contract; this file names
only ceremony-specific facts such as the roster and code conventions.
1. **One issue, one PR**, opened as a **draft** while building, with
`Closes #N` in the body. Drafts are invisible to the reviewer panel on
purpose. Every behavior change adds one line to `CHANGELOG.md` under
`## Unreleased` (insert **above** the heading below — never type over it;
the monotonic guard exists because of exactly that edit).
2. **When it's ready**: mark ready-for-review and request the whole panel.
3. **Rounds are answered whole.** Wait until every reviewer has a verdict in,
then answer the entire round in a **single reply**, push the fixes, and
re-request the reviewers that didn't approve. Prefer verification over
argument: a test settles what a comment thread can't.
4. **Reviews end in a verdict** — approve or request-changes, never a bare
comment. The verdict carries blockingness only; the body carries the
feedback. ([REVIEWER.md](REVIEWER.md) for why a comment-only review stalls
the machine.)
5. **Handoff**: when the round passes — every panel verdict is an approval of
the current head and no `blocker:*` label stands — the author posts the
round summary, requests the human's review, and sets `state:needs-human`.
The label write is optimistic; the reconciler validates it within seconds.
6. **A human merges.** Nothing else merges.
### Roster ### Roster
Five identities share the work (org team `agents`), each living in its own Four identities share the work (org team `agents`), each living in its own
[box](https://github.com/heavy-duty/box) — one box per credential, because [box](https://github.com/heavy-duty/box) — one box per credential, because
the box is the blast-radius boundary; roles are what a session is told, and the box is the blast-radius boundary; roles are what a session is told, and
[AGENTS.md](AGENTS.md) routes from there: [AGENTS.md](AGENTS.md) routes from there:
| identity | box (rig tenant) | standing work | | identity | box (rig tenant) | standing work |
|---|---|---| |---|---|---|
| `dan-claude-bot` | `triage` (claude-box) | **triage** — the only door issues come through; this identity mints issues and nothing else writes them (#18's `triage-actors`) | | `claude-bot-andresmgsl` | `triage` (claude-box) | **triage** — the only door work issues come through; this identity mints work issues and nothing else writes them (#18's `triage-actors`) — and review. It does not build. |
| `claude-bot-andresmgsl` | claude-box | build (release-flow and guards machinery) + review | | `codex-bot-andresmgsl` | codex-box | build + review |
| `codex-bot-andresmgsl` | codex-box | build (scaffolding, conversions) + review | | `glm-bot-andresmgsl` | glm-box | review |
| `grok-bot-andresmgsl` | grok-box | review | | `kimi-bot-andresmgsl` | kimi-box | review |
| `kimi-bot-andresmgsl` | kimi-box | review — builder trial on a small mechanical issue once its verdicts have a track record |
**The review panel for any PR is every bench identity except its author** — **The review panel for any PR is every bench identity except its author** —
recusal by construction, enforced by the reconciler (#10): the required recusal by construction, enforced by the reconciler (#10): the required
verdicts are the panel minus the PR's author, so convergence always means verdicts are the panel minus the PR's author. On this roster that resolves
three cross-vendor approvals of the current head. Builders and triage to **three** cross-vendor approvals of the current head, because the only
default to different models so the issue contract is honestly exercised — builder is itself on the panel and recuses from its own PRs; the rule is
a spec gap should surface as a question on the issue, not be silently filled panel-minus-author, and three is what it currently comes to, not a second
by shared priors. Humans (`danmt`) decide in discussions and merge; the rule. Builders and triage default to different models so the issue contract
roster is config, not doctrine — swapping a vendor is an edit to this table is honestly exercised — a spec gap should surface as a question on the
(and to `panel=` in `.github/labels.conf` once #10 lands), nothing more. issue, not be silently filled by shared priors. Humans (`andres`) decide in
proposals and merge; the roster is config, not doctrine — swapping a
vendor is an edit to this table (and to `panel=` in
`.github/labels.conf` once #10 lands), nothing more.
The identities named here must be the identities `.github/labels.conf`
names, and both must exist on the forge the repo lives on. A roster that
agrees with itself and disagrees with the instance is the failure #195
records: `panel=` naming absent users cannot converge and
`triage-actors=` naming an absent user makes every issue a stray mint that
nobody can normalize. `test/labels.test.sh` holds this table and the conf
to the same set, in both directions.
Each governed repo names its own roster in its CONTRIBUTING; this one is Each governed repo names its own roster in its CONTRIBUTING; this one is
ceremony's. ceremony's. Its `scope:*` set is the same kind of repo-specific fact:
ceremony's scopes are defined in [`.github/labels.conf`](.github/labels.conf)
— one `name|color|description` row each, with PR path mapping in
[`.github/labeler.yml`](.github/labeler.yml). The conf is the set; no prose
table repeats it (#104).
## Code conventions ## Code conventions
@ -100,23 +95,35 @@ ceremony's.
- Whole-version matching everywhere: `0.7.0` never matches `0.7.0-rc1`. - Whole-version matching everywhere: `0.7.0` never matches `0.7.0-rc1`.
- Shellcheck- and actionlint-clean is a CI gate, not a suggestion. - Shellcheck- and actionlint-clean is a CI gate, not a suggestion.
## Doctrine conventions
The vendored role files — the set [`docs/VENDORED.txt`](docs/VENDORED.txt)
declares — state each normative rule completely, keep at most one sentence of
why, and cite its record only with a bare parenthetical such as `(#N)`,
`(#N D3)`, or `(#N, #M)`. Incident narrative — timestamps, actors, quoted
comments, measured counts, and links to specific comments — belongs in that
record. If a rule cannot be followed without chasing its cite, the rule is
under-stated: fix the statement, not the citation. (#280)
Normative text in those files does not cite issues from other repositories.
Consumers read the vendored bytes outside this organization's context, and a
cited repository may not be public. A repo-boundary deferral remains allowed:
it names another component as the owner of a fact rather than citing one of
that component's issues. (#280)
This is distinct from the code-comment convention above: a code comment is
read by a maintainer inside the organization while standing in the file,
whereas vendored doctrine is read by any agent in any governed repository on
every session. (#280)
## How the other repos use this ## How the other repos use this
Two consumption modes, split by what has a runtime: Two consumption modes, split by what has a runtime: **machinery by
reference**, fetched at run time from the ref a caller pins, and **doctrine
- **Machinery is consumed by reference.** Workflows and actions are fetched as a mirror** — the set [`docs/VENDORED.txt`](docs/VENDORED.txt) declares,
by GitHub at run time from the ref the caller pins — no copy exists in the vendored at `.ceremony/` and held to the pin by a guard (issue #19). The
consumer. [README](README.md) states both modes in full, and why they differ; what
- **Doctrine is consumed as a machine-verified mirror.** A document's only follows is only what they leave a governed repo to carry.
"runtime" is an agent reading the working tree of the repo it stands in —
a doc that requires a cross-repo fetch before it governs is a doc that
sometimes goes unread. So the agent-facing set — **AGENTS.md, TRIAGE.md,
BUILDER.md, REVIEWER.md, LABELS.md** — is vendored into each governed
repo at **`.ceremony/`**, byte-identical to this repo at the pinned ref,
by the sync tool (issue #19). A CI guard diffs the mirror against the pin
on every PR: hand-editing a vendored file, or bumping the pin without
re-syncing, goes red. It is a copy that cannot drift — which is the only
kind of copy this org allows.
A governed repo (box, rig, cast, incubator, …) therefore carries: A governed repo (box, rig, cast, incubator, …) therefore carries:
@ -136,7 +143,8 @@ A governed repo (box, rig, cast, incubator, …) therefore carries:
- the **`scope:*` label set** (`.github/labels.conf` + `.github/labeler.yml`), - the **`scope:*` label set** (`.github/labels.conf` + `.github/labeler.yml`),
- the **drill meaning** (`drills/README.md`), - the **drill meaning** (`drills/README.md`),
- the repo's own code conventions; - the repo's own code conventions;
- **Discussions enabled**, so the triage door exists. - **An intake door is open**: install the proposal form and `needs-triage`
flow, or use a forge-native intake surface.
One pin governs both the machinery and the doctrine: the ref a repo's One pin governs both the machinery and the doctrine: the ref a repo's
workflows call is the ref its `.ceremony/` mirror is verified against. workflows call is the ref its `.ceremony/` mirror is verified against.

329
FLEET.md
View file

@ -1,104 +1,289 @@
# FLEET.md — the roster, and how it actually runs # FLEET.md — the fleet shape, and how it actually runs
> **Status:** descriptive snapshot, not doctrine. This file records how the > **Status:** descriptive snapshot, not doctrine. This file records how the
> agent fleet that builds this repo is wired *today*, so the setup can later be > heavy-duty operator fleet is wired *today*. It is **not** part of the
> solidified into a replicable fleet-management solution. It is **not** part of > vendored doctrine set (`.ceremony/`) and is never mirrored to consumer
> the vendored doctrine set (`.ceremony/`) and is never mirrored to consumer > repos. [Crew](https://github.com/heavy-duty/crew) is a general tool: its
> repos. The doctrine files (AGENTS.md, TRIAGE.md, BUILDER.md, REVIEWER.md, > repository ships the engine, while the fleet definition belongs to the
> LABELS.md, CONTRIBUTING.md) say what roles *must* do; this file says how the > operator; heavy-duty is one operator of it. Membership, repository scope,
> current bench *physically* does it. > agent-profile overrides and doctrine paths belong to that definition;
> membership itself lives outside every checkout. Crew's shipped defaults
> name heavy-duty's AGENTS.md, TRIAGE.md, BUILDER.md and REVIEWER.md, but
> those are compatibility defaults, not vocabulary compiled into the engine
> — operator `doctrine.conf` values can replace them.
> The *mechanism* lives with crew and this file points at it. Last reconciled
> against the merged engine at
> [`heavy-duty/crew@eaeb302`](https://github.com/heavy-duty/crew/tree/eaeb3022aa47d90e797f2b9e007b831df7ca8406),
> 2026-07-28 — a descriptive file with no reconciliation stamp gives the next
> reader nothing to diff, which is exactly how the #149 drift went unnoticed.
## The roster ## Fleet shape
One box (an isolated, disposable VM) per GitHub identity. Boxes are credential One box (an isolated, disposable VM) per GitHub identity. Boxes are credential
boundaries; sessions inside a box are role boundaries. No box has an inbound boundaries; sessions inside a box are role boundaries. No box has an inbound
network path — GitHub is the only queue. network path — GitHub is the only queue. Fleet membership is the operator's
definition and lives outside every checkout; this file deliberately carries
no second roster.
| Identity | Box | CLI | Roles | Review panel per PR = the governed repo's `.github/labels.conf` `panel=` line
|---|---|---|---| minus the PR's author, as [REVIEWER.md](REVIEWER.md) specifies (recusal by
| `dan-claude-bot` | triage-box | Claude Code | **triage** — the only issue-minter |
| `claude-bot-andresmgsl` | claude-box | Claude Code | builder (hard machinery) + reviewer |
| `codex-bot-andresmgsl` | codex-box | Codex CLI | builder (mechanical) + reviewer |
| `grok-bot-andresmgsl` | grok-box | Grok CLI | reviewer |
| `kimi-bot-andresmgsl` | kimi-box | Kimi CLI | reviewer |
Review panel per PR = the reviewer bench minus the PR's author (recusal by
construction). Only humans merge — enforced as permissions (the agents team construction). Only humans merge — enforced as permissions (the agents team
holds the triage role, not write), not as convention. holds the triage role, not write), not as convention.
## Anatomy of a duty loop ## Anatomy of a duty loop
Every box runs the same skeleton, adapted to its CLI: The mechanism is no longer described here. The five hand-rolled duty scripts
converged into crew's shared engine, and a prose mirror of running code in a
second repo is a second thing to keep true — this one drifted (it said cron
ran `duty.sh` directly and gave the hygiene sweep its own cron line; crew's
`duty.sh` records that separate line as the bug it fixed, sharing
`~/duty/work` unlocked). How a tick actually works — cron fires
[`bin/tick.sh`](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/shared/bin/tick.sh),
the only cron target, which wraps
[`bin/duty.sh`](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/shared/bin/duty.sh)
in a non-blocking `flock` with one evidence line per boundary; the boot gate
and crash recovery; the session runner; backlog hygiene self-scheduling
inside the duty tick under the same lock — lives with the code:
[`shared/README.md`](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/shared/README.md)
is the map, provenance table included. Sessions stay disposable: durable work
state lives on the board (issues, PRs, labels) and in git branches, while the
engine keeps only operational evidence and deduplication state under
`~/duty`; detection is the engine's, judgment is the session's.
- **Tick:** cron `*/5` runs `~/duty/duty.sh` under a non-blocking `flock`; the What belongs here is what a wake *means*:
triage box adds an hourly hygiene sweep under its own lock. Holding the lock
is load-bearing: a tick that acquires it *knows* nothing else is running on - **The registry is the scope.** A box acts only on repos its operator
this identity. listed. Work it finds outside that scope is reported and never acted on;
- **Poll:** the script reads `~/duty/repos.txt` (the repo registry — adding a the report is part of the boundary, because a bounded wake that goes quiet
repo is adding a line) and queries GitHub with `gh` for work matching the is indistinguishable from a broken one. Adding a repo is an **operator
box's role. decision**, never something a sweep makes by writing where nobody listed.
- **Act:** when there is work, the script launches the box's CLI as a one-shot The 2026-07-25 scope ruling (crew#16) closed the org-wide review and
session with a role prompt; the session does the work via `gh` as the box's author-side write surface; the crew#66 attention ruling closed the last
own identity, then exits. Sessions are stateless and disposable — all state exemption. Crew's
lives on the board (issues, PRs, labels) and in git branches. [`examples/repos.txt` header](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/examples/repos.txt)
is the pinned source for how that rule is implemented and reported.
### Wake conditions ### Wake conditions
- **Triage:** new discussions to mint from, builder questions on issues, stray One wake is shared by all three roles, so it is stated once instead of pasted
issues to reconcile, `@`-mentions, hourly hygiene (stale claims, label into each list: **an open issue assigned to me carrying `attention`.** Anyone
invariants). can be an assignee, which is why the trigger is role-independent — triage,
- **Builders**, in priority order: **resume** (an open draft PR of mine, or a builders and reviewers all carry it, and the pickup session is the same shape
claimed issue with my `build/*` branch but no PR — possible only if a in each. It runs **first, ahead of everything in the per-role lists below**
previous session died mid-work), a `ready` issue to claim, a completed for builders, ahead of resume: a demand parked by triage, the operator or a
review round on my PR (act on whole rounds, never single verdicts), my PR sibling agent outranks self-directed continuation, and it is frequently the
fully approved (write the closing summary, flip to `state:needs-human`, very thing that unparks the work resume would otherwise pick up. The query is
request the human), my PR `CONFLICTING` (rebase; never act on `UNKNOWN` the authenticated-user endpoint
post-merge flap). `gh api "/issues?filter=assigned&state=open&labels=attention"` — one call, no
- **Reviewers:** an open PR by someone else whose head I have not yet search index (the review queue below already records that the index lags) —
reviewed — one verdict per head, deduplicated against my own latest review's and it **sees** repos outside the operator's registry, because that endpoint
SHA, not against the search index (it lags). takes no repo filter.
### Resilience **Seeing is not acting, and that is a ruling** (crew#66, danmt, 2026-07-27).
The wake used to work every row it saw, which for a builder meant a clone and
the full worktree and round rule set against a repo no operator had listed —
write authority outside the registry, and the one hole left in the
containment story. Rows are now partitioned against the registry: inside it,
a session as before; outside it, reported and never acted on, exactly like an
out-of-scope review request or authored PR.
[`lib/duty-attention.sh`](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/shared/lib/duty-attention.sh)
implements the partition and states the ruling in its header.
- **Boot gate:** each tick compares the kernel boot id The cost was argued before the ruling rather than discovered after it: an
(`/proc/sys/kernel/random/boot_id`) to a stored marker. First tick after any assignment plus a label **is** a targeted authorization, so a cross-repo
reboot runs credential + disk probes; the marker is written only when auth handoff now waits on an operator adding the repo, and the box most likely to
actually works, so a box with dead credentials re-checks loudly every tick be handed work outside its beat is the one that goes quiet. That is why an
instead of silently skipping duty. out-of-scope demand does not only reach `duty.log` — it pings the operator
- **Crash-only resume:** there is no session state to restore. The recovery over the same channel the boot gate uses. A bounded wake that failed silently
path *is* the normal path: the resume wake condition reads the board, posts would trade an unbounded write surface for a broken channel to the human.
`⟲ resuming from <sha>`, and continues from the worklog. Rebooting a box
never loses work that was pushed. Each demand gets **exactly one session, and the ack bounds it**: the
- **Checkpoint discipline (builders):** open the PR as draft at the first session's first act, before any of the demanded work, is the pickup comment
commit with a `## Worklog` checkbox list; check off and push after every plus removing the label — [the `attention`
step. The board and the branch are the only memory. contract's](https://github.com/heavy-duty/ceremony/blob/bce09aa7648dbd74b8e91b1d4fbc2fa8d145f705/LABELS.md#L143-L149)
- **Worktree isolation:** builders build each PR in its own `git worktree`; ack (#85), which here becomes the session's ack-then-act ordering.
reviewers check out PR heads in throwaway detached worktrees and remove them Then it acts on the thread and exits — short by construction. Until the label
after the verdict. Main clones stay parked on the default branch, always is removed the flag is still up, so a session that **dies** before acking is
clean; stale worktrees are pruned by the boot gate. simply relaunched at the next tick — the same crash-only shape as resume
below. A session that **completes** without acking is a different fact: that
is a decline, and a seen-ledger stops it re-firing until the issue moves.
Dying and declining used to look identical to the engine, which meant a
demand a session had considered and correctly left alone woke a new one every
tick forever.
The design this replaces was built and rejected: polling notifications for
`reason: mention` re-arms a thread on every comment, so ordinary round
traffic — verdicts naming the builder, the builder's own replies echoing back
— burns a full agent session per tick on nothing actionable; a mention
answers *"was I named?"*, not *"am I needed?"*. The incident that bought the
wake: [#16's 16:49Z
ruling](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5061051198)
authorized the last open acceptance criterion on a `claimed` issue and sat
unowned for over an hour — the box answered every state signal that day and
never saw the comment, and the eventual pickup ran on a manual bridge. The
wake is no longer on paper: `duty-attention.sh` is deployed engine, and
`duty.sh` runs it first on every box, whatever its roles.
The engine's duty order is fleet-standard
([`bin/duty.sh`](https://github.com/heavy-duty/crew/blob/eaeb3022aa47d90e797f2b9e007b831df7ca8406/shared/bin/duty.sh)):
**attention → triage signals → review queue → resume → ci-red → build →
handoff → rebase → worktree hygiene → backlog hygiene (hourly)** — attention
role-independent and first, then each duty family the box's roles enable.
Every position in that order is deployed engine at the stamped SHA:
[crew#64](https://github.com/heavy-duty/crew/pull/64) merged ci-red between
resume and build, and `duty.sh`'s own header carries the same order.
The earlier form of this file folded handoff and rebase into the other
builder wakes; they are duties of their own.
- **Triage signals**, per registry repo: `needs-triage` issues,
queue-unlabeled strays, proposals without triage's voice, unread
`@`-mentions (their own session), and `blocked` issues whose named blockers
have all landed — a lead the session verifies, never a label the engine
flips. Backlog hygiene (stale claims, label invariants) runs hourly,
self-scheduled inside the duty tick. A `needs-ruling` standing **past
24h** is still triage's to pick up — the ladder's last rung makes the
option triage's to choose — but, like the notifier queue below, that
detection row is on paper only today.
- **Review queue**: one candidate set, enumerated from the pulls pages of
every registry repo — object endpoints, never the search index for the
queue itself, whose lag left cast#143, incubator#25 and box#164 sitting
unreviewed — filtered to PRs listing me in `requested_reviewers`, deduped
by (repo, PR) before acting (the sequential shape double-announced on
ceremony#32), and worked oldest-first. One search-backed **awareness pass**
per tick reports requests outside the registry and never acts on them —
the scope rule above. One verdict per head, deduplicated against my own
latest review's SHA; a re-request at an unchanged head is answered with an
auto-approve through the verdict gate rather than left as a stale blocker
(operator ruling 2026-07-23, ceremony#94).
- **Resume** (builders, checked before build): an open draft PR of mine, or
a `claimed` issue whose `build/*` branch exists on my fork with no open PR
— a session died between first push and PR creation. A branch whose PR
already **merged** is a post-merge wait, never resumed (#172,
incubator#55/#64).
- **ci-red** (builders): a non-draft PR of mine whose check at the current
head is failing. Evaluated before the build wake, so a red PR of mine
outranks a new claim — repairing my own red head comes ahead of new work
(ceremony#163: full-panel approvals at the head, mergeable, stranded on
a transient failure no wake covered). A round owed at a red head is
excluded from the build wake below but reported rather than silent, and
an unchanged red head goes quiet after one attempt, through the
`report_suppressed` path — suppressed, still said. A check that has not
finished is **not** a red head and wakes nothing here: nothing has failed
yet, so there is no investigation to launch. How a red head is detected
and kept quiet is the engine's mechanism, described in crew's
`shared/README.md`, not here.
- **Build**: a `ready` **unclaimed** issue (an assignee means mid-claim, not
pickable), or a completed review round on my PR — a changes-request with
no panel review request still outstanding; whole rounds, never single
verdicts, and never a round the check at its head does not support. The
wake admits a **green** head, and a head with **no checks configured**
terminal, not transient, so holding there would retire the round rather
than delay it. It holds a **red** head (already woken ci-red above) and a
head whose check has **not finished** (opening the round there spends the
panel on a head that may go red — crew#45's measured cost — and it admits
itself a tick later once the check settles). Both holds are reported, not
swallowed, and they are reported *differently*: only one of them is the
author's own work to do.
- **Handoff**: a round of mine that converged — every panelist's latest
opinionated review approves the current head, no panel request
outstanding, mergeable right now, `state:needs-human` not already set.
Convergence is computed from `latestOpinionatedReviews`, never
`reviewDecision`, which stays empty without branch protection and silently
stalled rounds for a day (ceremony#26, #39).
- **Rebase**: my PR `CONFLICTING` — and only `CONFLICTING`; `UNKNOWN` is
GitHub's post-merge recompute flap and waits. A conflicting draft belongs
to resume.
- **Worktree hygiene**: a `build/*` worktree is removed only when its branch
has PR history and no PR on it remains open; a branch with no PR at all is
an in-flight claim and stays.
#### The operator notifier — the `needs-ruling` queue
The operator notifier (`notify.sh`, a fleet singleton on the triage box; its
mechanism is crew's too) watches open PRs carrying `state:needs-human`. That
poll never reads `needs-ruling`, which lives mostly on *issues* — so an
escalation waits invisibly on the very human it names. Not hypothetical: on
2026-07-23 alone, three escalations spent their whole lives outside the
operator's view — [#16's fork-PR-workflows
question](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5053302689)
(raised 01:23Z, [ruled 09:24Z](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5056705884)
— eight hours in which the board showed a `claimed` issue indistinguishable
from a builder mid-build), [#56's R1R3
escalation](https://github.com/heavy-duty/ceremony/issues/56#issuecomment-5057506832),
and [epic #50's own 13:04Z
flag](https://github.com/heavy-duty/ceremony/issues/50#issuecomment-5058713181),
which surfaced only because a human happened to look. This file records how
the fleet actually runs; that is why this wiring changed (#50 D16). The spec
for the engine-side update:
- **The second query.** Alongside the `state:needs-human` PR poll, `notify.sh`
polls **open issues and PRs labelled `needs-ruling`** across every repo in
`notify-repos.txt`, which is deliberately wider than the duty registry:
a cross-repo handoff is precisely what the operator cannot discover alone.
- **One tracked message per item, edited in place** — the same
one-message-per-item discipline the PR poll already uses, so an aging
ruling reads as a **live queue**, not a feed. The message is removed when
the flag comes off. Never one notification per rung: a rung crossing
changes the text of the existing message and does not page again.
- **The message carries what makes the ruling decidable at a glance:** the
item, the decision line (the escalation comment's first line), the flag's
age, and the current rung.
- **Rungs are the message's content, never its trigger.** The four rungs are
[the ladder's](https://github.com/heavy-duty/ceremony/blob/cb3d482b8be5c6563374a8c52159287fad43644d/LABELS.md#L94-L112)
**012h**, **at 12h**, **at 24h**, **past 24h** — with the age measured
from the current episode's `needs-ruling` `labeled` event, the same anchor
the board-side sweep reads. Division of labor: #73's sweep comments put the
rungs on the board for the fleet; the notifier puts them in the operator's
queue. Neither decides.
- **What is worth alerting on:** a `needs-ruling` past its stated `Default:`
deadline, or standing past 24h, is the fleet-health signal — not the
flag's existence. An escalation resolved inside its window is working as
designed and deserves a quiet queue entry, not an alarm.
Nothing box-side ever sets, clears, or decides `needs-ruling` (#50 D9, D15):
the notifier and triage's past-24h wake above *report and pick up* what the
board already shows; the label itself moves only by the doctrine's hands.
The duty engine is crew's shared tree, one source deployed to every box.
Specs written in this file have a record of becoming engine: the attention
wake and the reviewers' request sweep both started here as paper (the sweep's
org-wide form was then retired by the 2026-07-25 scope ruling), and the
builders' ci-red wake above is the latest: written here as paper while
crew#64 was open, engine at the stamped SHA. Two rows are still on paper, and
both are `needs-ruling`: the notifier's queue — at that SHA, `notify.sh`'s
only label filter is `state:needs-human` — and triage's **past 24h**
detection row above, which the triage-signals bullet already marks. Earlier
counts here said "two" while silently excluding the second; naming them is
cheaper than a number that has to be recounted every time a wake lands.
### Conventions on the board ### Conventions on the board
- `🔎 reviewing head <sha>` — a reviewer announces work before starting, so - `🔎 reviewing head <sha>` — a reviewer announces work before starting, so
liveness is visible instead of hoped for. liveness is visible instead of hoped for.
- `⟲ resuming from <sha>` — a builder announces recovery after interruption. - `⟲ resuming from <sha>` — a builder announces recovery after interruption;
there is no session state to restore, so the recovery path *is* the normal
path: read the board, continue from the worklog. Rebooting a box never
loses work that was pushed.
- Checkpoint discipline (builders): open the PR as draft at the first commit
with a `## Worklog` checkbox list; check off and push after every step.
The board and the branch are the only memory.
- Claim ritual: comment on the issue + self-assign + label flip, before any - Claim ritual: comment on the issue + self-assign + label flip, before any
branch exists. branch exists.
- Handoff: the author closes an approved PR's round with a summary comment, - Handoff: the author closes an approved PR's round with a summary comment,
flips `state:needs-human`, and requests the human — merging is never the flips `state:needs-human`, and requests the human — merging is never the
fleet's job. fleet's job.
- Worktree isolation: builders build each PR in its own `git worktree`;
reviewers check out PR heads in throwaway detached worktrees and remove
them after the verdict. Main clones stay parked on the default branch,
always clean.
## Where this is going ## Where this is going
This wiring proved itself on day one (seven merged PRs, unanimous three-model This wiring proved itself on day one (seven merged PRs, unanimous three-model
review convergence on #39, and a full-fleet crash recovery). The plan: review convergence on #39, and a full-fleet crash recovery), and the plan it
carried has become **heavy-duty/crew** — a shared engine, CLI, operator
1. Once the ceremony machinery is complete and adopted, each agent will be configuration model, real-host rehearsal and fixture tests — so standing up
asked to write a **detailed, replicable description of its own setup** a fleet is a bootstrap, not an archaeology dig. What remains is adoption:
cron lines, duty script, prompts, probes — as durable documentation. crew#85 tracks the road to a `0.1.0` another operator can use without a fork.
2. Those five descriptions get converged into a **solidified fleet-management Membership stays in the operator definition; this file remains the map of
solution** (duty loops as reusable templates, likely living alongside the what a wake means, and crew is the map of how it runs.
rig templates registry), so standing up this roster on a new repo — or a
whole new fleet — is a bootstrap, not an archaeology dig.
Until then, this file is the map.

203
LABELS.md
View file

@ -3,12 +3,13 @@
The taxonomy shared across the heavy-duty repos. Only the `scope:` set The taxonomy shared across the heavy-duty repos. Only the `scope:` set
differs per repo (each repo's `.github/labels.conf` names its actual differs per repo (each repo's `.github/labels.conf` names its actual
surfaces); everything else below is core and identical everywhere, created by surfaces); everything else below is core and identical everywhere, created by
the labels workflow's bootstrap dispatch (issue #10). the labels workflow's dispatch (which is also the operator's manual
full-board reconcile sweep; issue #10).
Two state machines share the taxonomy: the **PR machine** (proven in Two state machines share the taxonomy: the **PR machine** (proven in
box/rig/cast, reconciled by machinery) and the **issue flow** (the box/rig/cast, reconciled by machinery) and the **issue flow** (the
triage → build queue, doctrine-enforced today, machinery to follow — triage → build queue, reconciled by the work-queue sweep). One rule joins
issue #18). One rule joins everything: **states are machine-owned, intent everything: **states are machine-owned, intent
labels are hand-set** — a hand-moved state label is a lie waiting to happen, labels are hand-set** — a hand-moved state label is a lie waiting to happen,
and the reconciler recomputes it from GitHub's own facts. and the reconciler recomputes it from GitHub's own facts.
@ -16,9 +17,9 @@ and the reconciler recomputes it from GitHub's own facts.
| Label | Color | Waiting on | | Label | Color | Waiting on |
|---|---|---| |---|---|---|
| `state:building` | `#FBCA04` | the builder — PR is a draft | | `state:building` | `#FBCA04` | the builder — pre-round: no verdict stands against the head. Draft is evidence for it, not the definition of it: a draft carrying a standing non-approving verdict is a fix round and reads `state:addressing` (#205) |
| `state:bots-reviewing` | `#1D76DB` | the reviewer panel to finish the round (a request is live) | | `state:bots-reviewing` | `#1D76DB` | the reviewer panel to finish the round (a request is live) |
| `state:addressing` | `#D93F0B` | the builder — round complete without full approval, or nobody was asked, or a blocker is up | | `state:addressing` | `#D93F0B` | the builder — round complete without full approval, or nobody was asked, or a blocker is up, or a ruling is pending |
| `state:needs-human` | `#8250DF` | the human — **this PR could be merged right now**: zero blockers, whole panel approved the current head | | `state:needs-human` | `#8250DF` | the human — **this PR could be merged right now**: zero blockers, whole panel approved the current head |
`bots-reviewing` vs `addressing` is deliberate: staleness in the first means `bots-reviewing` vs `addressing` is deliberate: staleness in the first means
@ -26,8 +27,10 @@ and the reconciler recomputes it from GitHub's own facts.
`state:needs-human` means exactly one thing — a human could merge this now — `state:needs-human` means exactly one thing — a human could merge this now —
so it requires zero blockers and head-current approvals; anything less and so it requires zero blockers and head-current approvals; anything less and
the reconciler takes it back. The author sets it at handoff (the one the reconciler takes it back. The author sets it at handoff (the one
hand-set state); the `labeled` event fires the sweep that validates the hand-set state). On a same-repository head, the `labeled` event fires the
write within seconds. sweep that validates the write within seconds; on a fork head whose
`pull_request_target` token is read-only, validation waits for the scheduled
sweep cadence (#241).
## PR blockers — what is in the way? (facts, as many as apply) ## PR blockers — what is in the way? (facts, as many as apply)
@ -48,39 +51,179 @@ strips it on sight).
| Label | Color | Means | Set by | | Label | Color | Means | Set by |
|---|---|---|---| |---|---|---|---|
| `needs-triage` | `#FBCA04` | an issue that did not come through triage — it owes normalization or conversion back to a discussion | anyone who spots one; cleared by triage | | `needs-triage` | `#FBCA04` | a proposal or stray issue that did not come through triage — it owes normalization into work or a reasoned refusal | anyone who spots one; cleared by triage |
| `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage | | `ready` | `#0E8A16` | triaged, spec complete, unblocked — a builder can start now and succeed | triage |
| `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder | | `claimed` | `#1D76DB` | a builder owns it: assignee set, a draft PR expected shortly | the claiming builder |
| `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it | | `blocked` | `#6A737D` | waiting on another issue or PR (`Blocked by #N` in the body names it) | triage; anyone may correct it |
| `post-merge` | `#006B75` | the Refs-linked PR merged; post-merge acceptance criteria remain; the claim is released — nothing here is buildable and nobody owes a draft | the sweep or triage |
| `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage | | `epic` | `#5319E7` | organizes other issues via a dependency-ordered task list; **builders never pick an epic** | triage |
The invariant a board scan relies on: every open issue is either The work-queue sweep enforces the invariant a board scan relies on: every open issue is either
`needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` / `needs-triage`, `epic`, or carries exactly one of `ready` / `claimed` /
`blocked`. A `claimed` issue with no open PR and no activity is what the `blocked` / `post-merge`. It flags conflicts rather than guessing intent. A `claimed` issue
staleness sweep will reclaim (issue #18); until that machinery exists, with no open PR and no activity for 48 hours is reclaimed by the sweep: it
[TRIAGE.md](TRIAGE.md) owns the hygiene by hand. comments, unassigns the stale owner, and restores `ready`.
When a merged PR references a `claimed` issue with `Refs #N` and unchecked
criteria remain, the sweep moves the issue to `post-merge`, clears the
assignee, and comments with the remaining criteria verbatim. The comment says
that the claim is released and that triage owes a follow-up naming the owner
and wake condition for completion. Triage writes that full transition comment
in the same tick when it or the operator makes the move by hand. The sweep
never reclaims `post-merge`: weeks of quiet can be the state working. It does
make the quiet visible — after 7 days with no comment on the issue, the sweep
posts one nudge naming the triage actor, saying the wake evidence is owed and
linking the item. Only a comment resets that clock: label churn does not, and
neither does an assignment, which is the claim clock's fact and on this queue
state is the invalid composition flagged below. Which criterion starved is
prose the machine never judges; the link is the payload. Like the ruling nudge
it carries no idempotency marker on purpose — the comment is itself activity,
so the rule self-rate-limits to one nudge per 7 quiet days — and it writes no
label.
`post-merge` never composes with `blocked`; the transition comment carries the
wait. It never composes with `attention`, because releasing the claim clears
the assignee and leaves nobody parked-for. An assigned `post-merge` issue is
flagged rather than repaired: a hand-assignment is intent. `needs-ruling`
still composes. When the remainder becomes buildable, triage moves
`post-merge` to `ready` or mints a fresh `ready` issue. Any builder may claim
that work from current `main`; the original builder has no special standing,
and re-entry does not set `attention`.
## Cross-cutting (PRs and issues) ## Cross-cutting (PRs and issues)
| Label | Color | Meaning | | Label | Color | Meaning |
|---|---|---| |---|---|---|
| `stale` | `#B60205` | no activity for 48h — sweep-managed, never hand-applied | | `stale` | `#B60205` | no activity for 48h — sweep-managed, never hand-applied |
| `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it) | | `blocked` | `#6A737D` | (see above — same label serves PRs waiting on another PR/issue; legitimately quiet, the staleness sweep skips it). The reconciler refuses `state:needs-human` while `blocked` stands — the PR falls to `state:addressing` (#180) |
| `offsite` | `#CFD3D7` | issue deliverable is a PR in another repository; set by the builder with the draft link and cleared by the builder at handoff |
| `needs-ruling` | `#D4C5F9` | a human-owned decision is required; use BUILDER.md's ruling template and ladder. Set by triage or the builder; a state, not a signal — it clears on agreement, not on a reply |
| `attention` | `#D93F0B` | issue-only demand parked for the assignee; hand-set, and never written by the machine |
| `release` | `#0E8A16` | release flow, versioning, packaging work — and the ceremony PR itself | | `release` | `#0E8A16` | release flow, versioning, packaging work — and the ceremony PR itself |
| `merge-next` | `#0E8A16` | head of the merge queue — merge this one next. Queue order is *intent*: never set by the reconciler, only cleared by it | | `merge-next` | `#0E8A16` | head of the merge queue — merge this one next. Queue order is *intent*: never set by the reconciler, only cleared by it |
`needs-ruling` marks where the human's turn is when the pending thing is a
*decision*, not a merge ([#50 D1D14](https://github.com/heavy-duty/ceremony/issues/50)).
It applies to any human-owned decision — org policy, published artifacts,
secrets, prod, or any choice whose cost lands outside the work. A panel
deadlock is one instance, not the definition (D11). It is not
`state:needs-human`: that label means exactly "this PR could be merged right
now", and the retired `state:needs-rebase` is the family's proof that a
label meaning two things lies about both. It is not a `blocker:*` either:
every blocker names work the *builder* owes, a ruling is owed by the human —
and the flag must live on issues too, where blockers do not exist. On issues
it coexists with the queue labels (the one-of-three invariant above ignores
it); its color is the light shade of `state:needs-human`'s, so the human
axis reads as one family. It is a state, not a signal: set only with the
[canonical escalation contract](BUILDER.md#the-ruling-ask) (D12). A bare
flag is noise. The comment carries exhaustive, mutually exclusive options
(at most three), a mandatory recommendation, what stops and what continues,
and either a default affirmatively known to be reversible inside the PR or
`none — hard block`. Unsure is a block; published artifacts, secrets, prod,
and org policy are hard blocks by construction (D13).
The ruling ladder runs from the current episode's `needs-ruling` **`labeled`
event** (D13D14):
- **012h:** a clear, reversible decision may proceed when its stated default
expires, saying out loud that it did; anything with reasonable doubt waits
as a hard block.
- **at 12h:** the setter re-reads the default against what has landed and asks
whether it still holds and whether doubt remains. A stale default does not
fire; new doubt makes it a hard block.
- **at 24h:** the builder proceeds regardless, **as a PR**, stating the option
chosen and the doubt that remains. Nothing merges by this; the human still
gates the merge.
- **past 24h:** triage picks the option, records it as a decision, and remains
accountable. The operator may overturn it at merge.
A re-flag starts a new ladder. The rungs apply whatever `Default:` says,
including a hard block. Active discussion still climbs the ladder; by
contrast, the separate 7-day nudge resets on real activity. The machine
observes the rungs but never sets, clears, or decides `needs-ruling`.
The flag stays up until agreement is *reached* — a human reply alone does not
clear it — and its setter closes it out: records the ruling as a decision in
one comment, removes the label, and returns the item to its flow in that same
comment, never as a side effect. If the human disagrees that agreement was
reached, the label goes back on. The reconciler refuses `state:needs-human`
while it stands (the PR falls to `state:addressing` — the ball on the PR is
the builder's, who carries the ruling in), and the staleness sweep skips it,
because waiting on a human is legitimately quiet. Quiet, but not unwatched
(#52, both surfaces): a flag set with no escalation comment from its setter
is called out by the sweep — comment-only, scoped to the labeled event, the
label never removed — and a ruling with no real activity for 7 days draws a
comment-only nudge addressed to the decider, linking the escalation. The
nudge carries no marker on purpose: the comment is itself activity, so it
resets its own window and never repeats within a quiet week. Label churn is
never activity, or the sweep would reset itself — and each surface's clock
reads what exists on it: on a pull request, comments, reviews and commits;
on an issue, comments alone. An assignment is the claim clock's fact, not
the ruling's — claiming a flagged issue does not answer it, and buys the
escalation no quiet (#284).
`offsite` is issue-only and records that a claimed issue's deliverable lives
in another repository, where a closing reference cannot make a local open PR
visible to the sweep (#68). The builder sets it in the same step that posts
the cross-repo draft link, then clears it at handoff in the same comment that
reports whether that PR merged or closed. The machine reads the flag and
never writes it. It stops only the claim-reclaim clock: missing assignees are
still flagged, queue-label conflicts and missing queue state are still
repaired, and epic-completion and PR-side stale behavior are unchanged. The
sweep tells the assignee once when every visible cross-referenced PR has
closed; it only tells, and never clears the flag or changes the claim.
`attention` is issue-only and says a demand is parked on an issue for its
assignee. Anyone who needs that assignee's hands — triage, the operator, or a
sibling agent — sets it. The assignee alone clears it, as the first act of
pickup together with a short comment; that removal is the acknowledgement
and re-arms the flag for the next demand. If the session dies before the ack,
the still-visible flag launches the next pickup instead. An unanswered flag
is auditable evidence on the board.
The flag is additive: it composes with `ready`, `claimed`, or `blocked` and
with `needs-ruling`, and never substitutes for queue state. It pauses no
clock. Unlike `offsite` and `needs-ruling`, which make silence legitimate,
unanswered `attention` is exactly the silence the 48-hour reclaim should
take. It is hand-set: the machine never sets `attention`, never assigns
anyone to receive one, and never decides that one has been answered — the
assignee's removal is the only ack. It writes the label in exactly one
place, the derived `claimed``post-merge` transition below, and nowhere
else; where it reads the flag it reads it to diagnose. The PR sweep comments
when `attention` is put on a pull request, and the issue sweep comments when
it is put on an issue with no assignee. Both diagnoses leave the label and
assignees alone; the machine never infers the claim issue, decides that the
demand was answered, or repairs either malformed shape.
An `attention` issue without an assignee is therefore a board bug, not a
demand; anyone may assign it or remove the flag. It never composes with
`post-merge`, whose released claim has no assignee to answer the demand. The
one machine-clear exception is the derived `claimed``post-merge`
transition: releasing the assignee clears a carried `attention` in the same
edit. A hand-created `post-merge` + `attention` composition is flagged, not
rewritten.
The three signals are mutually distinct: `attention` means an assignee owes
a move; `needs-ruling` means a human owes a decision under
[the escalation contract and ladder](BUILDER.md#the-ruling-ask); and a bare
`@`-mention is an FYI that demands nothing and remains perfectly fine. A
demand that is itself a human decision carries `needs-ruling`, never both.
This distinction records the
[#16 missed-ruling incident](https://github.com/heavy-duty/ceremony/issues/16#issuecomment-5061051198)
and why the rejected mention poll is not returning: ordinary thread traffic
re-arms mentions, but only the writer can declare that a move is owed (#83).
## Scope — which surface? (PRs and issues, any number) ## Scope — which surface? (PRs and issues, any number)
All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. The All scopes share one calm color, `#C5DEF5` — scopes locate, states alert. The
set is per-repo (`.github/labels.conf`); PRs get theirs from changed paths via set is per-repo: PRs get theirs from changed paths via the labels workflow's
actions/labeler, issues get theirs from triage. This repo's set: scope job — an additive write only, so a label applied by hand or by an agent
while the machine runs always survives it (#130) — and issues get theirs from
| Label | Covers | triage. This file never enumerates a set — it is mirrored
|---|---| byte-identically into every governed repo, and any list it carried would be
| `scope:release-flow` | the reusable release workflow, decide, the doors | true in one repo and false in the rest (#104). The set for the repo you are
| `scope:guards` | changelog-armed / changelog-monotonic / drill-recorded | standing in lives in the two places that are true wherever you read them: its
| `scope:labels` | the labels workflow, reconciler, this taxonomy | `.github/labels.conf` (the definitions, one `name|color|description` row per
| `scope:docs` | README doctrine, CONSUMERS.md, the role files | scope) and its own `CONTRIBUTING.md`, beside the other repo-specific facts.
## Issue types ## Issue types
@ -90,9 +233,13 @@ on a PR would say the same thing twice and drift.
## Maintenance ## Maintenance
The labels workflow (issue #10) recomputes PR state statelessly on PR events The labels workflow (issue #10) recomputes PR state statelessly on subscribed
plus a 15-minute advisory cron, and bootstraps this taxonomy idempotently on events plus a consumer-owned scheduled discovery sweep. Hourly is the
manual dispatch. Issue-flow labels are doctrine-owned until #18 lands recommended default when no other engine drives board state; relax it only as
machinery for them. Default GitHub labels (`duplicate`, `invalid`, the transition classes with no other writer shrink. Manual dispatch both
`question`, `wontfix`, `help wanted`, `good first issue`) are deleted at bootstraps this taxonomy idempotently and runs the operator's on-demand
bootstrap — a `question` is a discussion, not an issue. full-board reconcile. The sweep warns when the core taxonomy declares a label
the repository lacks. The same workflow reconciles issue-flow labels on issue
events and during the scheduled sweep. Default GitHub labels (`duplicate`,
`invalid`, `question`, `wontfix`, `help wanted`, `good first issue`) are
deleted at bootstrap — a `question` belongs in a proposal, not a work issue.

652
README.md
View file

@ -1,15 +1,66 @@
# ceremony # ceremony
One release ceremony for the whole heavy-duty family — implemented once, The heavy-duty family's **governance repo**: the machinery every repo in the
tested once, documented here, consumed everywhere else by reference. The family runs, and the doctrine every agent in the family reads. Implemented
approach and its constraints live in once here, tested once here, consumed everywhere else — the machinery never
[#1](https://github.com/heavy-duty/ceremony/issues/1); this README is the copied at all, the doctrine only as a mirror a guard keeps byte-identical to
operator-facing doctrine that used to live, three times over, in the the pin.
consumers' CONTRIBUTINGs.
- **Adopting or converting a repo** → [docs/CONSUMERS.md](docs/CONSUMERS.md). Two kinds of thing live in this tree, and they are consumed in two different
- **Working in this repo as an agent** → [AGENTS.md](AGENTS.md) routes you; ways because they have two different runtimes.
[CONTRIBUTING.md](CONTRIBUTING.md) has the repo specifics.
**Machinery is consumed by reference, at a pin.** The reusable workflows in
[`.github/workflows/`](.github/workflows/) and the composite actions in
[`actions/`](actions/) are fetched by GitHub at run time from the ref the
caller pins; no copy exists in the consumer. That machinery is two systems.
The **release ceremony** — [`release.yml`](.github/workflows/release.yml),
the decision and fact libraries under [`lib/`](lib/), and the guard actions
that keep a release honest — is the operator-facing half, and the runbook
below is its documentation. The **label and issue-flow machine**
[`labels.yml`](.github/workflows/labels.yml) and its detached sweep half
[`labels-sweep.yml`](.github/workflows/labels-sweep.yml) (split in #209),
driving [`labels-scope`](actions/labels-scope/),
[`labels-reconcile`](actions/labels-reconcile/) and
[`issueflow-reconcile`](actions/issueflow-reconcile/) — converges PR state
and the issue work queue. What its labels *mean* is
[LABELS.md](LABELS.md)'s contract, not this page's.
**Doctrine is consumed as a machine-verified mirror.** A document's only
runtime is an agent reading the working tree it stands in, and a doc that
needs a cross-repo fetch before it governs is a doc that sometimes goes
unread. So the agent-facing set — the files named in
[`docs/VENDORED.txt`](docs/VENDORED.txt) — is vendored into each governed
repo at `.ceremony/`, byte-identical to this repo at the pinned ref, by
[`actions/docs-sync`](actions/docs-sync/). A CI guard diffs the mirror
against the pin on every PR: hand-editing a vendored file, or bumping the
pin without re-syncing, goes red. It is a copy that cannot drift, which is
the only kind of copy this org allows. This README is deliberately *not* in
that set — a consumer's router is its `AGENTS.md`, not this repo's front
page — and [`.github/scripts/vendored-check.sh`](.github/scripts/vendored-check.sh)
records that reason beside the three other ceremony-only root docs.
**One pin governs both halves.** The ref a repo's workflow callers name is
the ref its `.ceremony/` mirror is verified against, so a process change
rolls out as one reviewed PR per repo: the pin line plus the re-synced
mirror, checked by the same guard.
## Where to go
- **Adopting ceremony, or converting a repo that carries its own copy**
[docs/CONSUMERS.md](docs/CONSUMERS.md) — the bootstrap and conversion
checklists, the caller stubs, the pin-bump procedure.
- **Working in this repo as an agent** → [AGENTS.md](AGENTS.md) routes you
to your role file ([TRIAGE.md](TRIAGE.md), [BUILDER.md](BUILDER.md),
[REVIEWER.md](REVIEWER.md)); [CONTRIBUTING.md](CONTRIBUTING.md) carries
this repo's own specifics — the review panel roster, the `scope:*` set,
the code and doctrine conventions.
- **The board: what a label means, and who may set it**
[LABELS.md](LABELS.md). It is the shared state machine; misusing one label
lies to every other agent on the board.
- **Family release windows — what ships together, and when**
[RELEASES.md](RELEASES.md).
- **How the operator fleet is actually wired** → [FLEET.md](FLEET.md), a
descriptive snapshot rather than doctrine.
- **Operating a release, or staring at a red run on main** → read on. - **Operating a release, or staring at a red run on main** → read on.
## What a release is ## What a release is
@ -21,78 +72,100 @@ stamps:
1. **The version goes bare**: `X.Y.Z-dev``X.Y.Z` 1. **The version goes bare**: `X.Y.Z-dev``X.Y.Z`
([lib/version.sh](lib/version.sh)). ([lib/version.sh](lib/version.sh)).
2. **The changelog is stamped *and re-armed* — two edits, not one** 2. **The changelog section is assembled — one edit, produced by the tool**
(box#108). `## Unreleased` becomes `## X.Y.Z — DATE`, and an **empty (#112). Entries never accumulate in `CHANGELOG.md`: each PR wrote one
`## Unreleased` goes back on top**, immediately above it: fragment file, `changelog.d/<issue>.md`
([the directory's marker](changelog.d/README.md) names the doctrine), and
the ceremony PR runs [bin/changelog-assemble](bin/changelog-assemble) —
by hand, on purpose, so the section lands in the PR's diff where the
panel reads it (#112 D12; a consumer's exact invocation is in
[docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)). The
tool folds every fragment into a new `## X.Y.Z — DATE` section on top and
deletes the fragments it consumed; the
[assembled guard](#changelog-assembled--the-stamp-is-exactly-the-fragments)
replays that run and refuses a stamp that is not byte-for-byte the
fragments' assembly.
```markdown There is no second edit: the old stamp *re-armed* — put an empty
## Unreleased `## Unreleased` back on top — because every PR inserted at that one
shared anchor, and between the stamp and the re-arm a PR authored
## 0.7.1 — 2026-07-19 *before* the release landed its entry under whatever now occupied the
position — **the section that just shipped** — cleanly, no conflict, no
### Fixed signal (box#108; confirmed cross-repo as rig#66). Fragments make that
... failure structurally impossible rather than guarded-against: a fragment
``` merged after the release simply sits in the directory and is assembled
into the *next* section. There is no anchor left to misplace, and nothing
The second edit is not cosmetic and not deferrable. Between the stamp to re-arm — the directory is always armed.
and the next re-creation of that heading, main has no `## Unreleased`. A
PR authored *before* the release wrote its entry under that heading;
with the heading gone, git lands the entry under whatever now occupies
the position — **the section that just shipped** — and it merges
cleanly, no conflict, no signal. The changelog then credits a released
version with a change it does not contain, and nothing but a human
reading the file will ever say so (box#108; confirmed cross-repo as
rig#66). The [armed guard](#changelog-armed--main-never-sits-disarmed)
exists because of exactly this edit.
3. **The drill record is present**: `drills/X.Y.Z.md`, non-blank — the 3. **The drill record is present**: `drills/X.Y.Z.md`, non-blank — the
evidence the release rests on evidence the release rests on ([the drill doctrine](#the-drill-doctrine)).
([the drill doctrine](#the-drill-doctrine)).
(This repo's own ceremony adds a fourth stamp: `CEREMONY_SELF_REF` — the (This repo's own ceremony adds a fourth stamp: `CEREMONY_SELF_REF` — the ref
ref consumers' runs fetch this repo at — moves to the version being consumers' runs fetch this repo at — moves to the version being released, in
released, in [release.yml](.github/workflows/release.yml#L123-L132) and [release.yml](.github/workflows/release.yml#L123-L132) and every other
every other workflow that carries it. workflow that carries it.
[self-ref-check.sh](.github/scripts/self-ref-check.sh) fails CI here, not [self-ref-check.sh](.github/scripts/self-ref-check.sh) fails CI here, not a
a consumer's release, when it is stale.) consumer's release, when it is stale.)
**The merge is the ship decision; the tag is transcription.** After the **The merge is the ship decision; the tag is transcription.** After the
merge, [release.yml](.github/workflows/release.yml#L136-L300) asserts its merge, [release.yml](.github/workflows/release.yml#L136-L310) asserts its
way to certainty, tags the merge commit, publishes the GitHub release with way to certainty, tags the merge commit, publishes the forge release with
the version's own changelog section as the body — the curated prose, never the version's own changelog section as the body — the curated prose, never
the generated PR list the generated PR list ([lib/changelog.sh](lib/changelog.sh) is the one
([lib/changelog.sh](lib/changelog.sh) is the one canonical extractor) — canonical extractor, and [bin/changelog-section](bin/changelog-section) is
and re-arms main by bumping to `X.Y.(Z+1)-dev` its command-line face) — and, on the bare-`X.Y.Z` path, re-arms main by
([release.yml](.github/workflows/release.yml#L266-L300)). The machine does bumping to `X.Y.(Z+1)-dev`; the version is the only re-arm left, the
the transcription because humans err silently and machines fail loudly: changelog needs none (#112). An rc ships too, and its next version is a human
decision rather than arithmetic, so the re-arm stops for you to make it
([The re-arm refused](#the-re-arm-refused-releaseyml)). The machine does the
transcription because humans err silently and machines fail loudly:
**everything asserts its way to certainty and fails loudly, creating **everything asserts its way to certainty and fails loudly, creating
nothing** — a wrong release is worse than a missing one, so every failed nothing** — a wrong release is worse than a missing one, so every assert in
assert leaves zero artifacts: no tag, no release, no bump. this file fires *before its door creates anything*, and one that fails leaves
zero artifacts of the run's own: no tag it made, no release, no bump. Three
steps run past the tag, and what a failure at each leaves behind is what
sorts them. Two fail before the release exists: the consumer's
[artifact hook](docs/CONSUMERS.md#the-artifact-hook) sits between the tag and
the publish, so its non-zero exit aborts, and the publish itself
([`forge_release_create`](.github/workflows/release.yml#L264-L277))
can fail on the API call or the assets. Either leaves the same state — a tag
standing and no release — which the merge-door preflight recognizes and a
re-run resumes. The tag door remains the fallback when the original run is no
longer reachable or the release must come from a fixed tree. The third is the
re-arm, which runs after the publish, and its refusal is the single failure in
this file that leaves a real release behind.
## The two doors ## The two doors
- **The merge door — the paved road.** A push to main - **The merge door — the paved road.** A push to main runs the
([release.yml](.github/workflows/release.yml#L140)) runs the
[decide table](#what-happens-when-my-pr-lands-on-main); a merged, [decide table](#what-happens-when-my-pr-lands-on-main); a merged,
`release`-labeled PR whose version transitioned to bare is the ceremony, `release`-labeled PR whose version transitioned to bare is the ceremony,
everything legitimate that isn't one is a green no-op, and every everything legitimate that isn't one is a green no-op, and every
half-ceremony dies loudly. Use it for every normal release. half-ceremony dies loudly
([release.yml](.github/workflows/release.yml#L136-L310)). Use it for every
normal release.
- **The tag door — the fallback and the backfill.** A bare `X.Y.Z` tag - **The tag door — the fallback and the backfill.** A bare `X.Y.Z` tag push
push — **no `v` prefix**, box's 0.6.0 set the scheme **no `v` prefix**, box's 0.6.0 set the scheme
([release.yml](.github/workflows/release.yml#L302-L369)) — publishes the ([release.yml](.github/workflows/release.yml#L325-L410)) — publishes the
same way. The tag is the operator's explicit act, so there is no decide same way. The tag is the operator's explicit act, so there is no decide
and no label check; the one assert is that **the tag names the tree's and no label check — what is left is three asserts: **the tag names the
own version**, and a mismatch refuses, creating nothing. No `-dev` bump tree's own version**
either — the fallback does not rewrite main (cast's precedent). Use it ([L350L361](.github/workflows/release.yml#L350-L361)), **the tagged
when the merge path is red, for backfills, and for the tree carries a publishable `## X.Y.Z` section**
([L362L374](.github/workflows/release.yml#L362-L374)), and **no published
release already exists for the tag**
([L375L390](.github/workflows/release.yml#L375-L390)); any failure
refuses, creating nothing. No `-dev` bump either
— the fallback does not rewrite main (cast's precedent). Use it when the
merge path is red, for backfills, and for the
[first-release edge](#what-happens-when-my-pr-lands-on-main) (row 4). [first-release edge](#what-happens-when-my-pr-lands-on-main) (row 4).
Tag + publish (+ the consumer's artifact hook) happen **in the same job, Tag + publish (+ the consumer's artifact hook) happen **in the same job, on
on purpose**: a `GITHUB_TOKEN`-created tag fires no workflows (GitHub's purpose**: a `GITHUB_TOKEN`-created tag fires no workflows (GitHub's
anti-recursion), so the merge door's tag can never re-enter the tag door anti-recursion), so the merge door's tag can never re-enter the tag door and
and double-publish — and that job is the release's only chance to publish double-publish — and that job is the release's only chance to publish (#1
([release.yml](.github/workflows/release.yml#L223-L234), #1 constraint 2). constraint 2).
## What happens when my PR lands on main ## What happens when my PR lands on main
@ -100,8 +173,8 @@ The merge door runs on **every** push to main, and the `release` label
legitimately means two things (release ceremonies, and ordinary work *on* legitimately means two things (release ceremonies, and ordinary work *on*
the release machinery), so the door's first act is a decision: the six-row the release machinery), so the door's first act is a decision: the six-row
table in [lib/decide.sh](lib/decide.sh#L29-L61) (issue #8 — the comment table in [lib/decide.sh](lib/decide.sh#L29-L61) (issue #8 — the comment
block *is* the spec, and the table is contract-tested offline). Rendered block *is* the spec, and the table is contract-tested offline by
for operators: [test/decide.test.sh](test/decide.test.sh)). Rendered for operators:
| # | the tree your merge produced | the run | what it means — and your move | | # | the tree your merge produced | the run | what it means — and your move |
|---|---|---|---| |---|---|---|---|
@ -110,11 +183,11 @@ for operators:
| 3 | version bare, unchanged, already released | green `NOTICE`, no-op | The post-release window: the ceremony landed, the `-dev` bump hasn't. Nothing to do. | | 3 | version bare, unchanged, already released | green `NOTICE`, no-op | The post-release window: the ceremony landed, the `-dev` bump hasn't. Nothing to do. |
| 4 | version bare, unchanged, **never released** | **red, nothing created** | The label says ship but this PR did not mint the version. Mislabeled → drop the label. Meant to release → it forgot the bump; re-do the ceremony PR. A repo whose first version never carried `-dev` ships its first release by the **tag door** — the known first-release edge (cast#111; [lib/decide.sh](lib/decide.sh#L70-L74)). | | 4 | version bare, unchanged, **never released** | **red, nothing created** | The label says ship but this PR did not mint the version. Mislabeled → drop the label. Meant to release → it forgot the bump; re-do the ceremony PR. A repo whose first version never carried `-dev` ships its first release by the **tag door** — the known first-release edge (cast#111; [lib/decide.sh](lib/decide.sh#L70-L74)). |
| 5 | version transitioned to bare, **no merged `release`-labeled PR** behind the commit | **red, nothing created** | A transition nobody declared — a release is a labeled ceremony PR, not a bare push. Label a proper ceremony PR and re-do it, or publish by the tag door if the tree is genuinely right. | | 5 | version transitioned to bare, **no merged `release`-labeled PR** behind the commit | **red, nothing created** | A transition nobody declared — a release is a labeled ceremony PR, not a bare push. Label a proper ceremony PR and re-do it, or publish by the tag door if the tree is genuinely right. |
| 6 | version transitioned to bare, merged `release`-labeled PR behind the commit | **the ceremony** | Tag → notes → publish → `-dev` re-arm. Your move afterwards: verify the release exists and main reads `X.Y.(Z+1)-dev`. | | 6 | version transitioned to bare, merged `release`-labeled PR behind the commit | **the ceremony** | Tag → notes → publish → `-dev` re-arm. Your move afterwards: verify the release exists and main reads `X.Y.(Z+1)-dev`. **Read *bare* as decide reads it** — anything not `-dev` ([lib/decide.sh](lib/decide.sh#L108-L110)) — so an rc transition is a shippable ceremony here too, and the release lands but the re-arm stops for you to pick the next version ([The re-arm refused](#the-re-arm-refused-releaseyml)). |
The green rows are the point as much as the red ones: the machinery must The green rows are the point as much as the red ones: the machinery must be
be safe to work on, so every legitimate non-ceremony is a green `NOTICE` safe to work on, so every legitimate non-ceremony is a green `NOTICE` no-op,
no-op — never a red run on main per infra PR never a red run on main per infra PR
([lib/decide.sh](lib/decide.sh#L6-L12)). The label is hand-set intent and ([lib/decide.sh](lib/decide.sh#L6-L12)). The label is hand-set intent and
automation never guesses; the version transition is the interlock, and automation never guesses; the version transition is the interlock, and
label-without-transition (row 4) and transition-without-label (row 5) both label-without-transition (row 4) and transition-without-label (row 5) both
@ -122,143 +195,230 @@ refuse (#1 constraint 8).
## The guards ## The guards
Three composite actions run in every consumer's CI (and in this repo's [`actions/`](actions/) holds ten composite actions. Three belong to the
own). Shared shape: version-keyed where the tree's state matters, loud label machine named above and are not the operator's business here. Of the
where it fails, and **a file of its own so a test can drive it**. The full remaining seven, a consumer's own `ci.yml` carries **five** guard steps —
war stories are in the scripts' header comments — authoritative and longer `changelog-armed`, `changelog-monotonic`, `changelog-assembled`,
than this; what follows is the operator's cut. `drill-recorded` and [`runner-isolated`](actions/runner-isolated/), the last
asserting that no `pull_request`-triggered workflow names a self-hosted
runner (#58) — plus [`refs-not-closing`](actions/refs-not-closing/) in its
own [`refs-guard.yml`](.github/workflows/refs-guard.yml) caller, because
body edits are load-bearing there (#200, #218), and
[`docs-sync`](actions/docs-sync/) once the repo adopts the agent team flow.
The exact steps and their pin-availability rules are in
[docs/CONSUMERS.md](docs/CONSUMERS.md).
The four below are the release's own, and this is the operator's cut of
them. Shared shape: version-keyed where the tree's state matters, loud where
it fails, and **a file of its own so a test can drive it**. The full war
stories are in the scripts' header comments — authoritative and longer than
this.
This repo eats what it serves: [`ci.yml`](.github/workflows/ci.yml) runs the
guard actions against its own real tree, and
[`release-exercise.yml`](.github/workflows/release-exercise.yml) replays the
merge door's step sequence on every PR.
### changelog-armed — main never sits disarmed ### changelog-armed — main never sits disarmed
**The rule** ([actions/changelog-armed/changelog-armed.sh](actions/changelog-armed/changelog-armed.sh#L27-L36)), **The rule** ([actions/changelog-armed/changelog-armed.sh](actions/changelog-armed/changelog-armed.sh)),
keyed on the tree's version: keyed on the tree's shape, then its version. In **fragment mode**
`changelog.d/` exists, the arming property moved onto the directory (#112
D7):
- always → the marker `changelog.d/README.md` must exist (what keeps the
directory tracked when it holds no fragments), no `## Unreleased` section
may survive in `CHANGELOG.md` (a second anchor with no owner), and every
fragment must be publishable on its own — named `<issue>.md` or
`<repo>-<issue>.md`, no `## ` heading, at least one bullet, no `### `
heading without an entry. A malformed fragment fails the PR that wrote it,
not the release that consumes it (#112 D9).
- `-dev` tree → nothing more. The directory **is** the arming: the next PR's
entry is a new file, and a new file always has somewhere to land.
- bare tree (the ceremony PR and its merge) → every fragment must be
consumed, and the top section must be the stamped, publishable section for
exactly that version. Fragment mode has no re-armed shape — there is
nothing left to re-arm.
In **legacy mode** — no `changelog.d/` — the version-keyed rules stand
verbatim; both shapes stay supported so a consumer adopts fragments on a pin
bump, on its own schedule (#112 D8):
- `-dev` tree → the top section **must** be `## Unreleased`. - `-dev` tree → the top section **must** be `## Unreleased`.
- bare tree (the ceremony PR and its merge) → the top section may be - bare tree (the ceremony PR and its merge) → the top section may be
`## Unreleased` (re-armed) *or* the stamped section for exactly that `## Unreleased` (re-armed) *or* the stamped section for exactly that
version — **and** that version's section must exist and carry prose, version — **and** that version's section must exist, carry at least one
because it is the one about to ship (the half-ceremony refusal, rig#67: `-` or `*` entry, and have no `### ` heading without an entry before the
version bumped, stamp missing — asserted through the very extractor the next heading or section end. A heading is not an entry. These publication
publisher uses, so the two cannot disagree about what a section is). rules do not apply to `Unreleased`: the empty three-heading template is
deliberately valid there (the half-ceremony refusal, rig#67: version
bumped, stamp missing — asserted through the very extractor the publisher
uses, so the two cannot disagree about what a section is).
**The incident**: box#108 / rig#66 — the silent mislanding described **The incident**: box#108 / rig#66 — the silent mislanding described
[above](#what-a-release-is). **Red means** a PR entry has nowhere safe to [above](#what-a-release-is). Fragment mode retires the incident's mechanism
land; **the fix** is to re-arm: add an empty `## Unreleased` above the top outright; legacy mode guards it. **Red means** a PR entry has nowhere safe
stamped section. to land — a missing marker, a surviving `## Unreleased`, a malformed
fragment — or a stamped version would publish no entries, a dangling grouped
heading, or a bare tree still carrying fragments the stamp did not consume
(`not consumed` — re-run the assembler); the message names the fix in every
case. What this guard cannot see is a fragment that *was* consumed but whose
entry the stamp omits — the fragment is gone from HEAD, so only
[changelog-assembled](#changelog-assembled--the-stamp-is-exactly-the-fragments)'s
merge-base replay catches that loss.
**Do not "simplify" this to "always require `## Unreleased`".** The **Do not "simplify" this to "always require `## Unreleased`".** The
unconditional form is false by construction on the ceremony PR's own tree unconditional form is false by construction on the ceremony PR's own tree —
— it makes every release unshippable — and rig#44 and cast#108 both had it makes every release unshippable — and rig#44 and cast#108 both had to
to revert exactly that revert exactly that. The version-keyed form is what rig and cast get back by
([the script's header](actions/changelog-armed/changelog-armed.sh#L8-L16)). adopting this repo.
The version-keyed form is what rig and cast get back by adopting this repo.
One consequence worth knowing before it happens: a ceremony PR that stamps One consequence worth knowing before it happens, legacy mode only: a
and forgets to re-arm still passes this guard — a bare tree is allowed to ceremony PR that stamps and forgets to re-arm still passes this guard — a
be stamped. It goes red **the moment the automatic `-dev` bump lands on bare tree is allowed to be stamped. It goes red **the moment the automatic
main** ([the script](actions/changelog-armed/changelog-armed.sh#L37-L42)). `-dev` bump lands on main**. The guard does not block the release; it
The guard does not block the release; it refuses to let main *sit* refuses to let main *sit* disarmed, which is the window a late PR falls
disarmed, which is the window a late PR falls into. into. Fragment mode has no such window: with no re-arm step there is nothing
to forget.
### changelog-assembled — the stamp is exactly the fragments
**The rule**
([actions/changelog-assembled/changelog-assembled.sh](actions/changelog-assembled/changelog-assembled.sh)):
on a release PR in fragment mode, the stamped `## X.Y.Z` section must be
**byte-for-byte** what the fragments it consumed assemble to. The guard
reads the fragments as of the merge base (they are gone from HEAD — that is
the point of the ceremony), replays `changelog-assemble --check` over that
set, and diffs the result against HEAD's section body. Every tree it does
not apply to — a `-dev` tree, legacy mode, no consumed fragments — passes
with a green `NOTICE`, so a non-ceremony PR is never red here.
**The failure it catches** (#116): assembly is a hand-run step by design —
the section must land in the PR's diff where the panel reads it (#112 D12) —
and a mis-run hand step can leave no trace. The two failure shapes differ,
and the guards split them exactly as
[test/changelog-assembled.test.sh](test/changelog-assembled.test.sh)'s trio
rows record: leave a fragment **out of the deletion** and it survives on
HEAD, where [changelog-armed](#changelog-armed--main-never-sits-disarmed)
already refuses the bare tree (`not consumed`) — this guard goes red too,
naming the entry the section lost. But **delete** a fragment while omitting
its entry from the stamp, or hand-edit one word of the assembled prose, and
nothing on HEAD is out of place: armed is green, monotonic is green, and the
publisher would happily publish history that is not what the authors wrote.
Only the merge-base replay catches those. The replay is what makes a
hand-run step safe. **This guard needs history** — same stance as the
monotonic guard: `fetch-depth: 0`, and in CI an unresolvable base is a hard
failure, not a skip.
### changelog-monotonic — shipped headings are append-only ### changelog-monotonic — shipped headings are append-only
**The rule** **The rule**
([actions/changelog-monotonic/changelog-monotonic.sh](actions/changelog-monotonic/changelog-monotonic.sh#L4-L7)): ([actions/changelog-monotonic/changelog-monotonic.sh](actions/changelog-monotonic/changelog-monotonic.sh)):
the set of `## X.Y.Z` headings on your branch must be a **superset** of the set of `## X.Y.Z` headings on your branch must be a **superset** of the
the set at the merge base, and no heading may appear twice on HEAD. The set at the merge base, and no heading may appear twice on HEAD. The rule
rule needs no tuning because release headings are append-only by doctrine: needs no tuning because release headings are append-only by doctrine: the
the ceremony adds one and nothing ever legitimately removes one — so ceremony adds one and nothing ever legitimately removes one — so superset
superset has no exception to carve. The ceremony's own stamp passes by has no exception to carve. The ceremony's own stamp passes by construction:
construction: rewriting `## Unreleased` into `## X.Y.Z — DATE` adds a the assembler writes a new `## X.Y.Z — DATE` heading and removes none.
heading and removes none (`Unreleased` is not a version heading; it is Fragment mode changes nothing here (#112 D10): fragments add no `## `
[changelog-armed](#changelog-armed--main-never-sits-disarmed)'s business). heading, and `Unreleased` was never in the guard's set — it is not a version
heading; it is
[changelog-armed](#changelog-armed--main-never-sits-disarmed)'s business —
which is why a repo's adoption PR can delete it and stay green.
**The incidents**: box#122 (caught in review of box#118) — an author **The incidents**: box#122 (caught in review of box#118) — an author adding
adding an entry under `## Unreleased` **replaced** the heading below it an entry under `## Unreleased` **replaced** the heading below it instead of
instead of inserting above it; git merges that cleanly, and the shipped inserting above it; git merges that cleanly, and the shipped section's body
section's body is silently absorbed into `## Unreleased`. And box#118 is silently absorbed into `## Unreleased`. And box#118 itself — a bad rebase
itself — a bad rebase *duplicated* a shipped heading, which containment is *duplicated* a shipped heading, which containment is blind to, which is why
blind to, which is why uniqueness-on-HEAD is a separate assert uniqueness-on-HEAD is a separate assert.
([the script](actions/changelog-monotonic/changelog-monotonic.sh#L96-L116)).
**Red means** a shipped section was deleted (put the heading back and **Red means** a shipped section was deleted (put the heading back and insert
insert **above** it) or duplicated (collapse to one heading; the failure **above** it) or duplicated (collapse to one heading; the failure message
message walks through both fixes with the diff to run). **This guard needs walks through both fixes with the diff to run). **This guard needs
history**: the consumer's checkout must use `fetch-depth: 0`, and in CI an history**: the consumer's checkout must use `fetch-depth: 0`, and in CI an
unresolvable base is a hard failure, not a skip — a guard that can quietly unresolvable base is a hard failure, not a skip — a guard that can quietly
stop guarding is the failure shape this family of checks exists to refuse stop guarding is the failure shape this family of checks exists to refuse.
([strict mode](actions/changelog-monotonic/changelog-monotonic.sh#L60-L79)).
### drill-recorded — a release carries its evidence ### drill-recorded — a release carries its evidence
**The rule** **The rule**
([actions/drill-recorded/drill-recorded.sh](actions/drill-recorded/drill-recorded.sh#L23-L48)), ([actions/drill-recorded/drill-recorded.sh](actions/drill-recorded/drill-recorded.sh)),
keyed on the tree's version: a `-dev` tree passes with nothing to assert keyed on the tree's version: a `-dev` tree passes with nothing to assert (a
(a development tree ships nothing); a bare tree — the ceremony PR and its development tree ships nothing); a bare tree — the ceremony PR and its merge
merge — must carry `drills/<version>.md` with at least one — must carry `drills/<version>.md` with at least one non-whitespace
non-whitespace character. One file per version, so `0.9.0.md` and character. One file per version, so `0.9.0.md` and `0.9.0-rc1.md` are simply
`0.9.0-rc1.md` are simply different files and prefix confusion is different files and prefix confusion is unrepresentable (#1 constraint 7).
unrepresentable (#1 constraint 7).
**The incident**: box's CONTRIBUTING said since box#96 that the release **The incident**: box's CONTRIBUTING said since box#96 that the release
ritual must be run and recorded. No release ever did it — box#95, box#114 ritual must be run and recorded. No release ever did it — box#95, box#114
and box#148 all shipped as a version bump plus a changelog stamp, because and box#148 all shipped as a version bump plus a changelog stamp, because
the gate was a sentence in a document and the only thing standing on it the gate was a sentence in a document and the only thing standing on it was
was a reviewer remembering to ask. The rule moved into CI, where it fires a reviewer remembering to ask. The rule moved into CI, where it fires
whether or not anyone is paying attention. whether or not anyone is paying attention.
**Red means** the release is asserting a ritual it left no evidence of. **Red means** the release is asserting a ritual it left no evidence of.
**The fix is to run the drill** and record it — or to waive it *in **The fix is to run the drill** and record it — or to waive it *in writing*
writing* at the same path: the guard demands a **record, not a passing at the same path: the guard demands a **record, not a passing result**
result** ([below](#the-drill-doctrine)). ([below](#the-drill-doctrine)).
## The drill doctrine ## The drill doctrine
**Evidence, not success.** The guard asserts a record exists — a failed **Evidence, not success.** The guard asserts a record exists — a failed
drill honestly written down satisfies it, and so does a maintainer waiver drill honestly written down satisfies it, and so does a maintainer waiver
that says plainly the drill was waived and why. What it refuses is that says plainly the drill was waived and why. What it refuses is silence:
silence: a skip must cost a deliberate, reviewable file in the diff, a skip must cost a deliberate, reviewable file in the diff, which is
which is precisely what box's three silent skips never produced. CI precisely what box's three silent skips never produced. CI cannot run a
cannot run a consumer's drill (box's wants real hardware and the better consumer's drill (box's wants real hardware and the better part of an hour);
part of an hour); it can only refuse a release that never ran one. it can only refuse a release that never ran one.
**Each repo defines what its drill *means*** — the gate only reads the **Each repo defines what its drill *means*** — the gate only reads the
record. box asserts the **isolation contract**; rig asserts record. box asserts the **isolation contract**; rig asserts **convergence**
**convergence** (a machine reaches its role, idempotently); cast asserts (a machine reaches its role, idempotently); cast asserts **promotion** (A→B
**promotion** (A→B reproduces, the diff is idempotent); ceremony's own reproduces, the diff is idempotent); ceremony's own drill is a **door
drill is a **door rehearsal** — both doors exercised end-to-end on a rehearsal** — both doors exercised end-to-end on a disposable repo, written
disposable repo (#11 names the six probes); incubator's is TBD in out step by step in [drills/README.md](drills/README.md), with the records
heavy-duty/incubator. Each repo states its meaning in its own themselves in [drills/](drills/); incubator asserts the **staging verify**
`drills/README.md`. Three different exercises sharing a substrate is why the canonical candidate deployed, its smoke probe run *inside* the staging
the records are per-repo — they are not phases of one script. container on the deployed environment's credentials, the record pinning the
commit SHA and image digest that were exercised
([heavy-duty/incubator `drills/README.md`](https://github.com/heavy-duty/incubator/blob/main/drills/README.md)).
Each repo states its meaning in its own `drills/README.md`. Five different
exercises sharing a substrate is why the records are per-repo — they are not
phases of one script.
**Drills exercise candidate refs, not released artifacts.** A ref is a **Drills exercise candidate refs, not released artifacts.** A ref is a
static identifier that exists as soon as the release branch does, so no static identifier that exists as soon as the release branch does, so no repo
repo has to be released — or drilled — before another can be drilled: has to be released — or drilled — before another can be drilled: what looks
what looks like a box↔rig recursion at runtime dissolves into two like a box↔rig recursion at runtime dissolves into two independent tests
independent tests against one fixed pair of refs. And drilling the against one fixed pair of refs. And drilling the candidate *is* drilling the
candidate *is* drilling the release: a ceremony PR's diff is the stamps release: a ceremony PR's diff is the stamps and nothing else, so no
and nothing else, so no executable byte differs between the tree that was executable byte differs between the tree that was drilled and the tree that
drilled and the tree that ships. ships.
**A cross-repo release set shares one run ID.** Each repo records its own **A cross-repo release set shares one run ID.** Each repo records its own
legs in its own `drills/X.Y.Z.md`, citing that run ID and the sibling legs in its own `drills/X.Y.Z.md`, citing that run ID and the sibling SHAs,
SHAs, so the records reconcile afterwards — but the guard only ever reads so the records reconcile afterwards — but the guard only ever reads the repo
the repo it runs in. If a defect shows up only in the combination: patch, it runs in. If a defect shows up only in the combination: patch, re-drill,
re-drill, re-record. The set converges; it is not required to be right in re-record. The set converges; it is not required to be right in one pass.
one pass.
## Troubleshooting red main ## Troubleshooting red main
Every refusal the release flow can emit, verbatim, with cause and remedy. Every refusal the release flow can emit, verbatim, with cause and remedy.
The catalog is generated from the sources, not paraphrased — regenerate The catalog is generated from the sources, not paraphrased — regenerate it
it with: with:
```sh ```sh
grep -n -A2 'refuse \|>&2' lib/decide.sh lib/facts.sh .github/workflows/release.yml grep -n -A2 'refuse \|>&2' \
lib/decide.sh lib/facts.sh lib/version.sh .github/workflows/release.yml
``` ```
`$VER`-style variables appear as the run interpolates them. `$VER`-style variables appear as the run interpolates them. One refusal is
outside that command by construction: `version_read: $path: no version field`
is a `console.error` inside the node one-liner at
[lib/version.sh#L55](lib/version.sh#L55) — no `>&2`, no `refuse `, so the grep
cannot see it. It is quoted below as it reaches the log at run time, which is
the convention this catalog is written to.
### The decision refused ([lib/decide.sh](lib/decide.sh)) ### The decision refused ([lib/decide.sh](lib/decide.sh))
@ -281,12 +441,11 @@ or — if the tree is genuinely the release — publish by the tag door.
> the version '$VER' is bare and unchanged, but RELEASED is empty — this state is decided by whether '$VER' is already released, and the caller did not establish that fact. Refusing to guess — creating nothing. > the version '$VER' is bare and unchanged, but RELEASED is empty — this state is decided by whether '$VER' is already released, and the caller did not establish that fact. Refusing to guess — creating nothing.
> the version transitioned ('$BASE_VER' -> '$VER') but LABELED is empty — a transition ships only behind a merged, release-labeled PR, and the caller did not establish that fact. Refusing to guess — creating nothing. > the version transitioned ('$BASE_VER' -> '$VER') but LABELED is empty — a transition ships only behind a merged, release-labeled PR, and the caller did not establish that fact. Refusing to guess — creating nothing.
The fact-gathering guards The fact-gathering guards ([L92L105](lib/decide.sh#L92-L105),
([L92L105](lib/decide.sh#L92-L105), [L135](lib/decide.sh#L135), [L135](lib/decide.sh#L135), [L151](lib/decide.sh#L151)): a missing fact must
[L151](lib/decide.sh#L151)): a missing fact must never fall through to never fall through to "no". These indicate a bug upstream in
"no". These indicate a bug upstream in [lib/facts.sh](lib/facts.sh) or the [lib/facts.sh](lib/facts.sh) or the workflow plumbing, not an operator
workflow plumbing, not an operator mistake — read the run's `facts:` mistake — read the run's `facts:` stderr line and file what you find.
stderr line and file what you find.
### The facts could not be established ([lib/facts.sh](lib/facts.sh), [lib/version.sh](lib/version.sh)) ### The facts could not be established ([lib/facts.sh](lib/facts.sh), [lib/version.sh](lib/version.sh))
@ -301,85 +460,162 @@ stderr line and file what you find.
> version_read: node is required for version-source: package-json > version_read: node is required for version-source: package-json
[lib/version.sh](lib/version.sh#L16-L66): the tree's version source is [lib/version.sh](lib/version.sh#L16-L66): the tree's version source is
missing, empty, or unreadable. A wrong release is worse than a missing missing, empty, or unreadable. A wrong release is worse than a missing one,
one, so an unreadable state is never an empty print — restore the so an unreadable state is never an empty print — restore the `VERSION` file
`VERSION` file (or `package.json` version field) on main. (or `package.json` version field) on main.
### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L300)) > version_read: unknown backend: $backend
[L62](lib/version.sh#L62): not an operator mistake and not reachable through
the release flow — [lib/facts.sh](lib/facts.sh#L33-L40) rejects a bad
`VERSION_SOURCE` with the message above before `version_read` is ever called,
so this line can only appear when some *other* caller invokes `version_read`
directly with a backend that is neither `file` nor `package-json`. Fix that
caller.
### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L310))
> CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release > CHANGELOG.md has no '## $VER' section at the merge commit — the ceremony PR must stamp it; refusing to publish an empty release
[L202L205](.github/workflows/release.yml#L202-L205): the ceremony merged [L202L205](.github/workflows/release.yml#L202-L205): the ceremony merged
without its stamp (a state the without its stamp (a state the
[armed guard](#changelog-armed--main-never-sits-disarmed) already refuses [armed guard](#changelog-armed--main-never-sits-disarmed) already refuses on
on the PR — red main here means it was overridden). Stamp the section on the PR — red main here means it was overridden). Stamp the section on main,
main, then publish by the tag door. then publish by the tag door.
> tag '$VER' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing. > release '$VER' already exists — this release already happened; refusing to re-release, creating nothing.
> release '$VER' already exists — refusing to re-release, creating nothing. > tag '$VER' already exists at <tag sha> but this run would tag <MERGE_SHA> — a manual tag won the race, or it names a different commit; refusing to re-release, creating nothing. Delete that tag, or re-tag the merge commit.
> NOTICE: tag '$VER' already stands at this merge commit and no release exists — a previous run of this door tagged and then failed to publish. Resuming: the tag is not recreated; the artifact hook and the publish run.
[L207L222](.github/workflows/release.yml#L207-L222), the nothing-exists [L208L239](.github/workflows/release.yml#L208-L239), the merge-door
assert — what makes a re-run of a completed ceremony refuse instead of preflight — the published-release refusal prevents clobbering, the
clobber, and what catches a manual tag racing the merge. If the release different-commit refusal diagnoses a racing or manual tag with both SHAs, and
truly exists, there is nothing to do: this red is the system declining to the notice resumes this door after its tag succeeded but the artifact hook or
do the thing twice. If the tag exists but the release does not (a manual publish failed. Re-run the merge-door job first. If that run is no longer
tag won the race, or reachable or the tree itself needs repair, use the tag-door fallback: delete
[a failed artifact hook](docs/CONSUMERS.md#the-artifact-hook)), recover by and re-push the tag from the fixed tree, or run `forge_release_create` by hand.
the tag door: delete and re-push the tag, or `gh release create` by hand
from a fixed tree.
> direct push refused (branch protection?) — opening the bump PR instead > direct push refused (branch protection?) — opening the bump PR instead
[L292L300](.github/workflows/release.yml#L292-L300) — loud, but not a [L302L310](.github/workflows/release.yml#L302-L310) — loud, but not a
refusal: the post-release `-dev` bump could not push directly, so the run refusal: the post-release `-dev` bump could not push directly, so the run
opened a `release`-labeled bump PR itself. Your move: merge it promptly — opened a `release`-labeled bump PR itself. Your move: merge it promptly —
until it lands, main is sitting bare, where a dev install until it lands, main is sitting bare, where a dev install impersonates the
[impersonates the release](.github/workflows/release.yml#L291) and the release and the
[armed guard's window](#changelog-armed--main-never-sits-disarmed) stays [armed guard's window](#changelog-armed--main-never-sits-disarmed) stays
open. open.
### The tag door refused ([release.yml](.github/workflows/release.yml#L302-L369)) ### The tag door refused ([release.yml](.github/workflows/release.yml#L325-L410))
> tag '$GITHUB_REF_NAME' does not match the tree's version '$ver' — creating nothing. > tag '$GITHUB_REF_NAME' does not match the tree's version '$ver' — creating nothing.
> A release is a PR, then a tag: the release PR bumps the version and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit. > A release is a PR, then a tag: the release PR bumps the version and stamps the changelog; the tag goes on its MERGE commit. Delete this tag and re-tag the right commit.
[L333L337](.github/workflows/release.yml#L333-L337). The message is the [L356L359](.github/workflows/release.yml#L356-L359). The message is the
remedy. remedy.
> CHANGELOG.md has no '## $VER' section — stamp the Unreleased section in the release PR before tagging; refusing to publish an empty release > CHANGELOG.md has no '## $VER' section — run changelog-assemble in the release PR before tagging; refusing to publish an empty release
[L346L349](.github/workflows/release.yml#L346-L349). The tagged tree was [L368L374](.github/workflows/release.yml#L368-L374). The tagged tree was
never stamped. Stamp first, then delete and re-push the tag. never stamped. Assemble the section
([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), then
delete and re-push the tag.
> release '$VER' already exists — refusing to re-release, creating nothing.
[L375L390](.github/workflows/release.yml#L375-L390). A published release is
never replaced by the fallback. If it is correct, there is nothing to do; if
it is wrong, correct that published artifact deliberately before retrying.
### The re-arm refused ([release.yml](.github/workflows/release.yml#L276-L310))
The bump belongs to the merge door alone — the tag door deliberately does not
rewrite main ([L325L329](.github/workflows/release.yml#L325-L329)) — and it
runs *after* the tag, the notes and the publish. So a refusal here leaves a
real release standing behind a main that never re-armed — the release exists,
and main is left *armed to impersonate* it, still reading the version it just
shipped ([L275](.github/workflows/release.yml#L275)). That is the one failure
in this catalog whose remedy is a manual bump, not a re-run.
> version_next_dev: refusing '$ver' — expected bare X.Y.Z
[L86](lib/version.sh#L86): the version reaching the bump is not bare `X.Y.Z`.
Two senses of *bare* meet here, and the gap between them is the **rc release
path** — the way this refusal is actually reached, and designed behaviour
rather than a decide bug. decide calls a version bare when it is not `-dev`
([version_is_dev](lib/version.sh#L68-L76) matches that suffix and nothing
else), so row 6 admits a transition to `1.2.3-rc1`, and a labeled rc ceremony
is designed to ship ([lib/decide.sh](lib/decide.sh#L108-L110)).
`version_next_dev` means `^[0-9]+\.[0-9]+\.[0-9]+$`. An rc sits between the
two, and nothing filters it out on the way: the step's only gate is
`ceremony == 'yes'` and its `VER` is the tree's version verbatim. So an rc
ceremony tags, writes the notes, publishes — and *then* the re-arm refuses.
That is the machine correctly declining to guess rather than a bug: an rc's
next version "is a human decision, not arithmetic"
([L78L82](lib/version.sh#L78-L82)), so make the decision and bump main by
hand to it. A `-dev` version reaching this line is the same refusal's other
half, and *that* half is unreachable as the doors stand — rows 12 send `-dev`
to a no-op, and the tag door never bumps. A malformed version is not: nothing
upstream checks the shape ([version_read](lib/version.sh#L22-L33) checks only
that a version is present and non-empty), so `banana` rides row 6 exactly as
an rc does, and the same manual bump is the remedy. One note on work that has
not landed: #317 would make rc cuts native and their re-arm deterministic, and
if it lands only the malformed half still reaches this refusal.
> version_write: npm is required for version-source: package-json
[L106](lib/version.sh#L106): the package-json backend needs npm to write —
`npm pkg set version=` plus a lockfile-only `npm install`
([L114L115](lib/version.sh#L114-L115)), never `npm version`, which would tag
— and the runner has none. The read path fails the same way one step earlier
(`node is required…`, above), so a run reaching *this* message got past the
read — set up node/npm in the caller.
> version_write: unknown backend: $backend
[L118](lib/version.sh#L118): the write-side twin of `version_read: unknown
backend`, and unreachable for the same reason — `VERSION_SOURCE` was validated
before either was called. Fix the caller.
In every case the remedy has the same shape — bump `VERSION` (or the
`package.json` version field) by hand and push: `X.Y.(Z+1)-dev` where the
shipped version was bare, and where it was an rc, whatever you have decided
comes next. Note that a *push* refusal is not one of these — branch
protection is expected, and the step opens the bump PR itself rather than
failing ([L302L310](.github/workflows/release.yml#L302-L310)).
### Red main that is not the release workflow ### Red main that is not the release workflow
Consumer CI runs its guard steps on pushes to main too (this repo's Consumer CI runs its guard steps on pushes to main too (this repo's
[ci.yml](.github/workflows/ci.yml) does the same). The one guard red an [ci.yml](.github/workflows/ci.yml) does the same). The one guard red an
operator will actually meet on main is operator will actually meet on main is **changelog-armed after a re-arm was
**changelog-armed after a re-arm was forgotten**: the ceremony stamped forgotten — legacy mode only**: the ceremony stamped without putting
without putting `## Unreleased` back, the release's own `-dev` bump `## Unreleased` back, the release's own `-dev` bump landed, and the guard now
landed, and the guard now says (first line): says (first line):
> changelog-armed: the version is '$ver' (a development tree) but the top > changelog-armed: the version is '$ver' (a development tree) but the top
> section of $changelog is: … > section of $changelog is: …
The fix is a one-line PR: add an empty `## Unreleased` above the stamped The fix is a one-line PR: add an empty `## Unreleased` above the stamped
section. The full message section. The full message carries the same instruction. Fragment mode has no
([the script](actions/changelog-armed/changelog-armed.sh#L87-L101)) re-arm to forget, so it has no equivalent red on main — its refusals (a
carries the same instruction. missing marker, a surviving `## Unreleased`, a malformed or unconsumed
fragment) all fire on the PR that caused them, where the author is still
holding it.
## Design lineage ## Design lineage
The ceremony converged across box#83 → box#96, rig#32 → rig#47, and The ceremony converged across box#83 → box#96, rig#32 → rig#47 and cast#96 →
cast#96 → cast#111; this repo is those three implementations folded into cast#111; this repo is those three implementations folded into one, and the
one (the drift that motivated it is measured in drift that motivated it is measured in
[#1](https://github.com/heavy-duty/ceremony/issues/1)). The load-bearing [#1](https://github.com/heavy-duty/ceremony/issues/1), which also lists the
constraints — each bought with an incident, none of them safe to load-bearing constraints — each bought with an incident, none of them safe
"simplify" away — are listed in to "simplify" away. The label machine's own record is #10, #11 and #130; the
[#1](https://github.com/heavy-duty/ceremony/issues/1) and carried, with issue-flow queue's is #15, #16 and #73; the fragment changelog's is #112 and
their war stories, in the headers of the scripts they bind: #116; the sweep/trigger split is #209.
[release.yml](.github/workflows/release.yml#L1-L109),
[lib/decide.sh](lib/decide.sh#L1-L74), The narrative lives in those issues, by design: the war stories are carried
[lib/facts.sh](lib/facts.sh#L1-L24), and the three in the headers of the scripts they bind —
[guard scripts](actions/). The comments are the documentation of record; [release.yml](.github/workflows/release.yml),
this README is their operator-facing cut. [lib/decide.sh](lib/decide.sh), [lib/facts.sh](lib/facts.sh) and the
[guard scripts](actions/) — and those comments are the documentation of
record. This README is their operator-facing cut.

219
RELEASES.md Normal file
View file

@ -0,0 +1,219 @@
# Release management
This file describes the release-management pattern available to governed
repositories. Adoption is per repository and operator-ruled: a repository
without version epics is not out of compliance. A repo-local roadmap is the
map; each epic remains the source of truth for its own release. Where an older
repo-local description differs from this file, this file governs.
## The ladder
Represent each planned release with one version epic. The epic is the working
surface for that release: it states the goal, names the members, and records
the ordered waves as checklists. Keep the machine-readable progress checklist
under a heading matching `## Task list`, case-insensitively; the issue-flow
sweep reads task rows there until the next heading when it decides whether to
nudge triage about a completed epic. Other member or wave headings are not
completion inputs.
Keep a short repo-local roadmap beside the epics. The roadmap shows the whole
ladder and points to each working surface; it does not duplicate the live
member lists or ordering. crew's roadmap discussion [heavy-duty/crew#338](https://github.com/heavy-duty/crew/discussions/338)
maps the ladder whose `0.1.2` working surface moved from the crufty ledger
[heavy-duty/crew#162](https://github.com/heavy-duty/crew/issues/162) to
[heavy-duty/crew#346](https://github.com/heavy-duty/crew/issues/346).
## Gates
Each version epic declares `Blocked by <predecessor>`. Special ordering — a
double gate or an out-of-chain gate — is written explicitly on that epic;
there is no hidden global schedule. The epic carries `epic` and the
repository's release label, with no queue label. Its `Blocked by` line is a
declaration a human reads: shipping closes the predecessor, then triage opens
the next window by hand as the first step of release-init. The issue-flow
sweep does not promote version epics; automating that gate would require a
separately specified change to its queue-category model.
The gate orders windows, not their contents. Members enter a release only by
decision during release-init. The double gate on
[heavy-duty/crew#163](https://github.com/heavy-duty/crew/issues/163) and the
out-of-chain track on [heavy-duty/crew#348](https://github.com/heavy-duty/crew/issues/348)
are worked examples of exceptions declared where they apply.
## The membership record
A release issue's `Blocked by` line answers the predecessor gate above and
nothing else. Which issues are *in* the release is a separate record on the
same issue, and the sweep reads it by heading (#343):
- the heading is literally `## Members`, matched case-insensitively, tolerant
of any run of whitespace between the `##` and the word and of trailing
whitespace after it, and the record runs to the next heading — the same
shape `## Task list` already has;
- one member per list row, under any Markdown list marker and only those:
`-`, `*`, `+`, and 1 to 9 digits followed by `.` or `)` all open a row,
because a row is whatever a reader sees as one — and a tenth digit opens
nothing, CommonMark's ordered marker being at most nine digits, so
`1234567890. #412` is narration and enrols no member. Indentation is bounded
the same way: up to three spaces still open a row, four or more open nothing,
a leading tab counting as four. The record is **flat** — one member per
top-level row — and past that bound a line is not one: standing alone it is
an indented code block, and under a row it is a sub-bullet annotating that
member, and neither is a member itself. Below the bound it enrols, an
indented row being the same bytes as a top-level one. The member is the
row's first token after the list marker and an optional checkbox, and it is
a bare local `#<number>`: `- #253` and `- [ ] #253` both enrol #253.
Everything after that token is prose and contributes nothing, so a row is
free to cite the PR that closed it, a sibling repository, or an issue it
names as explicitly *not* a member;
- a row whose first token is anything else — a qualified `repo#N`, a number
with punctuation attached, or ordinary prose — contributes no member. The
parse stays silent rather than guessing;
- a qualified reference is never a member: a window is one repository's DAG,
decided against one board read;
- a row naming the release issue itself contributes no member. The sink is
never one of its own members;
- **there is no fallback to the gate.** A release issue with no members
section enumerates no membership, is not a standing window, and draws no
window flag. A repository whose epics predate this record gets silence,
never a false flag, until its next release-init writes one.
Why a heading and not a marker phrase: the `Blocked by` parse unions every
occurrence of its marker and runs each clause to a sentence terminator, which
is the right error direction for a `blocked` issue and the wrong one for a
release body that is mostly narration *about* its members. Why the first token
and not every reference in the row: a real member row cites merged PRs, other
repositories and explicit non-members, and reading the whole row enrols all of
them.
The cost is named rather than hidden: a version epic maintains two lists — the
`## Members` record and the `## Task list` progress view — and triage writes
both in the same flip. The purchase is that the progress view stays a progress
view, prose-rich and free to carry several issues in one row or to omit a
member that is not in the build queue, while membership is a machine record
with exactly one shape.
## Release-init
The predecessor closing and clearing the next epic's declared gate is the
trigger, and today triage must notice it and open that window by hand.
[heavy-duty/ceremony#253](https://github.com/heavy-duty/ceremony/issues/253)
tracks the not-yet-shipped sweep announcement of that duty; do not treat the
announcement as present until the consumer's pin carries it. Triage runs five
steps:
1. Mint the epic's “to mint when this arc opens” list together with findings,
deferred work, and discussion outcomes accumulated since the epic was
written. Each member initially declares `Blocked by <the epic>`.
2. Graph hard `Blocked by` edges and same-file clusters on the epic.
3. Write the waves into the epic body as checklists in claim order, with a
separate verification lane and the progress view under `## Task list`, and
write the window's membership under `## Members` — release-init is where
that record is first written, and until it exists no window stands.
4. Ask the operator to bless the order, then have triage open the first wave
by applying the flip mechanics below. The operator's blessing is the one
step this chain never automates.
5. Ship through the repository's cut process, close the epic, and treat that
close as the trigger for the next window.
heavy-duty/crew#346 is the worked wave plan; its graph made both hard edges
and shared-file contention visible before builders entered the queue. If init
finds no work worth minting, the operator either folds the empty window into a
later release or skips the version, recording that ruling on the epic before
closing it unshipped.
## One primary window, declared parallel tracks
Run one primary release window by default. A cut takes whatever has landed, so
interleaving unrelated windows blurs both the release story and the evidence
behind it. Gates open windows; they do not silently admit members, so builders
still see one deliberately ordered queue.
While a window stands — an open release-labeled issue whose membership record
holds at least one open member — its members form a DAG whose sink is the
release issue. Every member reaches that sink. Members declare only their
immediate predecessors; ordering edges live on members, while the sink records
membership only, in the record above and nowhere else; and the `ready` set is
exactly the graph's current sources. Every close
releases exactly its declared successors, and that whole set is concurrently
claimable: a member may have multiple successors, while the collision rule
already orders any that share a deliverable. Insertion re-points downstream
edges rather than merely appending membership at the sink. It follows that
every `ready` issue is a member. `epic` and `post-merge` issues are exempt
because neither is claimable (#292).
A member that lands `post-merge` releases nothing: that exemption is about
claimability, while a `post-merge` issue is still open and an open predecessor
holds its successors, so every successor declaring on it stays held and the
window stops advancing along that edge (#329).
**When a member reaches `post-merge` and any open declaration names it, triage
splits the remainder**: mint a fresh issue carrying the outstanding criteria
verbatim, naming its owner and its wake condition and citing the original, then
close the original on what it delivered. Triage owns this because only triage
mints work issues and `post-merge` is its completion queue (#329).
**The release edge is the original's close, never the remainder's.** Each
successor's declaration names the original's number, so closing the new issue
releases nothing (#329).
**Split only when an open declaration names the issue.** The trigger is a check
rather than a judgement — run the blocker parse over every open `blocked` body
and see whether this number appears — because an issue that strands nothing is
`post-merge` working as intended (#329).
**Never close work out from under a builder.** Where the original is assigned,
`claimed`, or carrying an open PR, amend its body to hand the outstanding
criteria to the new issue and let its holder close it, so the release edge above
is reached without taking the work from them (#329).
**Do not instead teach the blocker parse that `post-merge` counts as landed.**
That promotes a successor while its predecessor still owes acceptance criteria,
inverts a parser whose deliberate error direction is to hold or flag a reference
it cannot read rather than release it, and needs label data a reference-state
lookup does not carry (#329).
The operator may declare a parallel track at init when its footprint is
disjoint from the primary window: another repository, another artifact, or
provably non-overlapping clusters. The declaration names the boundary and any
bridge work that must rejoin the primary. [heavy-duty/crew#348](https://github.com/heavy-duty/crew/issues/348)
is the worked example: its app and artifact form a parallel track while its
small crew-side bridge remains in the primary window.
## Flip mechanics
To admit a member, delete or rewrite its literal, parseable
`Blocked by <the epic>` declaration and swap `blocked` to `ready` in the same
edit. Markdown or HTML strikethrough is insufficient: the blocker parser reads
the raw marker text and still returns the reference. Never preserve history by
negating the marker phrase — the parser unions declarations even when prose
says they no longer apply. Preserve the history only after rewriting the
marker into non-parseable prose, then verify that the parser returns an empty
set for the release gate.
**The same flip adds the member's row to the release issue's membership
record.** That write is not bookkeeping to catch up on later: the record is
the only thing that makes the window stand, so a member flipped `ready`
without a row is, to the sweep, an unblocked non-member — the exact state the
window flag exists to report. Verify the flip by reading the record back and
finding the new member's row in it (#343).
Release membership is a decision, never a sweep default. Triage performs each
flip only after the operator blesses the wave; the issue-flow sweep may resolve
ordinary issue dependencies, but it does not choose a release's contents.
heavy-duty/crew#346 records the member-by-member flip that opened its first
wave.
## The ledger pattern
When a release epic has become too crufty to remain a clear working surface,
create a replacement and treat the old epic as a ledger. Do not close the old
epic until every live member declaration points at the replacement and the
blocker parser verifies the new set. Closing early can release every member
that still names the old issue.
The [heavy-duty/crew#162](https://github.com/heavy-duty/crew/issues/162) to
[heavy-duty/crew#346](https://github.com/heavy-duty/crew/issues/346)
transition is the worked example: all member declarations were re-pointed and
parse-verified before #162 closed; #162 remains the historical record while
#346 is the release's working surface.

View file

@ -16,6 +16,11 @@ The machine reads only your **verdict**; humans read your reasons.
their discretion. Anything blocking — including a question whose answer their discretion. Anything blocking — including a question whose answer
gates your approval — is **request changes**, saying exactly what gates your approval — is **request changes**, saying exactly what
unblocks it. unblocks it.
- **Name what you could not verify, in the verdict body.** Say which checks
you could not run and why, and what you relied on instead: CI, reading, or
a narrower probe. An unstated environment gap reads as coverage — exactly
the blind spot Kimi's [crew report](https://github.com/heavy-duty/crew/blob/main/kimi-bot-andresmgsl/assessment.md)
identified for boxes without `node` or `shellcheck`.
- An approval you would not defend to the human is a defect. You are not - An approval you would not defend to the human is a defect. You are not
being asked to be agreeable; you are being asked to be right. being asked to be agreeable; you are being asked to be right.
@ -23,14 +28,45 @@ The machine reads only your **verdict**; humans read your reasons.
In order of authority: In order of authority:
1. **The issue's acceptance criteria** — the PR's `Closes #N` names your 1. **The issue's acceptance criteria** — the PR's `Closes #N`, its
spec. Check every criterion; a PR that ships less than the issue says is cross-repo `Part of <owner>/<repo>#N`, or its `Refs #N` when the issue
a request-changes even if the code is beautiful. body marks a criterion post-merge, names your spec. That last shape is
not a defect: the issue directs it, triage owns that close, and a
request-changes on the "missing" keyword enforces the bug the shape
exists to fix — `Closes #137` closed its issue with a post-merge
criterion unmet (#151). For a `Refs #N` body, also verify that no closing
keyword immediately precedes `#N` anywhere in the body, even in prose
explaining the hand close or inside a code span: GitHub used those exact
shapes to close #209, #212 and #199 (#200, #218). The safe forms put the
number first (`#N is closed by hand`) or omit it (`triage closes the issue
by hand`). Check every
criterion; a PR that ships less than the issue says is a request-changes
even if the code is beautiful.
2. **The repo's load-bearing constraints** — the rules bought with 2. **The repo's load-bearing constraints** — the rules bought with
incidents (in ceremony itself: issue #1's constraint list; in a governed incidents (in ceremony itself: issue #1's constraint list; in a governed
repo: its own CONTRIBUTING plus ceremony's README). A change that repo: its own CONTRIBUTING plus ceremony's README). A change that
"simplifies away" a constraint gets request-changes with a link to the "simplifies away" a constraint gets request-changes with a link to the
incident that made the rule. incident that made the rule.
- **Verify a pinned consumer at its pin, not ceremony's `main`.** Every
option, trigger, config key, and unmarked documentation claim must exist
at that ref; run the pinned tool against the proposed config or read the
tagged file. On [box#164](https://github.com/heavy-duty/box/pull/164),
`0.1.0`'s `load_config` rejected `triage-actors=...` with
`malformed label row` and `exit=1`. CI green on a conversion PR proves
nothing about the new config: the base branch's workflow is what ran.
- **Third-party actions never hold a write-capable token by default.** In
any job whose token is write-capable (`packages: write`,
`contents: write`, `id-token: write`, or one carrying deploy secrets),
the default is a repo-owned script a test can drive. A third-party
action may hold that token only if it comes from an **established
publisher** — a real organization with maintenance history and more
than one maintainer, not a memberless shell or a lone account shipping
an unauditable `dist/` blob — and is **pinned by full commit SHA**. An
action matching the incubator red-flag profile never holds a write
token, however well it works. Read-only jobs: ordinary dependency
judgement, SHA-pinning still required. This is bot-run infrastructure —
no human watches runtime logs, so a compromised action's window is
unbounded (incubator#53/#54; #216).
3. **The code itself** — correctness first, then tests (does the test plan's 3. **The code itself** — correctness first, then tests (does the test plan's
floor exist? do the failure cases actually fail?), then conventions. floor exist? do the failure cases actually fail?), then conventions.
Changelog line present for behavior changes; comments carry why, not Changelog line present for behavior changes; comments carry why, not
@ -40,12 +76,56 @@ In order of authority:
test settles what a comment thread can't. A review that says "I ran X and test settles what a comment thread can't. A review that says "I ran X and
saw Y" outranks one that says "this looks like it might". saw Y" outranks one that says "this looks like it might".
## Where you review
- **A review request on you is your authorization** in any `heavy-duty` repo
and on any fleet member's fork. You need no separate permission and do not
wait for the repo to appear on a list: review is reversible
read-plus-comment work, and the requester already decided it should happen.
- **A request is authorization, not panel membership.** Convergence is
measured against the target repo's `panel[<author>]=` line if its
`labels.conf` defines one for the PR author, else its `panel=` line; minus
the author in either case (#224). If you
are requested off-panel, post the verdict anyway and say in its body that
it is advisory; neither your silence nor your request-changes is a gate the
reconciler enforces. The nine-hour wait for kimi's off-panel verdict on
rig#112 showed why authorization and membership must not be conflated.
- **Being requested is a wake condition of its own.** It is how work in a
repo you have never heard of reaches you; a repo list finds only work in
repos somebody thought to list.
## How you work the queue
- **Your queue is the API, not the search index.** Enumerate
`requested_reviewers` from the pulls API, your reviews from
`pulls/N/reviews`, and comments from `issues/N/comments`. Search lag left
cast#143, incubator#25, and box#164 waiting, as Claude's
[crew report](https://github.com/heavy-duty/crew/blob/main/claude-bot-andresmgsl/assessment.md)
records: search is only a backstop that adds candidates, never evidence of
no duty. `requested_reviewers` self-clears when you submit, so the endpoint
shows what you owe now.
- **Every write is one-shot, keyed to (you, PR, head SHA).** Put a fresh
read and verify immediately around the mutation; a session-start check is
insufficient. If verification says it landed, stop even when the CLI
looked unhappy. This binds the `🔎` announce as much as the verdict:
deduplicate all discovery paths before acting. Duplicate verdicts on
[#26](https://github.com/heavy-duty/ceremony/pull/26),
[#29](https://github.com/heavy-duty/ceremony/pull/29), and
[#39](https://github.com/heavy-duty/ceremony/pull/39), and duplicate
announces on [#32](https://github.com/heavy-duty/ceremony/pull/32), bought
the rule; do not answer a double-post with a third comment.
- **Review each head in a throwaway checkout; keep the main clone clean.**
Use a detached worktree per PR head and remove it after the verdict. A
crashed build corrupted Claude's build clone in 2026-07-22
([crew report](https://github.com/heavy-duty/crew/blob/main/claude-bot-andresmgsl/knowledge.md));
running another tree in the clone you keep risks the whole box.
## What you do not do ## What you do not do
- **Re-litigate the spec.** The issue's decisions were made in triage and, - **Re-litigate the spec.** The issue's decisions were made in triage and,
above it, in a discussion where humans had their say. If you think the above it, in a proposal where humans had their say. If you think the
spec itself is wrong, say so with reasons — as a comment pointing at the spec itself is wrong, say so with reasons — as a comment pointing at the
discussion, while still reviewing the implementation against the spec as proposal, while still reviewing the implementation against the spec as
written. Spec changes go through triage, not through a review round. written. Spec changes go through triage, not through a review round.
- **Merge, or tell the builder to merge.** Convergence hands the PR to a - **Merge, or tell the builder to merge.** Convergence hands the PR to a
human; only humans merge. human; only humans merge.
@ -61,10 +141,24 @@ saw Y" outranks one that says "this looks like it might".
- The builder answers rounds whole and re-requests you; until re-requested, - The builder answers rounds whole and re-requests you; until re-requested,
the ball is not yours (`state:addressing` is the builder working — pile-on the ball is not yours (`state:addressing` is the builder working — pile-on
reviews mid-address just churn the target). reviews mid-address just churn the target).
- A **draft carrying `state:addressing` is a fix round in progress**, not
abandonment: an engine may convert a PR back to draft at round close so the
builder's mid-round saves stop firing CI, and the flip back to ready is the
builder's own act announcing the round is answered
([BUILDER.md](BUILDER.md#the-review-round)).
- Convergence = every panel verdict approves the current head, no - Convergence = every panel verdict approves the current head, no
`blocker:*` standing. Then the builder hands off (`state:needs-human`) and `blocker:*` standing. Then the builder hands off (`state:needs-human`) and
the panel's job is done. the panel's job is done.
- If a round exposes a disagreement **within the panel**, argue it in the PR - Flag an unowned decision when it belongs to a human: org policy, published
with evidence until one side concedes or the builder escalates to the artifacts, secrets, prod, or any choice whose cost lands outside the PR. A
maintainer for a ruling. Two reviewers pulling a builder in opposite disagreement within the panel is one instance, not the definition
directions without resolution is a panel failure, not a builder failure. ([#50 D11](https://github.com/heavy-duty/ceremony/issues/50)). Argue a
panel disagreement in the PR with evidence until one side concedes or the
builder escalates; two reviewers pulling a builder in opposite directions
without resolution is a panel failure, not a builder failure.
`needs-ruling` is set by the **builder**, never by you: one accountable
flag-setter per PR hands the human one consolidated question. State the
unowned decision precisely enough for the builder to write
[the canonical ruling ask](BUILDER.md#the-ruling-ask), including what
stops and what continues ([#50 D12](https://github.com/heavy-duty/ceremony/issues/50);
[LABELS.md](LABELS.md)).

134
TRIAGE.md
View file

@ -1,44 +1,70 @@
# TRIAGE.md — the triage role # TRIAGE.md — the triage role
You are the only door issues come through. Humans and agents open You are the only door work issues come through. Humans and agents file
**discussions**; you decide what becomes work. The quality of every **proposals**; you decide what becomes work and set the quality builders and
downstream stage — a builder succeeding without asking, a reviewer having a reviewers receive.
spec to review against — is set here, by you, and nowhere else.
## Why this door exists ## Why this door exists
Discussions are allowed to be ambiguous; issues are not. An issue is a work Proposals may be ambiguous; work issues may not: a builder must be able to
order a builder must be able to execute **without asking anyone anything**. execute one **without asking anything**. One accountable role keeps builders
Keeping one accountable role between the two is what keeps the bar from from guessing.
eroding — the moment anyone can mint an issue, the backlog fills with
"improve X" entries nobody can build, and builders start guessing. Guessing
is the failure this whole flow exists to prevent.
## Your inputs ## Your inputs
- **Every open discussion** in the repo you serve. - **Every open proposal** in the repo you serve.
- **Stray issues** — anything filed directly, by anyone. Label it - **Stray issues** — anything filed outside the proposal form by a non-triage
actor. Label it
`needs-triage`, then either bring it up to contract (below) or convert its `needs-triage`, then either bring it up to contract (below) or convert its
substance back into a discussion and close it, saying why. Do not shame the substance into a proposal and close it, saying why. Route the work
filer; do route the work correctly. without shaming the filer.
## For each discussion, converge on exactly one outcome ## For each proposal, converge on exactly one outcome
1. **Answer.** The question has an answer, the bug is not one, the idea is 1. **Answer.** The question has an answer, the bug is not one, the idea is
already shipped or already tracked. Reply with the answer (link the code, already shipped or tracked. Link the code, doc, or issue; mark answered.
the doc, the existing issue), mark answered.
2. **Ask.** Real work is hiding behind ambiguity you cannot resolve from the 2. **Ask.** Real work is hiding behind ambiguity you cannot resolve from the
repo, its history, or its docs. Ask the 23 pointed questions whose repo, its history, or its docs. Ask the 23 pointed questions whose
answers would let you write the issue — then stop and wait. Do not mint an answers would let you write the issue — then stop and wait. Do not mint an
issue that carries the ambiguity forward; that just moves your job onto issue that carries the ambiguity forward; that just moves your job onto
the builder. the builder.
3. **Escalate.** The blocker is a *decision* only a human owns — scope, 3. **Escalate.** The pending thing is a decision only a human owns — org
money, product direction, breaking a public contract. Say precisely what policy, published artifacts, secrets, prod, or any choice whose cost lands
the decision is, list the options with your recommendation, and name the outside the work. A panel deadlock is one instance, not the definition
decider. The discussion is where humans decide; wait there. (#50 D11). Say precisely what the decision is, name the decider, and use
[BUILDER.md's canonical ruling template](BUILDER.md#the-ruling-ask),
including its options, recommendation, blocked/continues statement, and
reversible-only default rules (#50 D12D13).
The proposal is where humans decide; wait there. When the decision
blocks something already on the board — an existing issue, or minted work
a proposal's ruling gates — set `needs-ruling` on it too, so the board
shows where the human's turn is; the issue keeps its queue label.
When you direct a builder to hold a claim, say the claim is **parked**,
name what it waits on, and set `attention` so the assignee's ack is visible
on the board — the directive and the builder's doctrine
([BUILDER.md](BUILDER.md#claiming)) must use one word.
Immediately before asserting label-borne state in prose — a hold, a
claim, a queue state, whether in a comment, a body header, or a
`needs-ruling` ask — re-read that issue's **label events**
(`gh api /repos/{owner}/{repo}/issues/{n}/timeline`), not just its
comments: the answer often arrives as a label with no comment, and a
write that re-read only the thread races it (#149, #151).
Past 24 hours from the current episode's `labeled` event, if the ruling
still stands and doubt remains, it is triage's duty to pick the option the
builder proceeds on, record that pick as a decision, and stay accountable
for it; the operator may overturn it at merge (#50 D13D14). You set the
flag, so you also close it out ([LABELS.md](LABELS.md)): judge when
agreement is reached, record the ruling as a decision in one comment,
remove the label, and return the issue to its flow in that same comment;
when that ruling or any directive or answered builder question delivers
the assignee's next move in prose, set `attention` in the same comment on
the assigned issue that owns the claim — never on the pull request, even
when the comment lives there. Flagging an unassigned issue is a board bug,
not a demand; repair the board rather than setting `attention`.
This is not a substitute for minting work or for `needs-ruling`.
4. **Decline.** Real idea, wrong repo or wrong time. Say why plainly, link 4. **Decline.** Real idea, wrong repo or wrong time. Say why plainly, link
where it belongs if anywhere, close. A refusal with reasons is a good where it belongs if anywhere, close. A refusal with reasons is a good
outcome; a zombie discussion is not. outcome; a zombie proposal is not.
5. **Accept.** It justifies work → mint the issue(s). The contract below is 5. **Accept.** It justifies work → mint the issue(s). The contract below is
the bar. the bar.
@ -48,7 +74,7 @@ Every issue you mint carries, in this order:
- **A title that names the deliverable** — "lib/version.sh — one version - **A title that names the deliverable** — "lib/version.sh — one version
abstraction, two backends", never "improve version handling". abstraction, two backends", never "improve version handling".
- **Context**: why this exists, with links — the discussion it came from, - **Context**: why this exists, with links — the proposal it came from,
the code it touches (permalinks at a pinned SHA, so line references cannot the code it touches (permalinks at a pinned SHA, so line references cannot
rot), prior art in sibling repos. rot), prior art in sibling repos.
- **The spec**: decisions made, not options listed. If the spec still has an - **The spec**: decisions made, not options listed. If the spec still has an
@ -56,9 +82,38 @@ Every issue you mint carries, in this order:
- **Tasks**: the steps, checkboxed, in order. - **Tasks**: the steps, checkboxed, in order.
- **Acceptance criteria**: checkboxed, verifiable, and honest — these become - **Acceptance criteria**: checkboxed, verifiable, and honest — these become
the builder's definition of done and the reviewer's review spec, verbatim. the builder's definition of done and the reviewer's review spec, verbatim.
A criterion that can only be checked after the merge must carry its own
mechanism, in the criterion itself: that it is post-merge, that triage
owns the close, and that the PR references the issue with `Refs #N`
rather than `Closes #N`; relying on somebody to reopen the issue is an
incomplete criterion (#151). The merge moves the issue to `post-merge` and
releases the claim. The sweep writes the transition comment when it derives
the move; on a hand move, triage writes the comment in the same tick. In
either case triage follows up with the remaining criteria, their owner, and
the wake condition for completion.
- **Test plan**: what proves it, including the cases that must fail. - **Test plan**: what proves it, including the cases that must fail.
- **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an - **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an
epic organizes it. epic organizes it. Name a cross-repo dependency the same way with its
repository qualified (`Blocked by repo#N` or `owner/repo#N`); the sweep
cannot resolve it, so triage verifies it and flips the issue by hand.
When a deliverable is already carried by an open `ready`, `claimed`, or
`blocked` issue, the newer issue must declare an unconditional collision
edge with `Blocked by #N`, naming the newest open carrier; there is no
alternative for disjoint regions. This keeps every `ready` issue
concurrently claimable and makes each close release one successor (#288).
During a standing release window, every mint also gets a binary membership
call in the same tick. A non-member names the release issue as its blocker
in its own Dependencies. A member is placed with three writes: the new issue
names its immediate member predecessors; every member whose immediate
predecessor the new issue becomes adds or re-points its dependency to the
new issue, dropping any predecessor the new issue now reaches (inserting X
into A → B makes A → X → B, so B drops A); a member that must land after the
new issue but already reaches it through another member declares nothing
new; and the release issue adds a row for the new issue to its membership
record, which records membership only and is the only place the sweep reads
it — a release issue's `Blocked by` line answers its predecessor gate and
never its membership (#292, #343). Collision and window edges are
independent, so write both when both apply.
- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and - **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and
exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)). exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)).
@ -70,21 +125,36 @@ expected.
## Multi-issue work ## Multi-issue work
When an acceptance produces more than one issue, mint an **epic** (`epic` When an acceptance produces more than one issue, mint an **epic** (`epic`
label): the approach, the decisions, the constraint list, and a label) with the approach, decisions, constraints, and a dependency-ordered
dependency-ordered task list of child issues. Children reference the epic; child checklist. Children reference the epic; that checklist is the progress
the epic's checklist is the progress view. Builders never pick the epic view. For every epic, put it under a heading
literally `## Task list`, matched case-insensitively with nothing but optional
trailing whitespace; any other heading is invisible to the sweep and draws
neither a warning nor a completion nudge (#266). Builders never pick the epic
itself. Keep the checklist current — a stale epic misleads every scan. itself. Keep the checklist current — a stale epic misleads every scan.
Repositories that adopt version epics follow [RELEASES.md](RELEASES.md).
## Backlog hygiene (yours until #18 automates it) ## Backlog hygiene
- **Dedup before minting** — search issues *and* closed issues; extend or - **Dedup before minting** — search issues *and* closed issues; extend or
reopen before duplicating. reopen before duplicating.
- **Flip `blocked` → `ready`** when the named dependency lands. - The issue-flow sweep flips `blocked``ready` when every named dependency
- **Reclaim abandoned claims**: `claimed` + no open PR + no activity → lands, and flags a blocked issue whose dependency declaration is unreadable.
comment, unassign, restore `ready`. - The sweep reclaims abandoned claims after 48 hours: `claimed` + no open PR
+ no activity → comment, unassign, restore `ready`.
- `post-merge` is triage's completion queue, not a parked claim. Tick verified
criteria and close under the criterion's existing contract. If corrective
build work becomes necessary, move it to `ready` or mint a fresh `ready`
issue: any builder claims from current `main`, the original builder has no
special standing, and re-entry does not set `attention`.
- Automation never guesses intent. Resolve the conflict comments it leaves on
malformed queue states, and close or extend completed epics when nudged.
- **Close obsolete issues** with the reason and a link to what obsoleted - **Close obsolete issues** with the reason and a link to what obsoleted
them. Every label on every open issue stays true; the board is only worth them. Every label on every open issue stays true; the board is only worth
scanning if it does not lie. scanning if it does not lie.
- **A lifted hold makes its body prose stale in the same instant, and the
body is yours.** When a hold lifts, correct the body header that described
it in the same tick — do not leave it to the builder or next reader (#149).
## What you never do ## What you never do
@ -92,4 +162,4 @@ itself. Keep the checklist current — a stale epic misleads every scan.
- Assign a builder — builders pick and claim ([BUILDER.md](BUILDER.md)). - Assign a builder — builders pick and claim ([BUILDER.md](BUILDER.md)).
- Make the human's decisions (outcome 3 exists for those), or soften a - Make the human's decisions (outcome 3 exists for those), or soften a
refusal into a vague issue to avoid saying no. refusal into a vague issue to avoid saying no.
- Mint an issue to "discuss" something — that is a discussion. - Mint a work issue to explore an idea — file a proposal instead.

View file

@ -1 +1 @@
0.1.0 0.6.4-dev

View file

@ -13,6 +13,10 @@ inputs:
description: Path to the changelog, relative to the workspace description: Path to the changelog, relative to the workspace
required: false required: false
default: CHANGELOG.md default: CHANGELOG.md
fragments-dir:
description: Fragment directory whose presence selects fragment mode
required: false
default: changelog.d
runs: runs:
using: composite using: composite
steps: steps:
@ -21,4 +25,5 @@ runs:
env: env:
CHANGELOG: ${{ inputs.changelog }} CHANGELOG: ${{ inputs.changelog }}
VERSION_SOURCE: ${{ inputs.version-source }} VERSION_SOURCE: ${{ inputs.version-source }}
FRAGMENTS_DIR: ${{ inputs.fragments-dir }}
run: bash "$GITHUB_ACTION_PATH/changelog-armed.sh" run: bash "$GITHUB_ACTION_PATH/changelog-armed.sh"

View file

@ -1,9 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# changelog-armed.sh [<changelog>] [<version-source>] — assert that the # changelog-armed.sh [<changelog>] [<version-source>] [<fragments-dir>] —
# changelog is ARMED: that there is a heading for the next PR's entry to # assert that the changelog is ARMED: that there is a place for the next PR's
# land under, and that it is the right one for the state this tree is in. # entry to land, and that it is the right one for the state this tree is in.
# #
# Ported from box .github/scripts/changelog-armed.sh (box#108, confirmed # Ported from box .github/scripts/changelog-armed.sh (box#108, confirmed
# cross-repo as rig#66) — box is the only repo that carries this guard # cross-repo as rig#66) — box is the only repo that carries this guard
@ -15,6 +15,14 @@ set -euo pipefail
# tempted to simplify this back to the unconditional form should read # tempted to simplify this back to the unconditional form should read
# those two reverts first. # those two reverts first.
# #
# Fragment mode makes the directory itself the arming (#115): every PR gets
# its own issue-named file, so the box#108 clean-mismerge cannot happen because
# there is no shared heading to disappear under an open PR. The guard instead
# proves that the marker exists, Unreleased is gone, and every fragment is
# publishable before its author lets go of the PR. A bare release has no
# re-armed shape in this mode — there is nothing to re-arm — so it must have
# consumed every fragment and stamped its exact publishable section.
#
# The failure it exists to catch (box#108, rig#66) leaves no trace: the # The failure it exists to catch (box#108, rig#66) leaves no trace: the
# ceremony PR stamps '## Unreleased' into '## X.Y.Z — DATE' by hand, and # ceremony PR stamps '## Unreleased' into '## X.Y.Z — DATE' by hand, and
# nothing puts the heading back. A PR authored BEFORE the release wrote its # nothing puts the heading back. A PR authored BEFORE the release wrote its
@ -47,6 +55,7 @@ set -euo pipefail
changelog="${1:-${CHANGELOG:-CHANGELOG.md}}" changelog="${1:-${CHANGELOG:-CHANGELOG.md}}"
version_source="${2:-${VERSION_SOURCE:-file}}" version_source="${2:-${VERSION_SOURCE:-file}}"
fragments_dir="${3:-${FRAGMENTS_DIR:-changelog.d}}"
# The shared libs travel with this action: a consumer's # The shared libs travel with this action: a consumer's
# `uses: heavy-duty/ceremony/actions/changelog-armed@<tag>` downloads this # `uses: heavy-duty/ceremony/actions/changelog-armed@<tag>` downloads this
@ -67,6 +76,73 @@ ver="$(version_read "$version_source")" || {
exit 1 exit 1
} }
if [ -d "$fragments_dir" ]; then
[ -f "$fragments_dir/README.md" ] || {
echo "changelog-armed: fragment mode requires the generated marker '$fragments_dir/README.md' — restore it so the empty directory remains tracked" >&2
exit 1
}
if awk '$1 == "##" && $2 == "Unreleased" { found = 1 } END { exit !found }' "$changelog"; then
echo "changelog-armed: a '## Unreleased' section survived the adoption — move its entries into '$fragments_dir/<issue>.md' and delete the heading" >&2
exit 1
fi
fragments="$(changelog_fragments "$fragments_dir")"
while IFS= read -r fragment; do
[ -n "$fragment" ] || continue
if ! diagnosis="$(changelog_fragment_problem "$fragment")"; then
printf 'changelog-armed: %s\n' "$diagnosis" >&2
exit 1
fi
done <<<"$fragments"
if ! diagnosis="$(changelog_shape_problem "$changelog" "$fragments_dir")"; then
printf 'changelog-armed: %s\n' "$diagnosis" >&2
exit 1
fi
if version_is_dev "$ver"; then
echo "changelog-armed: version '$ver' agrees with fragment mode ($fragments_dir)"
exit 0
fi
if [ -n "$fragments" ]; then
surviving="$(printf '%s\n' "$fragments" | awk '
BEGIN { separator = "" }
{ printf "%s%s", separator, $0; separator = ", " }
')"
echo "changelog-armed: these fragments were not consumed: $surviving — re-run 'changelog-assemble $ver'" >&2
exit 1
fi
top="$(grep -m1 '^## ' "$changelog" || true)"
[ -n "$top" ] || {
echo "changelog-armed: $changelog has no '## ' section at all — the release stamp for '$ver' is missing" >&2
exit 1
}
if ! diagnosis="$(changelog_section_problem "$changelog" "$ver")"; then
printf "changelog-armed: the stamped section for '%s' is not publishable: %s\n" \
"$ver" "$diagnosis" >&2
exit 1
fi
top_ver="$(printf '%s\n' "$top" | awk '{ print $2 }')"
if [ "$top_ver" != "$ver" ]; then
cat >&2 <<EOF
changelog-armed: the version is '$ver' but the top section of $changelog is:
$top
Fragment mode has no re-arm step. A bare version means this tree is a
release, so the top section must be the stamped section for '$ver' itself.
A different version means the ceremony stamped the wrong number.
EOF
exit 1
fi
echo "changelog-armed: version '$ver' agrees with fragment mode ($fragments_dir)"
exit 0
fi
# The TOP section: the first '## ' heading in the file. Everything above it is # The TOP section: the first '## ' heading in the file. Everything above it is
# the changelog's own preamble and belongs to no section. # the changelog's own preamble and belongs to no section.
top="$(grep -m1 '^## ' "$changelog" || true)" top="$(grep -m1 '^## ' "$changelog" || true)"
@ -134,7 +210,7 @@ EOF
# the very extractor the publisher uses — changelog_section (#4) — so the # the very extractor the publisher uses — changelog_section (#4) — so the
# guard and the publisher cannot disagree about what a section is or when # guard and the publisher cannot disagree about what a section is or when
# one counts as empty (rig#67). # one counts as empty (rig#67).
if [ -z "$(changelog_section "$changelog" "$ver")" ]; then if ! diagnosis="$(changelog_section_problem "$changelog" "$ver")"; then
cat >&2 <<EOF cat >&2 <<EOF
changelog-armed: the version is '$ver' but $changelog has no non-empty changelog-armed: the version is '$ver' but $changelog has no non-empty
section for '$ver'. The top section is: section for '$ver'. The top section is:
@ -150,6 +226,8 @@ changelog-armed: the version is '$ver' but $changelog has no non-empty
The fix is the ceremony's first edit: stamp '## Unreleased' into The fix is the ceremony's first edit: stamp '## Unreleased' into
'## $ver — DATE', then put an empty '## Unreleased' back above it. '## $ver — DATE', then put an empty '## Unreleased' back above it.
$diagnosis
EOF EOF
exit 1 exit 1
fi fi

View file

@ -0,0 +1,53 @@
name: Changelog assembled
description: >-
Assert a release PR's stamped section is exactly what the changelog.d/
fragments it consumed assemble to — the fragments as of the MERGE BASE
are replayed through the assembler's --check and compared byte-for-byte
against the section on HEAD (#116; the fragment flow is #112). Needs the
HISTORY: the caller's checkout must use fetch-depth: 0, or the base ref
will not resolve and strict mode (the default — CI must never skip)
fails red with a message naming that fix. Inapplicable trees — a -dev
tree, a legacy repo with no fragments directory at the merge base — pass
with a NOTICE, never a silent skip.
inputs:
base-ref:
description: >-
The ref the fragment set is read relative to. The default resolves
the event's base branch — the PR's target on pull_request, the
pushed branch itself on push (where the merge base IS HEAD and the
check is vacuous by construction, named honestly in the log).
required: false
default: origin/${{ github.base_ref || github.ref_name }}
changelog:
description: Path to the changelog, relative to the workspace
required: false
default: CHANGELOG.md
fragments-dir:
description: Path to the fragments directory, relative to the workspace
required: false
default: changelog.d
version-source:
description: Where the tree's version lives ("file" or "package-json")
required: false
default: file
strict:
description: >-
"1" (the default) makes an unresolvable base ref a hard failure
instead of a loud skip — a guard that can quietly stop guarding is
the failure shape this family of checks exists to refuse. The local
default in the script itself stays "0", so a plain working-copy run
degrades sensibly.
required: false
default: "1"
runs:
using: composite
steps:
- name: changelog assembled
shell: bash
env:
CHANGELOG_ASSEMBLED_BASE: ${{ inputs.base-ref }}
CHANGELOG: ${{ inputs.changelog }}
CHANGELOG_ASSEMBLED_DIR: ${{ inputs.fragments-dir }}
VERSION_SOURCE: ${{ inputs.version-source }}
CHANGELOG_ASSEMBLED_STRICT: ${{ inputs.strict }}
run: bash "$GITHUB_ACTION_PATH/changelog-assembled.sh"

View file

@ -0,0 +1,288 @@
#!/usr/bin/env bash
set -euo pipefail
# changelog-assembled.sh [<base-ref>] [<changelog>] [<fragments-dir>] [<version-source>]
# — assert that a release PR's stamped section is EXACTLY what the fragments
# it consumed assemble to: read the fragments as of the MERGE BASE (they are
# gone from HEAD's tree — that is the point of the ceremony), replay the
# assembler's --check over that set, and compare byte-for-byte against
# changelog_section on HEAD (#116; the fragment flow is #112).
#
# The failure it exists to catch leaves no trace — the shape every guard in
# this family was bought by. bin/changelog-assemble is run BY HAND in the
# release PR, deliberately: the assembled section must land in the PR diff
# where the panel reads it (#114). Drop one fragment from the deletion and
# its entry is simply absent from the release: the file is well-formed,
# changelog-armed is green (the section exists and has prose),
# changelog-monotonic is green (no heading was deleted), and the publisher
# happily publishes the shortened section. Hand-edit one word of the
# assembled prose and the published history quietly stops being what the
# authors wrote. The only way anyone finds out is by reading the release
# body against a directory that no longer exists.
#
# Why this cannot live in changelog-armed: "the section matches the
# fragments it consumed" is not a property of a TREE — no single tree holds
# both the fragments and the section they became. It is a property of a
# DIFF: what existed at the merge base versus what HEAD stamped. That is
# exactly the argument changelog-monotonic made for being its own git-aware
# action rather than a clause inside changelog-armed, and this is the third
# guard on the same reasoning. changelog-armed.sh stays drivable against
# constructed two-file trees that are not git repos at all.
#
# The date is never compared as prose: --check prints the section BODY with
# no '## ' heading, and changelog_section extracts the body below HEAD's
# heading — so the date HEAD stamped into its heading never enters the
# comparison, and a date difference can never masquerade as a prose one.
#
# This guard narrows, but cannot close, the target-movement window: it sees a
# fragment present when CI reads the target ref, but one can still land after
# the final run and before merge. Requiring release PRs to be up to date with
# their target branch before merge is the repository setting that closes that
# residual window (#253).
base_ref="${1:-${CHANGELOG_ASSEMBLED_BASE:-origin/main}}"
changelog="${2:-${CHANGELOG:-CHANGELOG.md}}"
dir="${3:-${CHANGELOG_ASSEMBLED_DIR:-changelog.d}}"
version_source="${4:-${VERSION_SOURCE:-file}}"
# Fail-closed switch, changelog-monotonic's stance exactly: CI sets it (the
# action defaults strict to "1"), so a degradation that is sensible on a
# laptop becomes a red run there. A guard that can quietly stop guarding is
# the failure shape this whole family of checks exists to refuse.
strict="${CHANGELOG_ASSEMBLED_STRICT:-0}"
# The shared libs and the assembler travel with this action: a consumer's
# `uses: heavy-duty/ceremony/actions/changelog-assembled@<tag>` downloads
# this whole repository at that ref, so ../../lib and ../../bin are always
# present and always at the same ref — no checkout step, no version skew.
here="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/version.sh
. "$here/../../lib/version.sh"
# shellcheck source=lib/changelog.sh
. "$here/../../lib/changelog.sh"
assemble="$here/../../bin/changelog-assemble"
skip() {
if [ "$strict" = "1" ]; then
echo "changelog-assembled: $* — and CHANGELOG_ASSEMBLED_STRICT=1, so this is a FAILURE, not a skip." >&2
echo " CI sets STRICT because a guard that quietly stops guarding is worse than no guard." >&2
echo " Fix the checkout, not this script: the base ref must be fetched (fetch-depth: 0)." >&2
exit 1
fi
echo "changelog-assembled: SKIPPED — $*"
echo " (Everything this guard asserts compares HEAD against the merge base —"
echo " without the history there is nothing it can honestly say. In CI this"
echo " same condition is a hard failure.)"
exit 0
}
# A pass with a NOTICE, never a skip in silence: an inapplicable tree is a
# legitimate green, and the log says why instead of implying a check ran.
notice() {
echo "changelog-assembled: NOTICE — $*"
exit 0
}
[ -f "$changelog" ] || { echo "changelog-assembled: no such file: $changelog" >&2; exit 1; }
# --- everything here needs the HISTORY ---------------------------------------
# Even applicability does: "fragment mode" is a fact about the merge base,
# not about HEAD's tree, whose fragments are consumed by construction. So
# unlike changelog-monotonic there is no history-free half to run first —
# an unusable checkout degrades (or, under STRICT, refuses) before the
# guard claims anything.
git rev-parse --is-inside-work-tree >/dev/null 2>&1 \
|| skip "not inside a git work tree, so there is no merge base to read the fragments from"
git rev-parse --verify --quiet "$base_ref^{commit}" >/dev/null \
|| skip "base ref '$base_ref' does not resolve here (a shallow clone, or a fork checkout without the upstream remote)"
merge_base="$(git merge-base "$base_ref" HEAD 2>/dev/null || true)"
[ -n "$merge_base" ] \
|| skip "no merge base between '$base_ref' and HEAD (unrelated histories, or a clone too shallow to reach one)"
short_base="$(git rev-parse --short "$merge_base")"
# Push-to-main shape: the merge base IS HEAD, so the fragment set this guard
# would replay is HEAD's own — nothing was consumed between the two points,
# and comparing a tree against itself would assert nothing. Named honestly,
# the same discipline as changelog-monotonic's vacuous line.
if [ "$merge_base" = "$(git rev-parse HEAD)" ]; then
echo "changelog-assembled: vacuous (the merge base IS HEAD, so no fragments were consumed between them — there is no diff for the section to answer to)."
exit 0
fi
# --- applicability: the ceremony PR, and nothing else ------------------------
if ! git cat-file -e "$merge_base:$dir" 2>/dev/null; then
notice "no '$dir/' at the merge base ($short_base) — legacy mode; the changelog is edited directly and there is no fragment set for a section to answer to"
fi
# version_read refuses loudly on a missing or empty source; the wrapper line
# names the guard so a workflow log shows which check refused.
ver="$(version_read "$version_source")" || {
echo "changelog-assembled: cannot read the version (version-source: $version_source)" >&2
exit 1
}
if version_is_dev "$ver"; then
notice "version '$ver' is a development tree — no release section is being stamped; whatever this PR does to '$dir/' is cargo for a future ceremony, not a consumption to verify"
fi
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
git show "$merge_base:$changelog" >"$tmp/base-changelog.md" 2>/dev/null || : >"$tmp/base-changelog.md"
# A branch that merely SITS on a release is not the ceremony that stamped
# it: right after a release merges, main's version is bare until the -dev
# bump lands, and a PR branched in that window would otherwise be asked to
# answer for a consumption that happened at its merge base, not on it. If
# the section already existed at the merge base, this branch did not stamp
# it. Whole-version match, as everywhere in this family.
if awk -v ver="$ver" '/^## / && $2 == ver { found = 1; exit } END { exit !found }' "$tmp/base-changelog.md"; then
notice "the section for '$ver' already exists at the merge base ($short_base) — this branch is not the ceremony that stamped it, so there is no consumed set to answer to"
fi
# --- the replay: the merge base's fragment set, byte for byte ----------------
# Every blob in the directory is extracted — strays included, and subtrees
# recreated — so the replay refuses exactly what a real assembler run over
# that tree would have refused, instead of quietly narrowing the set.
mkdir -p "$tmp/$dir"
base_frags=""
while IFS= read -r -d '' entry; do
meta="${entry%%$'\t'*}"
path="${entry#*$'\t'}"
otype="$(printf '%s\n' "$meta" | awk '{ print $2 }')"
name="${path##*/}"
case "$otype" in
blob)
git show "$merge_base:$path" >"$tmp/$dir/$name"
case "$name" in
README.md) ;;
*.md) base_frags="${base_frags}${path}"$'\n' ;;
esac
;;
tree)
mkdir -p "$tmp/$dir/$name"
;;
esac
done < <(git ls-tree -z "$merge_base" -- "$dir/")
frag_count="$(printf '%s' "$base_frags" | grep -c . || true)"
failures=0
# Refusal: the target branch gained a fragment after this release PR's merge
# base, so the ceremony could not have consumed it. Merging this tree would
# strand that fragment for the next release and misattribute when it shipped.
stranded=""
while IFS= read -r -d '' entry; do
meta="${entry%%$'\t'*}"
path="${entry#*$'\t'}"
otype="$(printf '%s\n' "$meta" | awk '{ print $2 }')"
name="${path##*/}"
case "$otype:$name" in
blob:README.md) ;;
blob:*.md)
if ! printf '%s' "$base_frags" | grep -Fxq "$path"; then
stranded="${stranded} ${path}"$'\n'
fi
;;
esac
done < <(git ls-tree -z "$base_ref" -- "$dir/")
if [ -n "$stranded" ]; then
{
echo "changelog-assembled: fragment(s) on target '$base_ref' were not consumed by this release PR:"
echo
printf '%s' "$stranded"
echo
echo " Merging now would strand these entries for the next release and"
echo " misattribute when they shipped."
echo " Fix: rebase onto the target head and re-run bin/changelog-assemble '$ver'."
} >&2
failures=$((failures + 1))
fi
# Refusal: a fragment the ceremony consumed is still present on HEAD. The
# ceremony deletes exactly what it assembles (#112) — a fragment that
# survives its own release sits in the directory and is assembled AGAIN
# into the NEXT section, republishing its prose as if it were new.
survivors=""
while IFS= read -r p; do
[ -n "$p" ] || continue
if [ -e "$p" ]; then
survivors="${survivors} ${p}"$'\n'
fi
done <<<"$base_frags"
if [ -n "$survivors" ]; then
{
echo "changelog-assembled: fragment(s) present at the merge base ($short_base) are STILL PRESENT on HEAD:"
echo
printf '%s' "$survivors"
echo
echo " The ceremony deletes exactly what it assembles. A fragment that survives"
echo " its own release is assembled AGAIN into the next section, republishing"
echo " its prose as if it were new. Delete it in this PR — its entry is (or"
echo " should be) already in the stamped section."
} >&2
failures=$((failures + 1))
fi
expected=""
if expected="$( (cd "$tmp" && "$assemble" "$ver" --check --changelog "$tmp/base-changelog.md" --dir "$dir") 2>&1)"; then
found="$(changelog_section "$changelog" "$ver")"
if [ -z "$found" ]; then
{
echo "changelog-assembled: the version is '$ver' (a release tree) and '$dir/' at the"
echo " merge base ($short_base) holds $frag_count fragment(s), but $changelog has no"
echo " non-empty section for '$ver' on HEAD."
echo
echo " The fragments were consumed and their prose went nowhere: the release"
echo " this tree is about to publish would have a body the authors never got"
echo " to write. The ceremony's edit is one tool run, in this PR:"
echo
echo " bin/changelog-assemble '$ver'"
} >&2
failures=$((failures + 1))
else
if ! diff_out="$(diff -u \
--label "expected — assembled from the $frag_count fragment(s) at the merge base ($short_base)" \
--label "found — section '$ver' in $changelog on HEAD" \
<(printf '%s\n' "$expected") <(printf '%s\n' "$found"))"; then
{
echo "changelog-assembled: the section '$ver' in $changelog is NOT what the fragments it consumed assemble to:"
echo
printf '%s\n' "$diff_out" | sed 's/^/ /'
echo
echo " The release body is published verbatim from this section, so any"
echo " difference here ships: a missing line is an author's entry silently"
echo " dropped from history, an extra or edited line is prose nobody wrote,"
echo " and a re-ordering is not the canonical order the assembler produces."
echo " The fix is to redo the ceremony's edit with the tool — re-run"
echo " bin/changelog-assemble '$ver' <date> from the merge base's fragments —"
echo " never to hand-edit the section into agreement."
} >&2
failures=$((failures + 1))
fi
fi
else
{
echo "changelog-assembled: the merge base's fragment set does not assemble — the replay refuses:"
echo
printf '%s\n' "$expected" | sed 's/^/ /'
echo
echo " The set replayed is exactly '$dir/' as of the merge base ($short_base)."
echo " A section that bin/changelog-assemble would refuse to produce cannot"
echo " have been produced by it — whatever stamped this section did it by"
echo " hand, and the release body cannot be trusted to be what the fragment"
echo " authors wrote."
} >&2
failures=$((failures + 1))
fi
[ "$failures" -eq 0 ] || exit 1
echo "changelog-assembled: section '$ver' in $changelog is byte-for-byte the assembly of the $frag_count fragment(s) consumed at the merge base ($short_base)"

View file

@ -148,11 +148,32 @@ if [ -n "$source_dir" ]; then
else else
# The repo is public: a plain tarball fetch, no auth, no git. Works for a # The repo is public: a plain tarball fetch, no auth, no git. Works for a
# tag, a branch, or a commit SHA alike. # tag, a branch, or a commit SHA alike.
#
# THE FORGE COMES FROM THE ENVIRONMENT, NEVER FROM THIS FILE (#201).
# heavy-duty/ceremony exists on two forges and the same ref names a
# DIFFERENT TREE on each: `0.4.1` on this forge carries lib/forge.sh,
# lib/forge-github.sh and lib/forge-forgejo.sh; GitHub's `0.4.1` carries
# none of them. A hard-coded host therefore verified a consumer's mirror
# against a tree it never pinned — and did it with HTTP 200, so --check
# reported drift the consumer could not fix and --fix would have rewritten
# a correct mirror into the wrong one. The version numbers agreeing is the
# hazard, not the protection (#197 decision 2).
#
# GITHUB_SERVER_URL is what Actions injects on both forges, and
# lib/forge.sh already selects the whole backend on it — so a consumer run
# that reached this line has it. Unset means we do not know which forge the
# pin refers to, and guessing is what this issue is about: refuse instead,
# the same way the pin itself is never guessed.
[ -n "${GITHUB_SERVER_URL:-}" ] || die \
"docs-sync: GITHUB_SERVER_URL is unset, so the forge holding" \
" heavy-duty/ceremony@$ref is unknown — and the same ref names a" \
" different tree on each forge. Set it to the forge this consumer is" \
" pinned against, or pass --source <dir>. This tool never guesses a forge."
fetch_tmp="$(mktemp -d)" fetch_tmp="$(mktemp -d)"
url="https://github.com/heavy-duty/ceremony/archive/${ref}.tar.gz" url="${GITHUB_SERVER_URL%/}/heavy-duty/ceremony/archive/${ref}.tar.gz"
curl -fsSL "$url" | tar -xz --strip-components=1 -C "$fetch_tmp" || die \ curl -fsSL "$url" | tar -xz --strip-components=1 -C "$fetch_tmp" || die \
"docs-sync: cannot fetch heavy-duty/ceremony@$ref ($url) —" \ "docs-sync: cannot fetch heavy-duty/ceremony@$ref ($url) —" \
" does the pinned ref exist?" " does the pinned ref exist on that forge?"
src="$fetch_tmp" src="$fetch_tmp"
origin="heavy-duty/ceremony@$ref" origin="heavy-duty/ceremony@$ref"
fi fi

View file

@ -0,0 +1,13 @@
name: Reconcile issue flow
description: Converge the issue work queue and reclaim stale claims
runs:
using: composite
steps:
- name: reconcile issue flow
shell: bash
env:
LABELS_CONF: ${{ github.workspace }}/.github/labels.conf
EVENT_NAME: ${{ github.event_name }}
EVENT_ACTION: ${{ github.event.action }}
EVENT_ISSUE: ${{ github.event.issue.number }}
run: bash "$GITHUB_ACTION_PATH/issueflow-reconcile.sh"

File diff suppressed because it is too large Load diff

View file

@ -23,7 +23,8 @@ runs:
BOOTSTRAP: ${{ inputs.bootstrap }} BOOTSTRAP: ${{ inputs.bootstrap }}
LABELS_CONF: ${{ github.workspace }}/.github/labels.conf LABELS_CONF: ${{ github.workspace }}/.github/labels.conf
run: | run: |
if [ "$BOOTSTRAP" = yes ]; then # BOOTSTRAP passes through as-is: the script gates on the input. The
export GITHUB_EVENT_NAME=workflow_dispatch # export-the-event-name hack that lived here died with ceremony#215 —
fi # the script keyed on GITHUB_EVENT_NAME, which #209 made true for
# every machine wake, so "no" could never mean no.
bash "$GITHUB_ACTION_PATH/labels-reconcile.sh" bash "$GITHUB_ACTION_PATH/labels-reconcile.sh"

View file

@ -28,8 +28,9 @@ fi
# stale approval must never promote unreviewed code to the human. # stale approval must never promote unreviewed code to the human.
# #
# DRY_RUN=1 narrates every mutation instead of performing it (how this script # DRY_RUN=1 narrates every mutation instead of performing it (how this script
# is rehearsed against the live repo). A workflow_dispatch run also bootstraps # is rehearsed against the live repo). A run with BOOTSTRAP=yes also
# the taxonomy (label create --force) — that heal is dispatch-only; the cron # bootstraps the taxonomy (label create --force) — the operator's manual
# dispatch defaults the input to yes; every machine wake passes no. The cron
# sweep tolerates a missing label rather than recreating it. # sweep tolerates a missing label rather than recreating it.
# #
# The state machine below is pure (globals in, state out) and covered by # The state machine below is pure (globals in, state out) and covered by
@ -37,13 +38,54 @@ fi
HUMAN="${HUMAN_REVIEWER:-danmt}" HUMAN="${HUMAN_REVIEWER:-danmt}"
BOTS=() BOTS=()
# Per-author panels (#224): parallel arrays because the conf is tiny and an
# associative array buys nothing but a bash-4 dependency statement. One entry
# per panel[<login>]= row — PANEL_AUTHORS holds the login, PANEL_ROWS the
# space-joined reviewer set at the same index.
PANEL_AUTHORS=()
PANEL_ROWS=()
REQUIRED_BOTS=() REQUIRED_BOTS=()
STATES=(state:building state:bots-reviewing state:addressing state:needs-human) STATES=(state:building state:bots-reviewing state:addressing state:needs-human)
BLOCKERS=(blocker:conflict blocker:ci-red blocker:unrequested) BLOCKERS=(blocker:conflict blocker:ci-red blocker:unrequested)
# The PR's current labels, set per PR by the sweep. Initialized here because
# the script runs under `set -u` even when sourced, and the pure-function
# fixtures call decide_state — which now reads has_label — without ever
# setting it (#51). An empty default keeps has_label honest for every caller.
LABELS=""
# Labels this machine used to own and no longer does. Cleared on sight so a # Labels this machine used to own and no longer does. Cleared on sight so a
# retirement heals the board instead of stranding a label nothing recomputes. # retirement heals the board instead of stranding a label nothing recomputes.
RETIRED=(state:needs-rebase) RETIRED=(state:needs-rebase)
STALE_AFTER=$((48 * 3600)) STALE_AFTER=$((48 * 3600))
# How long the facts behind blocker:unrequested must have stood still before it
# is written (#236 D2). The operator's "more than 5 minutes", measured off the
# inputs' own timestamps rather than off sweep memory — this script is
# stateless per pass and stays that way. Overridable the way this file's other
# constants are, for a caller whose round cadence is slower or faster.
RECONCILE_UNREQUESTED_GRACE="${RECONCILE_UNREQUESTED_GRACE:-300}"
# The workflow whose runs checks_state must never grade — its own (#208).
# GITHUB_WORKFLOW is ambient in every Actions step and names the CALLER (the
# consumer's PR-facing workflow, since consumers name the caller), so this
# self-serves with no workflow-file change. The explicit override exists for
# two readers: the fixtures, and #209's detached sweep caller, which will
# need to point this at the PR-facing caller's name once reconcile no longer
# runs inside it. Empty means "filter nothing" — a caller outside Actions
# (a local rehearsal, an older pin) must not silently start dropping entries.
SELF_WORKFLOW="${SELF_WORKFLOW:-${GITHUB_WORKFLOW:-}}"
# The needs-ruling invariants (#52) — one implementation for both surfaces.
# shellcheck source=lib/ruling.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/ruling.sh"
# shellcheck source=lib/forge.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
# The attention target invariants (#232) — diagnosis only, both surfaces.
# shellcheck source=lib/attention.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/attention.sh"
# The guarded read and its reason line (#101) — one implementation for both
# surfaces. read_failure_reason lived here until the issue surface needed the
# identical rule (#247); a second copy of it is the failure lib/ruling.sh's
# own header was written to record.
# shellcheck source=lib/read.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/read.sh"
log() { printf 'labels: %s\n' "$*"; } log() { printf 'labels: %s\n' "$*"; }
@ -51,6 +93,38 @@ run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing
if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi
} }
blind_sweep_warning() { # $1 = unreadable PRs, $2 = all open PRs, $3 = sampled read-failure reason
# Report, do not diagnose (#101 D5). The old text asserted the caller's
# checks:/statuses: grants as THE cause — an inference #95 made from a
# control case, and the merged consumer-side fix (incubator#48/PR #49)
# left the symptom standing while the run emitting this warning held the
# evidence that would have said so. Lead with what gh actually said this
# sweep; the permissions hint stays, demoted to one named candidate.
if [ "$2" -gt 0 ] && [ "$1" -eq "$2" ]; then
local reason="${3:-}"
if [ -n "$reason" ]; then
echo "::warning::labels: every open PR was unreadable; sampled reason: $reason — one candidate is missing checks: read, statuses: read and actions: read in the caller (private repos do not imply them)"
else
echo "::warning::labels: every open PR was unreadable; no reason was captured — one candidate is missing checks: read, statuses: read and actions: read in the caller (private repos do not imply them)"
fi
fi
}
missing_core_labels_warning() { # $1 = declared rows, $2 = repo label names
local rows="$1" repo_labels="$2" row name missing=""
[ -n "$repo_labels" ] || return 0
while IFS= read -r row; do
[ -n "$row" ] || continue
name="${row%%|*}"
if ! grep -qxF "$name" <<<"$repo_labels"; then
if [ -n "$missing" ]; then missing="$missing, $name"; else missing="$name"; fi
fi
done <<<"$rows"
if [ -n "$missing" ]; then
echo "::warning::labels: missing core label(s): $missing; bump the ceremony pin, then re-dispatch workflow_dispatch to bootstrap the taxonomy"
fi
}
load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional
local conf="$1" line panel_seen=false local conf="$1" line panel_seen=false
[ -f "$conf" ] || { [ -f "$conf" ] || {
@ -58,8 +132,16 @@ load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional
return 1 return 1
} }
BOTS=() BOTS=()
PANEL_AUTHORS=()
PANEL_ROWS=()
# shellcheck disable=SC2094 # parse_panel_author_row takes $conf for its
# error messages only — nothing in this loop writes the file it reads
while IFS= read -r line || [ -n "$line" ]; do while IFS= read -r line || [ -n "$line" ]; do
[ -n "$line" ] || continue [ -n "$line" ] || continue
# The panel[ prefix is matched QUOTED (#224 D7): in a case pattern an
# unquoted panel[abc]=* is a bracket expression that matches panela=…,
# panelb=…, panelc=… — silently rerouting ordinary settings. The
# panela= tripwire in test/labels.test.sh goes red if this regresses.
case "$line" in case "$line" in
panel=*) panel=*)
[ "$panel_seen" = false ] || { [ "$panel_seen" = false ] || {
@ -73,6 +155,8 @@ load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional
return 1 return 1
} }
;; ;;
"panel["*) parse_panel_author_row "$line" "$conf" || return ;;
triage-actors=*) ;;
*) parse_label_row "$line" >/dev/null || return ;; *) parse_label_row "$line" >/dev/null || return ;;
esac esac
done <"$conf" done <"$conf"
@ -82,6 +166,55 @@ load_config() { # $1 = consumer labels.conf; panel is mandatory, scopes optional
} }
} }
parse_panel_author_row() { # panel[<login>]=<space-separated logins> (#224)
# Every failure here is a hard one that names the offending line (D3): a
# conf error takes the whole board down, and the run log is the only place
# the operator can read why. A malformed bracket is refused AS a bracket
# (D4) — falling through to parse_label_row would report it as a
# "malformed label row", the misleading diagnostic #224 was filed over.
local line="$1" conf="$2" login rest existing
case "$line" in
"panel["*"]="*) ;;
*)
echo "labels: malformed panel[<login>]= row (expected panel[<login>]=<reviewers>): $line in $conf" >&2
return 1
;;
esac
login="${line#panel[}"
login="${login%%]=*}"
[ -n "$login" ] || {
echo "labels: empty login in panel row: $line in $conf" >&2
return 1
}
# The login must be exactly one well-formed bracket pair of login
# characters. Without this, panel[z]]=b parses: the case above only
# establishes that SOME ]= occurs, ${login%%]=*} keeps the stray ] inside
# the login (z]), and set_required_bots for the real z then silently falls
# back to the base panel — the misroute D4 exists to refuse. GitHub logins
# are [A-Za-z0-9-], per the #285 spec.
case "$login" in
*[!A-Za-z0-9-]*)
echo "labels: malformed panel[<login>]= row (a login is [A-Za-z0-9-] only): $line in $conf" >&2
return 1
;;
esac
for existing in ${PANEL_AUTHORS[@]+"${PANEL_AUTHORS[@]}"}; do
[ "$existing" != "$login" ] || {
echo "labels: duplicate panel[$login]= row in $conf: $line" >&2
return 1
}
done
local -a row=()
rest="${line#*]=}"
read -r -a row <<<"$rest"
[ "${#row[@]}" -gt 0 ] || {
echo "labels: panel[$login]= must name at least one reviewer in $conf: $line" >&2
return 1
}
PANEL_AUTHORS+=("$login")
PANEL_ROWS+=("${row[*]}")
}
parse_label_row() { # exact name|color|description; pipes in descriptions are refused parse_label_row() { # exact name|color|description; pipes in descriptions are refused
local line="$1" name color desc extra local line="$1" name color desc extra
IFS='|' read -r name color desc extra <<<"$line" IFS='|' read -r name color desc extra <<<"$line"
@ -97,27 +230,55 @@ configured_label_rows() { # validated scope rows, excluding the panel setting
[ -f "$conf" ] || return 0 [ -f "$conf" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do while IFS= read -r line || [ -n "$line" ]; do
[ -n "$line" ] || continue [ -n "$line" ] || continue
case "$line" in panel=*) continue ;; esac # "panel["* quoted for the same D7 reason as load_config's case; skipping
# the bracketed rows (D5) keeps a dispatch bootstrap from trying to
# create a label named panel[<login>].
case "$line" in panel=* | "panel["* | triage-actors=*) continue ;; esac
parse_label_row "$line" || return parse_label_row "$line" || return
done <"$conf" done <"$conf"
} }
panel_for_author() { # $1 = author → the effective panel, space-joined (#224 D2)
# THE resolution point: the author's panel[<login>]= row when the conf
# defines one, the base panel= otherwise. Everything that computes a
# required set goes through here, because two places computing the panel
# is how the engine and the reconciler came to disagree in the first place.
local author="$1" i
for i in ${PANEL_AUTHORS[@]+"${!PANEL_AUTHORS[@]}"}; do
if [ "${PANEL_AUTHORS[i]}" = "$author" ]; then
printf '%s\n' "${PANEL_ROWS[i]}"
return
fi
done
printf '%s\n' "${BOTS[*]}"
}
set_required_bots() { # the PR author is recused by construction set_required_bots() { # the PR author is recused by construction
# Minus-the-author applies to WHICHEVER set panel_for_author returns (#224
# D2's safety net): an author who mistakenly appears inside its own
# bracketed row is still recused.
local author="$1" bot local author="$1" bot
local -a effective=()
read -r -a effective <<<"$(panel_for_author "$author")"
REQUIRED_BOTS=() REQUIRED_BOTS=()
for bot in "${BOTS[@]}"; do for bot in ${effective[@]+"${effective[@]}"}; do
[ "$bot" = "$author" ] || REQUIRED_BOTS+=("$bot") [ "$bot" = "$author" ] || REQUIRED_BOTS+=("$bot")
done done
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# The state machine. Pure functions over four globals, set per PR: # The state machine. Pure functions over these globals, set per PR:
# DRAFT true|false # DRAFT true|false
# HEAD_SHA the PR's current head commit # HEAD_SHA the PR's current head commit
# BASE_SHA the PR's base branch head
# MERGE_BASE_SHA the PR's merge base (the release-shape guard's ref)
# REQUESTED newline-separated logins with a review currently requested # REQUESTED newline-separated logins with a review currently requested
# REVIEWS_JSON JSON array of submitted (non-PENDING) reviews # REVIEWS_JSON JSON array of submitted, gradeable reviews
# MERGEABLE MERGEABLE | CONFLICTING | UNKNOWN (GitHub's own verdict) # MERGEABLE MERGEABLE | CONFLICTING | UNKNOWN (GitHub's own verdict)
# CHECKS SUCCESS | FAILURE | PENDING | NONE (the check rollup) # CHECKS SUCCESS | FAILURE | PENDING | NONE (the check rollup)
# LABELS newline-separated labels currently on the PR
# HEAD_COMMIT_AT the head commit's own date, ISO-8601; empty when unread
# NOW this sweep's epoch seconds (main sets it once per run)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
requested() { grep -qxF "$1" <<<"$REQUESTED"; } requested() { grep -qxF "$1" <<<"$REQUESTED"; }
@ -142,7 +303,43 @@ checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE |
# the exact shape of #136. The cost of being wrong is symmetric in form and # the exact shape of #136. The cost of being wrong is symmetric in form and
# not in consequence: a false FAILURE parks the PR on the agent, who looks; # not in consequence: a false FAILURE parks the PR on the agent, who looks;
# a false SUCCESS invites a human to merge a tree that will not merge. # a false SUCCESS invites a human to merge a tree that will not merge.
jq -r ' #
# The list-what-passes rule has exactly one carve-out, and it is narrower
# than an outcome: a CANCELLED entry is discarded when its context holds at
# least one non-cancelled sibling (#139). The reconcile job queues in one
# repo-global concurrency group, so any repo event — a sibling PR's push,
# triage labelling an issue — evicts the queued duplicate AFTER it has
# attached a check run to this PR's head, and that cancelled entry became
# the context's newest word: blocker:ci-red on a PR whose real checks were
# all green (#133/#136, evictable only by an empty commit). A cancelled run
# said nothing about this head; a non-cancelled sibling is a real verdict
# about exactly these bytes, whatever order the two arrived in — and for
# this workflow the evictor performs the duplicate's work anyway, since
# every sweep covers every open PR. This does not widen unknown-into-green:
# a context whose entries are ALL cancelled never reported at all (a killed
# or timed-out required job), so it keeps CANCELLED and still blocks —
# discard needs a surviving verdict, never an empty context.
#
# And one exclusion that comes before every rule above: the label machine
# never grades its own runs (#208). Every reconcile sweep serializes
# through one shared concurrency group, and GitHub records a displaced
# queued run as CANCELLED — there is no "superseded" conclusion for queue
# displacement. When the displaced run was born from a pull_request_target
# event, that cancelled entry attaches to the victim PR while its
# SUCCESSOR — triggered by a different PR or an issues event — attaches
# elsewhere, so the #139 carve-out's premise (a surviving sibling on the
# same PR) fails structurally: on the victim the newest self entry stays
# CANCELLED, the deny-list scores it FAILURE, and the sweep sets
# blocker:ci-red off its own corpse — then re-affirms it every cadence.
# Proven on crew#227: every real check green, the only red rollup entry
# the sweep's own displaced run. So drop every entry belonging to
# $SELF_WORKFLOW before the newest-per-context collapse. Accepted
# consequences: a rollup of ONLY self entries scores NONE (honestly: no
# checks — never SUCCESS), and a genuine reconcile failure surfaces on the
# Actions tab instead of as blocker:ci-red, which is right because no PR
# edit can fix the label machinery. An empty $self filters nothing — the
# exclusion must never widen into dropping entries on a guess.
jq -r --arg self "$SELF_WORKFLOW" '
if (has("statusCheckRollup") | not) then "UNREADABLE" else if (has("statusCheckRollup") | not) then "UNREADABLE" else
# NEUTRAL and SKIPPED satisfy branch protection — a skipped required check # NEUTRAL and SKIPPED satisfy branch protection — a skipped required check
@ -183,15 +380,25 @@ checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE |
# and treating it as newest keeps an undateable in-flight run from being # and treating it as newest keeps an undateable in-flight run from being
# discarded in favour of a stale success. Every ambiguity resolves toward # discarded in favour of a stale success. Every ambiguity resolves toward
# "not settled". # "not settled".
# The #208 exclusion (header above): self entries leave the rollup here,
# BEFORE the group_by — a self-only context must vanish entirely, never
# survive as an all-cancelled context that still classifies FAILURE.
| [ (.statusCheckRollup // [])[] | [ (.statusCheckRollup // [])[]
| select($self == "" or (.workflowName // "") != $self)
| { ctx: [.workflowName // "", .name // .context // ""], | { ctx: [.workflowName // "", .name // .context // ""],
at: ([.startedAt, .createdAt, .completedAt] at: ([.startedAt, .createdAt, .completedAt]
| map(select(type == "string" and . != "" | map(select(type == "string" and . != ""
and (startswith("0001-01-01") | not))) and (startswith("0001-01-01") | not)))
| first // ""), | first // ""),
outcome: ((.conclusion // .state // "") | ascii_upcase) } ] outcome: ((.conclusion // .state // "") | ascii_upcase) } ]
# The #139 carve-out (header above): drop CANCELLED entries only when the
# context keeps a non-cancelled survivor — BEFORE the sort, so a cancelled
# entry that arrived newest cannot outvote the real verdict it displaced.
# An all-cancelled context is left intact and still classifies FAILURE.
| group_by(.ctx) | group_by(.ctx)
| map(sort_by([(.at == ""), .at]) | last | .outcome) as $latest | map( map(select(.outcome != "CANCELLED")) as $live
| (if ($live | length) > 0 then $live else . end)
| sort_by([(.at == ""), .at]) | last | .outcome ) as $latest
| if ($latest | length) == 0 then "NONE" | if ($latest | length) == 0 then "NONE"
elif (($latest - $passing - $waiting) | length) > 0 then "FAILURE" elif (($latest - $passing - $waiting) | length) > 0 then "FAILURE"
@ -209,21 +416,66 @@ bot_verdict() { # $1 = login → MISSING | BLOCK | APPROVE | STALE | FEEDBACK
if [ -z "$review" ]; then echo MISSING; return; fi if [ -z "$review" ]; then echo MISSING; return; fi
state="$(jq -r '.state' <<<"$review")" state="$(jq -r '.state' <<<"$review")"
commit="$(jq -r '.commit_id' <<<"$review")" commit="$(jq -r '.commit_id' <<<"$review")"
# This case grades a submitted verdict. The ingestion allow-list answers the
# separate question of whether a row is a submitted review at all (#235).
case "$state" in case "$state" in
CHANGES_REQUESTED) CHANGES_REQUESTED | REQUEST_CHANGES)
# blocks at ANY head — GitHub's own semantic: only a newer review # blocks at ANY head — both forges' semantic: only a newer review from
# from the same reviewer clears it # the same reviewer clears it
echo BLOCK ;; echo BLOCK ;;
APPROVED) APPROVED)
if [ "$commit" = "$HEAD_SHA" ]; then echo APPROVE; else echo STALE; fi ;; if [ "$commit" = "$HEAD_SHA" ]; then echo APPROVE; else echo STALE; fi ;;
*) COMMENTED | COMMENT)
# COMMENTED and anything else: a non-verdict. The machine does not # A comment is a non-verdict. The machine does not read bodies — if the
# read bodies — if the comment is really an agreement, the AUTHOR # comment is really an agreement, the AUTHOR says so by requesting the
# says so by requesting the human's review. # human's review.
echo FEEDBACK ;; echo FEEDBACK ;;
*)
# An unknown state is not evidence that a reviewer answered. Keep the
# round open and make the next forge vocabulary surprise visible (#235).
log "$1: unrecognised review state $state" >&2
echo MISSING ;;
esac esac
} }
iso_epoch() { # $1 = ISO-8601 timestamp → epoch seconds; nothing, rc 1, when unreadable
# An absent field reaches this as the empty string or as jq's literal "null";
# both are "we did not read a time", and neither may be graded as one.
local at="${1-}" epoch
case "$at" in "" | null) return 1 ;; esac
epoch="$(date -d "$at" +%s 2>/dev/null)" || return 1
[ -n "$epoch" ] || return 1
printf '%s\n' "$epoch"
}
unrequested_quiescent() { # 0 when the unrequested facts have stood for the grace (#236 D2)
# The stall blocker's supporting facts are the head and the round's newest
# submitted review: the ask it demands is owed only once both have stopped
# moving. Measured off those timestamps, not off sweep memory — ceremony#235
# was flagged inside the ~90 seconds between a round-answer push and the
# author's re-request, because a sweep read the facts before the request
# landed and wrote after it. That is a round in motion, not a dropped ball.
#
# "Newest submitted review" is any submitted review, COMMENTED included: a
# non-verdict is still evidence the round is live, and counting it can only
# delay a flag, never invent one.
#
# A timestamp we could not read refuses the blocker (the standing rule: an
# unreadable fact never invents a verdict). This direction is deliberate and
# asymmetric — a missed flag costs one sweep of the 15-minute cadence, a
# false one flags a builder for doing exactly what BUILDER.md requires.
local newest verdict_at verdict_epoch
newest="$(iso_epoch "${HEAD_COMMIT_AT:-}")" || return 1
verdict_at="$(jq -r '[.[].submitted_at] | max // empty' <<<"${REVIEWS_JSON:-[]}")"
if [ -n "$verdict_at" ]; then
# A round WITH verdicts whose newest one cannot be dated is unreadable, not
# quiescent; a round with no verdicts at all is simply the head's clock.
verdict_epoch="$(iso_epoch "$verdict_at")" || return 1
[ "$verdict_epoch" -gt "$newest" ] && newest="$verdict_epoch"
fi
[ $((${NOW:-0} - newest)) -ge "$RECONCILE_UNREQUESTED_GRACE" ]
}
human_request_needed() { # 0 when needs-human requires a FRESH human request human_request_needed() { # 0 when needs-human requires a FRESH human request
# already requested → the handoff is live; head-current human approval → # already requested → the handoff is live; head-current human approval →
# nothing left to ask. Anything else (never reviewed, an old comment, an # nothing left to ask. Anything else (never reviewed, an old comment, an
@ -259,7 +511,27 @@ blockers() { # → the blocker:* labels this PR should carry, one per line
# A draft is exempt (the bots ignore drafts by design), and so is an # A draft is exempt (the bots ignore drafts by design), and so is an
# explicit human request — a maintainer claiming a PR early is deliberate, # explicit human request — a maintainer claiming a PR early is deliberate,
# not a dropped ball. # not a dropped ball.
if [ "$DRAFT" != true ] && ! requested "$HUMAN"; then #
# And so is a head whose checks have not answered yet (#236 D1). This is the
# one blocker that names an act the author must PERFORM, so it is the one
# that has to know when performing it is permitted: BUILDER.md's review round
# requires a green check at the head before requesting, so a builder waiting
# out a pending run is complying, and flagging compliance teaches its readers
# to ignore the label. Both 2026-08-03 instances were exactly that —
# crew#318 at ~12:44Z carried state:addressing + blocker:unrequested while
# the head's run was IN_PROGRESS, and ceremony#235 at 12:30Z caught the
# ~90-second gap between a round-answer push and the re-request.
#
# PENDING and FAILURE each already have an owner, which is why gating loses
# no coverage: on PENDING the next move is CI's and state:addressing /
# state:bots-reviewing already say what the PR is doing; on FAILURE
# blocker:ci-red owns that head, and stacking a second blocker on it
# double-flags one stall. NONE joins SUCCESS because no checks configured is
# nothing to wait for — the same reading the request rule gives the builder.
# UNREADABLE never arrives here: the caller skips the PR before deciding.
local checks_permit_the_ask=false
case "${CHECKS:-NONE}" in SUCCESS | NONE) checks_permit_the_ask=true ;; esac
if [ "$DRAFT" != true ] && [ "$checks_permit_the_ask" = true ] && ! requested "$HUMAN"; then
local b v owed=false any_requested=false local b v owed=false any_requested=false
for b in "${REQUIRED_BOTS[@]}"; do for b in "${REQUIRED_BOTS[@]}"; do
requested "$b" && any_requested=true requested "$b" && any_requested=true
@ -270,18 +542,64 @@ blockers() { # → the blocker:* labels this PR should carry, one per line
v="$(bot_verdict "$b")" v="$(bot_verdict "$b")"
case "$v" in MISSING | STALE) owed=true ;; esac case "$v" in MISSING | STALE) owed=true ;; esac
done done
if [ "$owed" = true ] && [ "$any_requested" = false ]; then # The quiescence grace (#236 D2) is the last question, after the debt is
# established: it asks whether the debt has stood long enough to be a
# dropped ball rather than a round still in motion.
if [ "$owed" = true ] && [ "$any_requested" = false ] && unrequested_quiescent; then
echo blocker:unrequested echo blocker:unrequested
fi fi
fi fi
} }
round_outranks_draft() { # 0 when the round's standing word survives a re-draft (#205)
# A standing non-approving verdict outranks draft: a PR that took a round,
# carries CHANGES_REQUESTED (or a comment owed a reply, or approvals a push
# staled), and is then converted back to draft is a fix round in progress,
# not a build — and hiding it behind state:building is a dropped ball the
# staleness sweep reads as work in progress. Approvals do NOT outrank
# draft: a re-draft after a passed round is deliberately building again,
# and a draft must never read state:needs-human.
#
# A LIVE panel request on a draft also falls through — deliberately
# surfaced, not absorbed (#205's must-not-paper-over): the bots ignore
# drafts by design, so a draft wearing state:bots-reviewing on the board
# is the visible symptom of a real defect (a request nobody cleared at
# round close, or a hand-requested draft), and reading it as building
# would hide exactly that.
local b
for b in "${REQUIRED_BOTS[@]}"; do
requested "$b" && return 0
case "$(bot_verdict "$b")" in BLOCK | FEEDBACK | STALE) return 0 ;; esac
done
[ "$(bot_verdict "$HUMAN")" = BLOCK ]
}
decide_state() { # → the one state:* label this PR should carry decide_state() { # → the one state:* label this PR should carry
if [ "$DRAFT" = true ]; then echo state:building; return; fi # Draft decides the state only when the round implies nothing else (#205):
# a draft with no round history reads state:building exactly as it always
# has, and round_outranks_draft is what "nothing else" means.
if [ "$DRAFT" = true ] && ! round_outranks_draft; then
echo state:building
return
fi
local s local s
s="$(round_state)" s="$(round_state)"
# A draft disqualifies needs-human unconditionally (#205, round 1): with
# the short-circuit above now conditional, a draft carrying a live human
# request plus a standing bot block or comment fell through to
# round_state, whose explicit-human-request precedence sits above the
# BLOCK/FEEDBACK cases — and GitHub cannot merge a draft at all, so
# "a human could merge this right now" would lie no matter what the
# round says. state:addressing is the same honest landing the blocker/
# needs-ruling/blocked clauses below use: the round's word stands, only
# the mergeable-now claim is off the table while the PR is a draft.
if [ "$s" = state:needs-human ] && [ "$DRAFT" = true ]; then
echo state:addressing
return
fi
# The one rule joining the two axes: state:needs-human means a human could # The one rule joining the two axes: state:needs-human means a human could
# merge this RIGHT NOW, so it requires a clear branch. Any blocker at all # merge this RIGHT NOW, so it requires a clear branch. Any blocker at all
# means the work is the agent's — whatever the review round says — and the # means the work is the agent's — whatever the review round says — and the
@ -290,6 +608,34 @@ decide_state() { # → the one state:* label this PR should carry
if [ "$s" = state:needs-human ] && [ -n "$(blockers)" ]; then if [ "$s" = state:needs-human ] && [ -n "$(blockers)" ]; then
echo state:addressing; return echo state:addressing; return
fi fi
# A pending ruling disqualifies needs-human the same way (#51): while
# `needs-ruling` is up, the human's turn lives in the THREAD — the flag
# marks it — and "mergeable right now" must not read true beside an open
# decision. state:addressing is the honest landing because the ball ON THE
# PR is the builder's: the flag-setter judges when agreement is reached and
# carries the ruling in (#50 D6). The label is deliberately NOT in BLOCKERS:
# that array is machine-owned, and the converge loop strips every entry the
# current facts do not re-derive — `needs-ruling` is hand-set intent the
# machine reads and never writes (#50 D9), so parking it there would strip
# a live escalation on the next 15-minute tick.
if [ "$s" = state:needs-human ] && has_label needs-ruling; then
echo state:addressing; return
fi
# A directed hold disqualifies it the same way (#180): `blocked` is hand-set
# intent — triage sets it, anyone may correct it — and during the #111
# freeze rig#126/#128 carried it beside state:needs-human, so the board said
# "mergeable right now" about PRs a hold said must not merge (rig#126 was
# merged seven minutes later). Not a BLOCKERS entry, deliberately: that
# array is machine-owned and the converge loop strips whatever the facts do
# not re-derive, so emitting the label there would strip a live hold on the
# next 15-minute tick — the same trap #51 names for `needs-ruling`.
# state:addressing is the accepted imprecision: under a hold the builder
# owes nothing, but "a human could merge this now" must not lie.
if [ "$s" = state:needs-human ] && has_label blocked; then
echo state:addressing; return
fi
echo "$s" echo "$s"
} }
@ -356,7 +702,7 @@ round_state() { # → the state the REVIEW ROUND alone implies; knows no branch
core_label_rows() { core_label_rows() {
cat <<'EOF' cat <<'EOF'
state:building|FBCA04|PR is a draft — the coding agent is still building state:building|FBCA04|Pre-round: the builder is still building — draft is evidence for it, not the definition
state:bots-reviewing|1D76DB|Waiting on the bot reviewers to finish the round state:bots-reviewing|1D76DB|Waiting on the bot reviewers to finish the round
state:addressing|D93F0B|All bots reviewed — coding agent owes the single reply + fixes state:addressing|D93F0B|All bots reviewed — coding agent owes the single reply + fixes
state:needs-human|8250DF|No blockers, all bots approve — waiting on the human reviewer state:needs-human|8250DF|No blockers, all bots approve — waiting on the human reviewer
@ -366,16 +712,34 @@ blocker:unrequested|E99695|Somebody still owes a verdict and nobody was asked fo
merge-next|0E8A16|Head of the merge queue — merge this one next (set by hand/agent, cleared here) merge-next|0E8A16|Head of the merge queue — merge this one next (set by hand/agent, cleared here)
stale|B60205|No activity for 48h — needs a poke (sweep-managed) stale|B60205|No activity for 48h — needs a poke (sweep-managed)
blocked|6A737D|Waiting on another PR or issue to land first blocked|6A737D|Waiting on another PR or issue to land first
offsite|CFD3D7|Issue deliverable is a PR in another repository — claim clock paused
needs-ruling|D4C5F9|A human decision is pending — question, options and a recommendation are in the comment
attention|D93F0B|A demand is parked here for the assignee: pick up the thread, ack by removing this label
release|0E8A16|Release flow and version/packaging work release|0E8A16|Release flow and version/packaging work
needs-triage|FBCA04|Did not come through triage — owes normalization or conversion to a discussion needs-triage|FBCA04|Did not come through triage — owes normalization into work or a reasoned refusal
ready|0E8A16|Triaged, spec complete, unblocked — a builder can start now and succeed ready|0E8A16|Triaged, spec complete, unblocked — a builder can start now and succeed
claimed|1D76DB|A builder owns it: assignee set, draft PR expected shortly claimed|1D76DB|A builder owns it: assignee set, draft PR expected shortly
post-merge|006B75|Refs-linked PR merged; post-merge criteria remain and triage owns completion
epic|5319E7|Organizes other issues via a dependency-ordered task list — builders never pick it epic|5319E7|Organizes other issues via a dependency-ordered task list — builders never pick it
EOF EOF
} }
retired_label_names() { # the GitHub defaults LABELS.md retires — a `question` belongs in a proposal, not a work issue
# One registry, kept beside core_label_rows() for the same reason those rows
# are not in labels.conf: a rule that must hold in every governed repo
# cannot live in a per-repo file. The six names match LABELS.md exactly.
cat <<'EOF'
duplicate
invalid
question
wontfix
help wanted
good first issue
EOF
}
bootstrap_labels() { # dispatch-only: ~20 upserts is too chatty for every cron tick bootstrap_labels() { # dispatch-only: ~20 upserts is too chatty for every cron tick
local rows local rows name
rows="$(core_label_rows)" rows="$(core_label_rows)"
if [ -f "$LABELS_CONF" ]; then if [ -f "$LABELS_CONF" ]; then
rows="$rows rows="$rows
@ -383,14 +747,82 @@ $(configured_label_rows "$LABELS_CONF")"
fi fi
while IFS='|' read -r name color desc; do while IFS='|' read -r name color desc; do
[ -n "$name" ] || continue [ -n "$name" ] || continue
run gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force run forge_label_create "$name" "$color" "$desc"
done <<<"$rows" done <<<"$rows"
# LABELS.md publishes the defaults as deleted at bootstrap; until #93
# nothing deleted them — incubator's first dispatch ran green and left
# `good first issue` standing. Deletion is dispatch-only like the upserts,
# and never fatal: `gh label delete` exits non-zero on a label that is
# already gone, the NORMAL case from the second dispatch on, and under
# set -e an unguarded call aborts the whole run (#91's shape). A 403
# refusal gets the same tolerance — the bot bootstrap already 403s on
# blocker:drill-pending, and a token that cannot delete must still get
# the taxonomy it can create. Either way: log the name, keep going.
while IFS= read -r name; do
[ -n "$name" ] || continue
run forge_label_delete "$name" \
|| log "retire: '$name' not deleted (already absent, or refused) — continuing"
done <<<"$(retired_label_names)"
} }
has_label() { grep -qxF "$1" <<<"$LABELS"; } has_label() { grep -qxF "$1" <<<"$LABELS"; }
release_shape_warning() { # $1 = PR, $2 = head version, $3 = base version
# The #128 incident's guard (#130): a release-shaped PR — bare X.Y.Z at
# its head where the base says something else — reaching the board with
# no `release` label is exactly the state whose merge would publish
# nothing, so the sweep says so instead of letting the merge door
# discover it. A WARNING, never a write: `release` is declared intent,
# and the reconciler does not guess intent (LABELS.md's rule for
# `blocked`/`release`). An unreadable version blocks nothing — the
# sweep must not nag on facts it did not read.
local n="$1" head_ver="$2" base_ver="$3"
[ -n "$head_ver" ] || return 0
grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$' <<<"$head_ver" || return 0
[ "$head_ver" != "$base_ver" ] || return 0
echo "::warning::labels: #$n is release-shaped (version ${base_ver:-unreadable} -> $head_ver at its head) but carries no release label — the merge door reads that label as declared intent and will refuse without it; if this is the ceremony PR, apply release (#130; the #128 incident)"
}
tree_version() { # $1 = ref → that tree's version via the API, or nothing
# Both backends, no checkout: a VERSION file first, package.json's
# version field second (jq, not node — a read needs no npm machinery).
# Every failure path prints nothing: the caller treats "could not read"
# as "not release-shaped" rather than warning on a guess.
local ref="$1" ver
ver="$(forge_api "repos/$REPO/contents/VERSION?ref=$ref" --jq '.content' 2>/dev/null \
| base64 -d 2>/dev/null | tr -d '[:space:]')"
if [ -z "$ver" ]; then
ver="$(forge_api "repos/$REPO/contents/package.json?ref=$ref" --jq '.content' 2>/dev/null \
| base64 -d 2>/dev/null | jq -r '.version // empty' 2>/dev/null)"
fi
[ -z "$ver" ] || printf '%s\n' "$ver"
return 0
}
# label_write <n> <args…> — every label mutation on this surface goes through
# here (#192). A write that did not happen must reach main's exit code, and the
# first version of this fix marked only the primary state edit: clearing
# `merge-next` and the two `stale` edits could still fail into the generic
# per-PR branch and finish with `reconciled.` and exit 0
# (@codex-reviewer-andresmgsl). One helper means a future call site cannot
# reopen that by forgetting to mark itself.
#
# The marker is a log line rather than a return code because reconcile_pr runs
# in a subshell whose STDOUT main reads — the same channel the degraded-read
# warning already travels on.
label_write() {
local n="$1"
shift
if run forge_issue_edit "$n" "$@" >/dev/null; then
return 0
fi
log "#$n: label edit FAILED — attempted: forge_issue_edit $n $*; the write did not happen (reason on stderr above)"
return 1
}
reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
local n="$1" desired remove s args last_activity age local n="$1" desired remove s args last_activity last_activity_epoch age
desired="$(decide_state)" desired="$(decide_state)"
@ -403,7 +835,7 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
# concurrency group in labels.yml. With a comment-only bot on the panel # concurrency group in labels.yml. With a comment-only bot on the panel
# this path stays cold and the AUTHOR requests the human. # this path stays cold and the AUTHOR requests the human.
if [ "$desired" = state:needs-human ] && human_request_needed; then if [ "$desired" = state:needs-human ] && human_request_needed; then
run gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$HUMAN" --silent run forge_request_reviewer "$n" "$HUMAN"
log "#$n: requested $HUMAN (round passed)" log "#$n: requested $HUMAN (round passed)"
fi fi
@ -465,14 +897,32 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
if [ "$skip_edit" = false ] && { ! has_label "$desired" || [ -n "$remove" ] || [ -n "$add" ]; }; then if [ "$skip_edit" = false ] && { ! has_label "$desired" || [ -n "$remove" ] || [ -n "$add" ]; }; then
args=(--add-label "$desired${add:+,$add}") args=(--add-label "$desired${add:+,$add}")
[ -n "$remove" ] && args+=(--remove-label "$remove") [ -n "$remove" ] && args+=(--remove-label "$remove")
if run gh issue edit "$n" -R "$REPO" "${args[@]}" >/dev/null; then if label_write "$n" "${args[@]}"; then
log "#$n: state -> $desired${add:+ +$add}${remove:+ (cleared $remove)}" log "#$n: state -> $desired${add:+ +$add}${remove:+ (cleared $remove)}"
else else
# a deleted label must not wedge the sweep — dispatch heals the taxonomy # A WRITE THAT DID NOT HAPPEN IS FATAL, not a warning (#192). This was
log "#$n: WARNING: label edit failed (missing label? run the workflow manually to bootstrap)" # `log WARNING` and fell through, so the sweep printed `reconciled.` and
# exited green over an edit the forge had refused — the
# degraded-write-reports-success class #188 exists to eliminate,
# surviving inside the reconciler that reports it.
#
# The old text also diagnosed a cause it had not established: it named a
# missing label and told the operator to bootstrap, when the label was
# present and the call had returned 500. #101's rule is report, do not
# diagnose — so this says what was attempted and that it did not happen,
# and leaves the backend's own stderr to say why.
return 1
fi fi
fi fi
# ---- the release-shape guard (#130): a warning, never a write --------
# Drafts are exempt (the build phase is the builder's); the version
# reads cost two API calls and only on PRs missing the label.
if [ "$DRAFT" != true ] && ! has_label release; then
release_shape_warning "$n" "$(tree_version "$HEAD_SHA")" \
"$(tree_version "${MERGE_BASE_SHA:-$BASE_SHA}")"
fi
# ---- merge-next: cleared, never set ---------------------------------- # ---- merge-next: cleared, never set ----------------------------------
# Queue order is INTENT — which PR should land first is a judgement about # Queue order is INTENT — which PR should land first is a judgement about
# conflicts and dependencies that GitHub knows nothing about, so the # conflicts and dependencies that GitHub knows nothing about, so the
@ -481,61 +931,123 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
# the moment the PR is no longer the thing a human should merge next, the # the moment the PR is no longer the thing a human should merge next, the
# claim is removed. Setting it stays with whoever owns the queue. # claim is removed. Setting it stays with whoever owns the queue.
if has_label merge-next && [ "$desired" != state:needs-human ]; then if has_label merge-next && [ "$desired" != state:needs-human ]; then
run gh issue edit "$n" -R "$REPO" --remove-label merge-next >/dev/null label_write "$n" --remove-label merge-next || return 1
log "#$n: cleared merge-next (state is $desired, not mergeable-by-a-human)" log "#$n: cleared merge-next (state is $desired, not mergeable-by-a-human)"
fi fi
# ---- stale: real activity only, and blocked is legitimately quiet ---- # ---- stale: real activity only, and blocked is legitimately quiet ----
# forge_pr_activity owns the portable half: issue comments + commits +
# inline review comments. The flat /pulls/{n}/comments endpoint 404s on
# Forgejo; the forgejo backend re-derives it from reviews with
# comments_count > 0 (#188 / #4844). PR created_at and review submitted_at
# stay here — they are already in hand and need no second fetch.
last_activity="$( last_activity="$(
{ {
jq -r '.created_at' <<<"$PR_JSON" jq -r '.created_at' <<<"$PR_JSON"
jq -r '.[].submitted_at' <<<"$REVIEWS_JSON" jq -r '.[].submitted_at // empty' <<<"$REVIEWS_JSON"
gh api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' # Non-fatal degrade (pre-#188 same edge), but do NOT swallow stderr —
gh api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at' # forge_api names failures loudly, and hiding them re-opens this issue's
gh api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' # silent-green class (#4879 / #101 D5).
forge_pr_activity "$n" || true
} | sort | tail -n1 } | sort | tail -n1
)" )"
age=$((NOW - $(date -d "$last_activity" +%s))) last_activity_epoch="$(date -d "$last_activity" +%s)"
if has_label blocked || [ "$age" -le "$STALE_AFTER" ]; then age=$((NOW - last_activity_epoch))
# needs-ruling joins blocked here: waiting on a human is legitimately quiet
# (#50 D10). The 7-day nudge is #52's, once for both surfaces.
if has_label blocked || has_label needs-ruling || [ "$age" -le "$STALE_AFTER" ]; then
if has_label stale; then if has_label stale; then
run gh issue edit "$n" -R "$REPO" --remove-label stale >/dev/null label_write "$n" --remove-label stale || return 1
log "#$n: unstale" log "#$n: unstale"
fi fi
elif ! has_label stale; then elif ! has_label stale; then
run gh issue edit "$n" -R "$REPO" --add-label stale >/dev/null label_write "$n" --add-label stale || return 1
log "#$n: stale ($((age / 3600))h quiet)" log "#$n: stale ($((age / 3600))h quiet)"
fi fi
# ---- the ruling invariants (#52): the bare-flag check + the 7-day nudge --
# The stale EXEMPTION above is #51's; these are the sweep halves that ride
# the same real-activity computation (lib/ruling.sh, shared with the issue
# side). Behind the flag check so flag-free PRs — all of them, almost
# always — cost no extra API reads.
if has_label needs-ruling; then
reconcile_ruling "$n" "$last_activity_epoch" "$NOW"
fi
# `attention` belongs on the assigned issue that owns the claim, never on
# a pull request (#232). Behind the label gate so ordinary PRs pay no read.
if has_label attention; then
reconcile_attention "$n" pr "$(jq '.assignees | length' <<<"$PR_JSON")" ""
fi
} }
main() { main() {
# BEFORE anything reads the board (#188). Every call site below is still
# `gh`, so that is what this declares — honestly, which is the point: on
# a Forgejo consumer the preflight refuses here instead of letting the
# sweep run blind and print "reconciled." over zero PRs (rig run 979).
# The forge is decided once, here, before anything reads the board, and
# the backend that can speak it is loaded (#188). The CEREMONY_FORGE_CLIENT
# wrapper that stood here died with the call-site port: it declared "this
# code uses gh", which stopped being true the moment every site went
# through the shim, and leaving it would have defaulted the forgejo path
# into the very client its own preflight refuses.
forge_preflight || return 1
# "" means decide from the environment; forge_select takes an explicit
# forge only in tests.
forge_select "" || return 1
REPO="${REPO:?set REPO to owner/name}" REPO="${REPO:?set REPO to owner/name}"
LABELS_CONF="${LABELS_CONF:-.github/labels.conf}" LABELS_CONF="${LABELS_CONF:-.github/labels.conf}"
load_config "$LABELS_CONF" load_config "$LABELS_CONF"
NOW="$(date +%s)" NOW="$(date +%s)"
if [ "${GITHUB_EVENT_NAME:-}" = workflow_dispatch ]; then # The bootstrap keys on the INPUT, never the event name. It used to test
log "workflow_dispatch: bootstrapping the taxonomy" # GITHUB_EVENT_NAME = workflow_dispatch — correct while an operator's manual
# dispatch was the only dispatch there was, and wrong from #209 on, when the
# trigger job made EVERY event-woken sweep a workflow_dispatch run: the
# bootstrap=no input became inert by construction, and every board event
# re-upserted the taxonomy (ceremony#215 — runs 459/523, then venue drill
# runs 16/17, which bootstrapped on a delivered "no" and caught this).
if [ "${BOOTSTRAP:-no}" = yes ]; then
log "bootstrap=yes: bootstrapping the taxonomy"
bootstrap_labels bootstrap_labels
fi fi
# The repo's label set, read ONCE per sweep — reconcile_pr filters every # The repo's label set, read ONCE per sweep — reconcile_pr filters every
# add against it, because one unknown name fails the whole edit call. # add against it, because one unknown name fails the whole edit call.
REPO_LABELS="$(gh label list -R "$REPO" --limit 200 --json name --jq '.[].name' 2>/dev/null || echo "")" REPO_LABELS="$(forge_label_list 2>/dev/null || echo "")"
[ -z "$REPO_LABELS" ] && log "WARNING: could not read the label set — applying labels unfiltered" [ -z "$REPO_LABELS" ] && log "WARNING: could not read the label set — applying labels unfiltered"
missing_core_labels_warning "$(core_label_rows)" "$REPO_LABELS"
local n local n output status total=0 unreadable=0 write_failures=0 sampled_reason=""
for n in $(gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'); do while IFS= read -r n; do
( [ -n "$n" ] || continue
PR_JSON="$(gh api "repos/$REPO/pulls/$n")" total=$((total + 1))
status=0
output="$(
(
PR_JSON="$(forge_api "repos/$REPO/pulls/$n")"
DRAFT="$(jq -r '.draft' <<<"$PR_JSON")" DRAFT="$(jq -r '.draft' <<<"$PR_JSON")"
AUTHOR="$(jq -r '.user.login' <<<"$PR_JSON")" AUTHOR="$(jq -r '.user.login' <<<"$PR_JSON")"
set_required_bots "$AUTHOR" set_required_bots "$AUTHOR"
HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")" HEAD_SHA="$(jq -r '.head.sha' <<<"$PR_JSON")"
BASE_SHA="$(jq -r '.base.sha' <<<"$PR_JSON")"
MERGE_BASE_SHA="$(jq -r '.merge_base // empty' <<<"$PR_JSON")"
LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")" LABELS="$(jq -r '.labels[].name' <<<"$PR_JSON")"
REQUESTED="$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")" # This allow-list answers whether a row is a submitted, gradeable review;
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict # bot_verdict separately answers what that submitted verdict says (#235).
REVIEWS_JSON="$(gh api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \ # PENDING drafts and Forgejo REQUEST_REVIEW request rows are not reviews.
| jq -s '[.[] | select(.state != "PENDING")]')" REVIEWS_JSON="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
| jq -s '[.[] | select(.state == "APPROVED"
or .state == "CHANGES_REQUESTED"
or .state == "REQUEST_CHANGES"
or .state == "COMMENTED"
or .state == "COMMENT")]')"
# Read AFTER the reviews: review_filter_probe captures REVIEWS_JSON at
# this boundary. The request set itself comes from the backend's exact
# live representation rather than being derived from verdicts (#238).
REQUESTED="$(forge_pr_review_requests "$n")"
# mergeability + the check rollup, the two facts the state machine was # mergeability + the check rollup, the two facts the state machine was
# blind to (#136). `gh pr view` rather than the REST PR object: the API's # blind to (#136). `gh pr view` rather than the REST PR object: the API's
# `mergeable` is a tri-state boolean that GitHub computes lazily, while # `mergeable` is a tri-state boolean that GitHub computes lazily, while
@ -543,19 +1055,91 @@ main() {
# Failure to read them is NOT fatal and NOT treated as broken — an API # Failure to read them is NOT fatal and NOT treated as broken — an API
# hiccup must never flap every PR into needs-rebase, so both degrade to # hiccup must never flap every PR into needs-rebase, so both degrade to
# the "do not know" value that triggers nothing. # the "do not know" value that triggers nothing.
GH_VIEW="$(gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup 2>/dev/null || echo '{}')" # The WHY goes to gh's stderr, and 2>/dev/null threw it away — a
# permanent denial and a network hiccup left byte-identical evidence,
# and #95 had to infer a cause from a control case instead of reading
# it off a run (wrongly, it turned out). Captured into a file (#101
# D2), never left to interleave raw into the per-PR output block,
# where an unlucky line could collide with a matched string.
GH_VIEW_ERR_FILE="$(mktemp)"
GH_VIEW="$(forge_pr_view "$n" 2>"$GH_VIEW_ERR_FILE" || echo '{}')"
GH_VIEW_ERR="$(cat "$GH_VIEW_ERR_FILE")"
rm -f "$GH_VIEW_ERR_FILE"
MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")" MERGEABLE="$(jq -r '.mergeable // "UNKNOWN"' <<<"$GH_VIEW")"
CHECKS="$(checks_state <<<"$GH_VIEW")" CHECKS="$(checks_state <<<"$GH_VIEW")"
# Read failed: leave this PR exactly as it is. Recomputing on facts we # Read failed: leave this PR exactly as it is. Recomputing on facts we
# did not read is how an API hiccup turns into a false "merge me" — # did not read is how an API hiccup turns into a false "merge me" —
# and the next tick is 15 minutes away, not 15 hours. # and the next tick is 15 minutes away, not 15 hours.
if [ "$CHECKS" = UNREADABLE ]; then if [ "$CHECKS" = UNREADABLE ]; then
# Two lines on purpose (#101 D1): the sweep detects a wholly blind
# pass by whole-line-matching the counted line below, so the reason
# rides its OWN line — folding it in would silently break the
# `unreadable` counter and the wholly-blind warning #96 landed.
log "#$n: could not read mergeability/checks — left alone this pass" log "#$n: could not read mergeability/checks — left alone this pass"
log "#$n: read failed: $(read_failure_reason "$GH_VIEW_ERR")"
exit 0 exit 0
fi fi
# The head's own clock, for the blocker:unrequested grace (#236 D2). One
# read, pinned to the head SHA — not `gh pr view --json commits`, which
# asks for the FIRST hundred commits and would date a longer PR by a
# commit that is not its head. Last of the fetches on purpose: a PR the
# skip above walked away from must not pay for it, and neither do drafts,
# which never reach that blocker. Empty (a failed read, or a body without
# the field) leaves the blocker unjudged, by unrequested_quiescent.
HEAD_COMMIT_AT=""
if [ "$DRAFT" != true ]; then
HEAD_COMMIT_ERR_FILE="$(mktemp)"
HEAD_COMMIT_AT="$(forge_commit_at "$HEAD_SHA" \
2>"$HEAD_COMMIT_ERR_FILE" || echo "")"
HEAD_COMMIT_ERR="$(cat "$HEAD_COMMIT_ERR_FILE")"
rm -f "$HEAD_COMMIT_ERR_FILE"
case "$HEAD_COMMIT_AT" in
"" | null)
# Say why it degraded (#101 D2/D4), on its own line: this one
# narrows a blocker rather than skipping the PR, so it must not
# read as the wholly-blind shape the counted line above matches.
HEAD_COMMIT_AT=""
log "#$n: could not read the head commit's date: $(read_failure_reason "$HEAD_COMMIT_ERR") — blocker:unrequested not judged this pass" ;;
esac
fi
reconcile_pr "$n" reconcile_pr "$n"
) || log "#$n: reconcile failed — continuing with the remaining PRs" ) 2>&1
done )" || status=$?
[ -n "$output" ] && printf '%s\n' "$output"
if grep -qxF "labels: #$n: could not read mergeability/checks — left alone this pass" <<<"$output"; then
unreadable=$((unreadable + 1))
# the first observed reason stands in for the sweep in the blind warning
if [ -z "$sampled_reason" ]; then
sampled_reason="$(sed -n "s/^labels: #$n: read failed: //p" <<<"$output" | head -n1)"
fi
elif [ "$status" -ne 0 ]; then
# The per-PR tolerance is right and stays: one bad PR must not blind the
# sweep over the rest of the board. What was missing is the sweep-level
# accounting — a failed WRITE has to reach main's exit code, or a builder
# satisfies every task and the sweep still prints `reconciled.` over an
# edit that never happened (#192, @kimi-reviewer-andresmgsl #5189).
#
# Reads stay tolerated: an unreadable fact is already reported by the
# blind-sweep warning and leaves the board untouched. A write is
# different — the board and the tree now disagree.
if grep -q "^labels: #$n: label edit FAILED" <<<"$output"; then
write_failures=$((write_failures + 1))
log "#$n: reconcile failed on a WRITE — continuing the sweep, but it will not report success"
else
log "#$n: reconcile failed — continuing with the remaining PRs"
fi
fi
done < <(forge_pr_list)
blind_sweep_warning "$unreadable" "$total" "$sampled_reason"
if [ "$write_failures" -gt 0 ]; then
# The line must not contain the literal "reconciled." ANYWHERE — "NOT
# reconciled." still does, and a consumer grepping a job-log tail for that
# token would find it after a write that did not happen
# (@codex-reviewer-andresmgsl). The test asserts the whole output is free
# of it, not merely that the success prefix is absent.
log "$write_failures label write(s) attempted did not happen — sweep incomplete"
return 1
fi
log "reconciled." log "reconciled."
} }

View file

@ -0,0 +1,8 @@
name: Derive scope labels
description: Additively apply path-derived scope:* labels to a PR — the only write is POST (issue #130)
runs:
using: composite
steps:
- name: derive and add scope labels
shell: bash
run: bash "$GITHUB_ACTION_PATH/labels-scope.sh"

View file

@ -0,0 +1,185 @@
#!/usr/bin/env bash
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
set -euo pipefail
else
# Fixture tests source the pure functions and deliberately inspect failures.
set -u
fi
# shellcheck source=lib/forge.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
# labels-scope.sh — the additive half of the labels automation: derive
# scope:* labels from a PR's changed paths and ADD them, touching nothing
# else. This seat belonged to actions/labeler@v5 until #130: even under
# `sync-labels: false`, labeler computes (labels-fetched-at-job-start
# derived) and writes it back with `PUT /issues/{n}/labels`
# (src/labeler.ts: api.setLabels — a full replace), so any label applied
# between its read and its write is silently removed. On ceremony#128 the
# builder's `release` — the merge door's declared-intent read — landed in
# that window and vanished two seconds later; v6 and v7 write the same
# way, so the fix is this replacement, not a newer pin.
#
# The only write here is `POST /issues/{n}/labels`: GitHub adds the named
# labels, ignores ones already present, and removes nothing. A label
# applied while this runs survives by construction.
#
# The path mapping stays in the consumer's .github/labeler.yml, read via
# the API at CONFIG_REF — the base branch, never the PR head, so a PR
# cannot label itself by editing the mapping. The accepted shape is the
# one every governed repo uses:
#
# scope:name:
# - changed-files:
# - any-glob-to-any-file: ["glob", ...]
#
# in any YAML spelling (block or flow; a glob list may be a single
# string). Anything else — all-globs-to-all-files, branch matchers,
# negations, backslash escapes — is refused loudly rather than
# half-honoured: this parser exists to make one write additive, not to
# reimplement minimatch. Globs support `**` (crosses `/`), `*` and `?`
# (do not); a leading dot is not special; the whole path must match
# (`README` matches README, never docs/README).
log() { printf 'labels-scope: %s\n' "$*"; }
run() { # every mutation goes through here — DRY_RUN=1 logs instead of doing
if [ -n "${DRY_RUN:-}" ]; then log "DRY_RUN: $*"; else "$@"; fi
}
glob_to_regex() { # $1 = glob (the subset above) → anchored ERE, one line
local glob="$1" out="" c i=0 n
n="${#glob}"
while [ "$i" -lt "$n" ]; do
c="${glob:i:1}"
case "$c" in
\*)
if [ "${glob:i:2}" = '**' ]; then
out="$out.*"
i=$((i + 2))
continue
fi
out="${out}[^/]*"
;;
\?) out="${out}[^/]" ;;
[a-zA-Z0-9_/-]) out="$out$c" ;;
*) out="$out\\$c" ;; # every other byte is literal — ., +, {, (, …
esac
i=$((i + 1))
done
printf '^%s$\n' "$out"
}
parse_labeler_config() { # labeler.yml on stdin → "label<TAB>glob" lines
# The jq variable is $lbl, not $label: **`label` is a reserved keyword in
# jq's grammar** (`label $out | ... | break $out`), and jq 1.6 refuses
# `$label` outright — "syntax error, unexpected label, expecting IDENT".
# jq 1.7 parses it, which is why this survived: GitHub's hosted
# ubuntu-latest ships 1.7, and the Forgejo runner image
# (ghcr.io/catthehacker/ubuntu:act-22.04) ships **1.6**. Measured on both,
# 2026-08-02 (#188). Every scope-label derivation on this forge failed on a
# jq compile error before the config was even read.
# yq only normalizes YAML to JSON; the shape contract is enforced in jq,
# where an unsupported key is a loud error naming the label it sits under.
yq -o=json '.' - | jq -r '
if type != "object" then
error("labeler config: top level must be a map of label -> rules")
else . end
| to_entries[]
| .key as $lbl
| (if (.value | type) != "array" then
error("labeler config: \($lbl): rules must be a list")
else .value end)[]
| (if type != "object" then
error("labeler config: \($lbl): each rule must be a map")
else . end)
| ((keys - ["changed-files"]) as $extra
| if ($extra | length) > 0 then
error("labeler config: \($lbl): unsupported key(s) \($extra | join(", ")) — the scope job accepts changed-files/any-glob-to-any-file only (#130)")
else . end)
| .["changed-files"]
| (if type == "object" then [.]
elif type == "array" then .
else error("labeler config: \($lbl): changed-files must be a list") end)[]
| (if type != "object" then
error("labeler config: \($lbl): each changed-files entry must be a map")
else . end)
| ((keys - ["any-glob-to-any-file"]) as $extra
| if ($extra | length) > 0 then
error("labeler config: \($lbl): unsupported matcher(s) \($extra | join(", ")) — the scope job accepts any-glob-to-any-file only (#130)")
else . end)
| .["any-glob-to-any-file"]
| (if type == "string" then [.]
elif type == "array" then .
else error("labeler config: \($lbl): any-glob-to-any-file must be a glob or a list of globs") end)[]
| (if type != "string" then
error("labeler config: \($lbl): globs must be strings")
elif contains("\\") then
error("labeler config: \($lbl): backslash in glob \(.) — escapes are not supported (#130)")
else . end)
| [$lbl, .] | @tsv
'
}
derive_labels() { # $1 = "label<TAB>glob" lines, $2 = changed files (one per
# line) → matched labels, one per line, config order, deduped
local tsv="$1" files="$2" label glob matched=$'\n'
[ -n "$files" ] || return 0
while IFS=$'\t' read -r label glob; do
[ -n "$label" ] || continue
case "$matched" in *$'\n'"$label"$'\n'*) continue ;; esac
if printf '%s\n' "$files" | grep -qE -- "$(glob_to_regex "$glob")"; then
matched="$matched$label"$'\n'
printf '%s\n' "$label"
fi
done <<<"$tsv"
}
main() {
# See labels-reconcile's twin (#188). This action's degraded read was the
# quietest of the three: an unreadable mapping and an absent one produced
# the same "nothing to derive" no-op, so on Forgejo a PR simply got no
# scope labels and nothing said why.
# The forge is decided once, here, before anything reads the board, and
# the backend that can speak it is loaded (#188). The CEREMONY_FORGE_CLIENT
# wrapper that stood here died with the call-site port: it declared "this
# code uses gh", which stopped being true the moment every site went
# through the shim, and leaving it would have defaulted the forgejo path
# into the very client its own preflight refuses.
forge_preflight || return 1
# "" means decide from the environment; forge_select takes an explicit
# forge only in tests.
forge_select "" || return 1
REPO="${REPO:?set REPO to owner/name}"
PR_NUMBER="${PR_NUMBER:?set PR_NUMBER to the pull request number}"
CONFIG_REF="${CONFIG_REF:?set CONFIG_REF to the base commit the mapping is read at}"
CONFIG_PATH="${CONFIG_PATH:-.github/labeler.yml}"
local config tsv files labels
# No mapping is a consumer that has not adopted scope labels — an
# advisory no-op, not a red run (scopes locate, they do not alert). A
# mapping that EXISTS but does not parse still fails loudly below.
if ! config="$(forge_api "repos/$REPO/contents/$CONFIG_PATH?ref=$CONFIG_REF" \
--jq '.content' 2>/dev/null | base64 -d)" || [ -z "$config" ]; then
log "no $CONFIG_PATH at $CONFIG_REF — nothing to derive"
return 0
fi
tsv="$(parse_labeler_config <<<"$config")"
files="$(forge_api --paginate "repos/$REPO/pulls/$PR_NUMBER/files" --jq '.[].filename')"
labels="$(derive_labels "$tsv" "$files")"
if [ -z "$labels" ]; then
log "#$PR_NUMBER: no scope labels derived"
return 0
fi
local args=()
while IFS= read -r label; do args+=("$label"); done <<<"$labels"
run forge_labels_add "$PR_NUMBER" "${args[@]}"
log "#$PR_NUMBER: scopes -> $(paste -sd, <<<"$labels") (additive POST; already-present names are no-ops)"
}
# sourced by test/labels-scope.test.sh for the fixture tests; executed in CI
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
main "$@"
fi

View file

@ -0,0 +1,16 @@
name: Refs not closing
description: >-
Refuse a pull request whose `Refs #N` promise contradicts GitHub's
closing-issue graph (#218). GitHub recognizes closing keywords anywhere
in a PR body, including ordinary prose and code spans; the action reads
the graph once and lets a pure script decide whether any Refs target is
already scheduled to close.
runs:
using: composite
steps:
- name: refs targets are not closing
shell: bash
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: bash "$GITHUB_ACTION_PATH/run.sh"

View file

@ -0,0 +1,124 @@
#!/usr/bin/env bash
set -euo pipefail
# refs-not-closing.sh <body-file> [<closing-issue-number> ...] — compare the
# issues a PR promises merely to reference with GitHub's closing-issue graph
# (#218). The graph is authoritative because it includes both closing
# keywords and sidebar links. The body still matters: only an issue named by
# `Ref #N` or `Refs #N` is protected, so an ordinary `Closes #N` PR remains
# untouched.
#
# This decision stays network-free so test/refs-not-closing.test.sh can drive
# the incident matrix offline. The composite action gathers both facts in one
# GraphQL read and passes them here. A failed or partial read never reaches
# this script: action.yml refuses it before asking for a verdict.
body_file="${1:-}"
shift || true
if [ -z "$body_file" ] || [ ! -f "$body_file" ]; then
echo "refs-not-closing: body file is missing or unreadable: ${body_file:-<none>}" >&2
exit 1
fi
declare -A closing=()
for issue in "$@"; do
case "$issue" in
''|*[!0-9]*)
echo "refs-not-closing: invalid closing issue number: '$issue'" >&2
exit 1
;;
esac
closing["$issue"]=1
done
mapfile -t refs_targets < <(
awk '
{
rest = tolower($0)
while (match(rest, /(^|[^[:alnum:]_])refs?[[:space:]]*:?[[:space:]]*[[]?#[0-9]+/)) {
token = substr(rest, RSTART, RLENGTH)
sub(/^.*#/, "", token)
print token + 0
rest = substr(rest, RSTART + RLENGTH)
}
}
' "$body_file" | sort -nu
)
intersections=()
for issue in "${refs_targets[@]}"; do
if [ -n "${closing[$issue]:-}" ]; then
intersections+=("$issue")
fi
done
if [ "${#intersections[@]}" -eq 0 ]; then
echo "refs-not-closing: no Refs target appears in GitHub's closing-issue graph"
exit 0
fi
sentence_for_issue() {
local issue="$1" mode="$2"
awk -v issue="$issue" -v mode="$mode" '
/^[[:space:]]*$/ {
if (paragraph != "") {
text = text paragraph "\n\n"
paragraph = ""
}
next
}
{
if (paragraph != "") paragraph = paragraph " "
paragraph = paragraph $0
}
END {
text = text paragraph
count = split(text, sentence, /[.!?][[:space:]]+|\n\n+/)
if (mode == "closing") {
needle = "(^|[^[:alnum:]_])(close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)[[:space:]]+#[[:space:]]*" issue "([^0-9]|$)"
} else {
needle = "(^|[^[:alnum:]_])refs?[[:space:]]*:?[[:space:]]*\\[?#[[:space:]]*" issue "([^0-9]|$)"
}
for (i = 1; i <= count; i++) {
lower = tolower(sentence[i])
if (match(lower, needle)) {
matched = substr(sentence[i], RSTART, RLENGTH)
sub(/^[^[:alnum:]_]*/, "", matched)
sub(/[^0-9]*$/, "", matched)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", sentence[i])
printf "%s\t%s\n", matched, sentence[i]
exit
}
}
}
' "$body_file"
}
{
printf 'refs-not-closing: Refs target(s) also scheduled to close:'
printf ' #%s' "${intersections[@]}"
printf '\n'
for issue in "${intersections[@]}"; do
detail="$(sentence_for_issue "$issue" closing)"
if [ -z "$detail" ]; then
detail="$(sentence_for_issue "$issue" refs)"
printf " #%s: GitHub reports a closing reference; no adjacent closing keyword was found, so inspect the Development sidebar link.\n" "$issue"
fi
if [ -n "$detail" ]; then
matched="${detail%%$'\t'*}"
sentence="${detail#*$'\t'}"
printf ' matched: %s\n' "$matched"
printf ' sentence: %s\n' "$sentence"
fi
done
cat <<'EOF'
A `Refs #N` PR must not close N. Remove the sidebar closing link or rewrite
an adjacent closing-keyword sentence so the number comes first (`#N is
closed by hand`) or the number is omitted (`triage closes the issue by
hand`). Backticks do not protect a closing keyword from GitHub's parser.
EOF
} >&2
exit 1

88
actions/refs-not-closing/run.sh Executable file
View file

@ -0,0 +1,88 @@
#!/usr/bin/env bash
set -euo pipefail
# The composite action's executable boundary (#218). Keeping the gather here
# lets the offline contract test replace the forge and prove that failed and
# partial reads cannot accidentally produce a green verdict.
#
# THE GATHER IS REST, THROUGH THE SHIM (#199). It was a single GraphQL query
# issued through `gh`, asking GitHub for `closingIssuesReferences` — its own
# parse of the closing keywords. Forgejo serves no GraphQL surface at all:
# `/api/graphql` 404s on this instance, and a real forgejo-runner job arrives
# with GITHUB_GRAPHQL_URL set to the empty string (lib/forge.sh's header).
# There was nothing to translate it to, so it is re-expressed — exactly as
# #188 re-expressed its own two GraphQL sites — over two reads both backends
# already serve, plus a parser this repo owns.
#
# WHAT THE GRAPH GAVE THAT TWO READS MUST REPLACE. This file used to call the
# graph "authoritative because it includes both closing keywords and sidebar
# links". Those two halves resolve differently here:
#
# sidebar links Forgejo has no such concept — an issue is closed by a
# keyword, never by a manual link. Nothing is lost.
# commit messages Forgejo DOES honour closing keywords in commit messages.
# A body-only parse would miss a PR that closes an issue
# from a commit subject and let through exactly the
# contradiction this action exists to catch.
#
# Hence both reads, unioned. The commit half is not optional.
# shellcheck source=lib/forge.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/forge.sh"
# shellcheck source=lib/issue_references.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/issue_references.sh"
# shellcheck source=lib/closes_references.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../../lib/closes_references.sh"
# No CEREMONY_FORGE_CLIENT declaration any more (#199 removes #198's): this
# file speaks the shim, not a client. Fail CLOSED at the action boundary all
# the same — "this action cannot produce a verdict" is the ACTION's contract
# and stays a refusal, while "this check should not block the board" is the
# CALLER's decision (@codex-reviewer-andresmgsl, #198).
forge_preflight || exit 1
forge_select "" || exit 1
REPO="${REPO:-${GITHUB_REPOSITORY:-}}"
[ -n "$REPO" ] || {
echo "refs-not-closing: set REPO or GITHUB_REPOSITORY to owner/name" >&2
exit 1
}
[ -n "${PR_NUMBER:-}" ] || {
echo "refs-not-closing: pull request number is unavailable" >&2
exit 1
}
body_file="$(mktemp)"
closing_file="$(mktemp)"
trap 'rm -f "$body_file" "$closing_file"' EXIT
# A read that fails must never reach the parser: an empty body parses to an
# empty closing set, which is a PASSING verdict this action never earned.
# `set -e` covers the assignment, and the explicit checks below name which
# read failed rather than leaving the operator to guess.
if ! forge_api "repos/$REPO/pulls/$PR_NUMBER" --jq '.body // ""' >"$body_file"; then
echo "refs-not-closing: could not read PR $PR_NUMBER's body — refusing a verdict" >&2
exit 1
fi
# --paginate carries the completeness proof: the forgejo backend walks pages
# and then compares what it collected against the server's declared
# x-total-count, refusing a short gather (#188, #4699). That IS this action's
# `hasNextPage` refusal, relocated rather than reinvented — upstream refused
# past 100 closing references rather than issue a partial verdict, and an
# incomplete commit read is the same failure wearing REST's clothes.
commits_file="$(mktemp)"
trap 'rm -f "$body_file" "$closing_file" "$commits_file"' EXIT
if ! forge_api --paginate "repos/$REPO/pulls/$PR_NUMBER/commits" \
--jq '.[].commit.message' >"$commits_file"; then
echo "refs-not-closing: could not read PR $PR_NUMBER's commits completely — refusing a partial verdict" >&2
exit 1
fi
# The union. closes_references is line-oriented, so concatenating the body and
# every commit message and parsing once IS the union of parsing each — and it
# keeps one parse to reason about instead of two that could drift.
cat "$body_file" "$commits_file" | closes_references >"$closing_file"
mapfile -t closing_issues <"$closing_file"
bash "$GITHUB_ACTION_PATH/refs-not-closing.sh" \
"$body_file" "${closing_issues[@]}"

View file

@ -0,0 +1,32 @@
name: Runner isolated
description: >-
Assert that no `pull_request`- (or `pull_request_target`-) triggered
workflow names a self-hosted runner (#58). A pull_request workflow runs
the PR branch's code — from a fork, unreviewed code — and a self-hosted
runner executes it on our own hardware, inside our own network; the
fork-PR write-token and secrets toggles protect credentials, not the
runner. The rule is file-level, deliberately: a file whose trigger block
names pull_request and which names self-hosted anywhere fails, even
across jobs — the fix is to split the workflow. Known gaps, so silence
is never read as coverage: workflow_call reachability is not followed
(a pull_request caller plus a self-hosted callee goes unseen), and
indirection is not resolved (runner groups, matrix or expression values
for runs-on). A missing workflows directory is a pass. The caller must
have checked out its own repository first: the guard reads the
consumer's tree at the workspace.
inputs:
workflows-dir:
description: >-
Directory scanned for `*.yml`/`*.yaml` workflow files. A missing
directory passes — a guard that fails on absence is a guard nobody
adopts.
required: false
default: .github/workflows
runs:
using: composite
steps:
- name: runner isolated
shell: bash
env:
WORKFLOWS_DIR: ${{ inputs.workflows-dir }}
run: bash "$GITHUB_ACTION_PATH/runner-isolated.sh"

View file

@ -0,0 +1,185 @@
#!/usr/bin/env bash
set -euo pipefail
# runner-isolated.sh [<workflows-dir>] — assert that no `pull_request`-
# triggered workflow in the tree names a self-hosted runner (#58; epic #56
# decision D5).
#
# The threat, stated once: a `pull_request` workflow runs code from the
# PR's branch. When that branch comes from a fork and the repo's fork-PR
# settings do not require approval, that code is UNREVIEWED. Point such a
# job at a self-hosted runner and unreviewed code executes on our own
# hardware, inside our own network. Nothing else in the fleet's setup
# gates that path — the write-token and secrets toggles (correctly off,
# #16's ruling) protect credentials, not the runner.
#
# Nothing was wrong the day this was written: incubator's deploy.yml is
# push-triggered and self-hosted (legal), its pr-checks.yml is
# PR-triggered and hosted, and the rule lived as a sentence in
# pr-checks.yml's header, kept true by whoever remembered it. This guard
# is that sentence moved into CI — the same move drill-recorded made
# after three releases shipped through a documented-but-unenforced gate
# (its header: "that is not a gate, it is luck with good manners").
#
# THE RULE IS FILE-LEVEL, DELIBERATELY. The precise rule — no JOB
# reachable from a pull_request trigger runs self-hosted — needs a YAML
# parser, and a second parser in bash is a new class of guard bug bought
# in exchange for permitting a file shape we do not want. So: a file
# FAILS when its trigger block names pull_request (which also matches
# pull_request_target — intended) AND it names a self-hosted runner
# anywhere, even in a different job. The false positive has a clean,
# safer fix — SPLIT THE WORKFLOW; incubator already keeps pr-checks.yml
# apart from deploy.yml, which is the shape this guard asks for. False
# NEGATIVES are what a security guard must not have, and file-level
# granularity has none for the modelled threat: it can only be stricter
# than the precise rule, never laxer.
#
# Self-hosted detection covers two shapes, because the same-line rule
# alone ("runs-on and self-hosted on one line") would pass the
# block-sequence form — a false negative, the one defect this guard is
# not allowed to have:
#
# runs-on: [self-hosted, ci-runner] # same line: caught
# runs-on: # block sequence: the bare
# - self-hosted # key opens a window over
# - ci-runner # its `- …` list items
#
# KNOWN LIMITS, named in action.yml's description too so a consumer
# never reads silence as coverage:
# - workflow_call is not treated as PR-reachable in v1: a pull_request
# caller plus a self-hosted callee is a real path this guard does not
# see. Following `uses:` across files is the YAML-parsing problem
# again, and the family has no such caller today (ceremony's own
# release-exercise.yml is ubuntu-latest).
# - indirection is not resolved: a runner group (`runs-on: {group: …}`)
# or a matrix/expression value can reach self-hosted hardware without
# the string appearing on any line this guard reads.
#
# Comments are skipped on BOTH halves of the rule: a workflow that merely
# mentions self-hosted in prose is not the bug (incubator's pr-checks.yml
# header is exactly that prose), and a guard that cried wolf on comments
# would be turned off within a week. A missing workflows directory is a
# PASS, not an error — most repos in the family have one, but a guard
# that fails on absence is a guard nobody adopts.
#
# A file of its own (not inlined in action.yml) so
# test/runner-isolated.test.sh can drive it against constructed trees —
# the same discipline as the four guards beside it.
workflows_dir="${1:-${WORKFLOWS_DIR:-.github/workflows}}"
if [ ! -d "$workflows_dir" ]; then
echo "runner-isolated: no workflows directory at '$workflows_dir' — nothing to scan"
exit 0
fi
shopt -s nullglob
files=("$workflows_dir"/*.yml "$workflows_dir"/*.yaml)
if [ "${#files[@]}" -eq 0 ]; then
echo "runner-isolated: 0 workflow files under '$workflows_dir' — nothing to scan"
exit 0
fi
offenders=0
for file in "${files[@]}"; do
pr_triggered=0
in_on=0
in_runs_on=0
hits=()
lineno=0
while IFS= read -r line || [ -n "$line" ]; do
lineno=$((lineno + 1))
# A blank line ends nothing: it is not a top-level key, and a YAML
# sequence may legally continue past one.
case "$line" in
*[![:space:]]*) ;;
*) continue ;;
esac
stripped="${line#"${line%%[![:space:]]*}"}"
# Comment lines are invisible to both halves of the rule.
case "$stripped" in
'#'*) continue ;;
esac
# A line starting a top-level key opens or closes the trigger block.
# YAML 1.1 parses bare `on` as a boolean, so some repos quote the
# key — a guard that missed '"on":' would silently pass the file it
# most needs to read.
case "$line" in
[![:space:]]*)
in_runs_on=0
case "$line" in
'on:'*| '"on":'* | "'on':"*) in_on=1 ;;
*) in_on=0 ;;
esac
;;
esac
# Half one: the trigger. Checked on the `on:` line itself (scalar and
# inline-list shapes) and on every line of its block.
if [ "$in_on" -eq 1 ]; then
case "$line" in
*pull_request*) pr_triggered=1 ;;
esac
fi
# Half two: the runner. The window a bare `runs-on:` key opened over
# its list items closes at the first line that is not a `- …` item.
if [ "$in_runs_on" -eq 1 ]; then
case "$stripped" in
'-'*)
case "$line" in
*self-hosted*) hits+=("$lineno: $line") ;;
esac
;;
*) in_runs_on=0 ;;
esac
fi
case "$line" in
*runs-on*)
case "$line" in
*self-hosted*) hits+=("$lineno: $line") ;;
esac
case "$stripped" in
'runs-on:' | 'runs-on:'[[:space:]]*)
rest="${stripped#runs-on:}"
rest="${rest#"${rest%%[![:space:]]*}"}"
case "$rest" in
'' | '#'*) in_runs_on=1 ;;
esac
;;
esac
;;
esac
done <"$file"
if [ "$pr_triggered" -eq 1 ] && [ "${#hits[@]}" -gt 0 ]; then
offenders=$((offenders + 1))
{
echo "runner-isolated: $file is pull_request-triggered and names a self-hosted runner:"
printf ' %s\n' "${hits[@]}"
} >&2
fi
done
if [ "$offenders" -gt 0 ]; then
cat >&2 <<EOF
runner-isolated: $offenders offending workflow file(s). A pull_request
workflow runs the PR branch's code; from a fork that code is unreviewed,
and a self-hosted runner would execute it on our own hardware, inside
our own network. The unblock is to SPLIT THE WORKFLOW: PR-triggered
checks in one file on hosted runners, self-hosted work behind
push/dispatch triggers in another — the shape incubator's pr-checks.yml
and deploy.yml already have. The rule is file-level on purpose (this
script's header): a file mixing the two is one editing mistake away
from being the real bug.
EOF
exit 1
fi
echo "runner-isolated: ${#files[@]} workflow file(s) scanned under '$workflows_dir' — no pull_request-triggered work on a self-hosted runner"

128
bin/changelog-assemble Executable file
View file

@ -0,0 +1,128 @@
#!/usr/bin/env bash
# Fold the changelog.d/ fragments into one release section (#112, #114).
# Run by hand in the release PR, from a checkout of ceremony at the
# consumer's pin — deliberately not a CI step, because the assembled
# section must land in the PR diff where the panel reads it.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=lib/changelog.sh
source "$ROOT/lib/changelog.sh"
usage() {
echo "usage: changelog-assemble <version> [<date>] [--changelog <file>] [--dir <dir>] [--check]" >&2
exit 2
}
refuse() {
printf 'changelog-assemble: %s\n' "$1" >&2
exit 1
}
ver=""
stamp=""
changelog="CHANGELOG.md"
dir="changelog.d"
checkmode=0
while [ $# -gt 0 ]; do
case "$1" in
--changelog)
[ $# -ge 2 ] || usage
changelog="$2"
shift 2
;;
--dir)
[ $# -ge 2 ] || usage
dir="$2"
shift 2
;;
--check)
checkmode=1
shift
;;
-*)
usage
;;
*)
if [ -z "$ver" ]; then
ver="$1"
elif [ -z "$stamp" ]; then
stamp="$1"
else
usage
fi
shift
;;
esac
done
[ -n "$ver" ] || usage
[ -n "$stamp" ] || stamp="$(date -u +%F)"
[ -f "$changelog" ] || refuse "no such file: $changelog"
# Every entry in the directory must be a publishable fragment. A stray file
# in a machine-assembled directory is a mistake to surface, never to skip —
# except README.md, the directory's marker (#112 D1), and 'shape', the
# declared anchor (#182), which changelog_shape_problem validates below and
# which deliberately survives the consumption: it is the declaration a
# reader in the directory finds, and it must still be there after the
# release empties the fragments out.
for f in "$dir"/*; do
[ -e "$f" ] || continue
[ "${f##*/}" = "README.md" ] && continue
[ "${f##*/}" = "shape" ] && continue
if ! diagnosis="$(changelog_fragment_problem "$f")"; then
refuse "$diagnosis"
fi
done
fragments="$(changelog_fragments "$dir")"
[ -n "$fragments" ] || refuse "zero fragments in '$dir' — a release publishes prose; refusing to publish an empty release"
if ! diagnosis="$(changelog_shape_problem "$changelog" "$dir")"; then
refuse "$diagnosis"
fi
if ! body="$(changelog_assemble "$dir")"; then
refuse "$body"
fi
# Whole-version match, as everywhere in this family: 0.2.0-rc1 in the
# changelog never blocks assembling 0.2.0.
if awk -v ver="$ver" '/^## / && $2 == ver { found = 1; exit } END { exit !found }' "$changelog"; then
refuse "$changelog already has a section for '$ver' — the ceremony was already run"
fi
if [ "$checkmode" = 1 ]; then
# --check prints the body, not the heading: the body is the invariant a
# caller can compare — #116 checks it against the release PR's stamped
# section, whose date the PR chose, not the day the check runs.
printf '%s\n' "$body"
exit 0
fi
heading="## $ver — $stamp"
lineno="$(grep -n -m1 '^## ' "$changelog" | cut -d: -f1)" || lineno=""
tmp="$(mktemp "$changelog.XXXXXX")"
if [ -n "$lineno" ]; then
{
head -n "$((lineno - 1))" "$changelog"
printf '%s\n\n%s\n\n' "$heading" "$body"
tail -n +"$lineno" "$changelog"
} >"$tmp"
else
# No section yet: the whole file is preamble, and the section goes after it.
{
cat "$changelog"
printf '\n%s\n\n%s\n' "$heading" "$body"
} >"$tmp"
fi
mv "$tmp" "$changelog"
count=0
while IFS= read -r f; do
rm -- "$f"
count=$((count + 1))
done <<<"$fragments"
printf "changelog-assemble: wrote '%s' to %s, consumed %d fragment(s)\n" "$heading" "$changelog" "$count" >&2

View file

@ -16,9 +16,10 @@ fi
exit 1 exit 1
} }
notes="$(changelog_section "$changelog" "$ver")" if ! diagnosis="$(changelog_section_problem "$changelog" "$ver")"; then
[ -n "$notes" ] || { echo "changelog-section: $changelog has no section for '$ver' — the release PR assembles the section (changelog-assemble) with version + date BEFORE the tag" >&2
echo "changelog-section: $changelog has no section for '$ver' — the release PR stamps the Unreleased section with version + date BEFORE the tag" >&2 printf 'changelog-section: %s\n' "$diagnosis" >&2
exit 1 exit 1
} fi
notes="$(changelog_section "$changelog" "$ver")"
printf '%s\n' "$notes" printf '%s\n' "$notes"

7
changelog.d/269.md Normal file
View file

@ -0,0 +1,7 @@
### Fixed
- The upstream deferral record now names its floor and a dated measurement instead of a frozen `0.7.4` ceiling that expired before it was written (#269).
### Changed
- `docs/UPSTREAM-SYNC.md` now records unconditionally that the next sync campaign merges rather than ports, and so advances the ancestry baseline (#269).

3
changelog.d/271.md Normal file
View file

@ -0,0 +1,3 @@
### Fixed
- Forgejo release publishing now stages drafts until assets upload, rolls back failures, and recovers stranded same-tag drafts before retrying (#271).

3
changelog.d/273.md Normal file
View file

@ -0,0 +1,3 @@
### Fixed
- Merge-door release reruns resume after a matching stranded tag while completed or conflicting releases still refuse with precise diagnostics (#273).

3
changelog.d/275.md Normal file
View file

@ -0,0 +1,3 @@
### Fixed
- Compare release-shaped pull requests with their merge base so later base-branch releases do not create phantom version-change warnings (#275).

11
changelog.d/README.md Normal file
View file

@ -0,0 +1,11 @@
# changelog.d/ — the next release's section, one fragment per issue
Machine-assembled by `bin/changelog-assemble` (#112): every PR that changes
behavior writes one file here — `<issue>.md`, the exact prose that will be
published, nothing else — and the release PR folds them all into the next
`## X.Y.Z — DATE` section of `CHANGELOG.md`, consuming them. Distinct
filenames never conflict, which is this directory's whole reason to exist.
This README is the marker that keeps the directory tracked when it holds no
fragments (#112 D1) — `changelog-armed` refuses a tree without it; do not
delete it. The `shape` sentinel beside it declares the set's shape —
`grouped` here, so every fragment carries `### ` headings (#182).

1
changelog.d/shape Normal file
View file

@ -0,0 +1 @@
grouped

View file

@ -27,7 +27,7 @@ edits to this guide (#12).
- **The `release` label must exist** before the first ceremony PR — it is - **The `release` label must exist** before the first ceremony PR — it is
the merge door's declared-intent read the merge door's declared-intent read
([lib/facts.sh](../lib/facts.sh#L88-L101)). Bootstrap it via the labels ([lib/facts.sh](../lib/facts.sh#L88-L101)). Bootstrap it via the labels
workflow's `workflow_dispatch` sweep caller's `workflow_dispatch`
([Labels automation](#labels-automation)), or create it by hand, ([Labels automation](#labels-automation)), or create it by hand,
matching the core table matching the core table
([actions/labels-reconcile/labels-reconcile.sh](../actions/labels-reconcile/labels-reconcile.sh#L369)): ([actions/labels-reconcile/labels-reconcile.sh](../actions/labels-reconcile/labels-reconcile.sh#L369)):
@ -48,8 +48,23 @@ the machinery at all:
[lib/decide.sh](../lib/decide.sh#L70-L74)). Bootstrapping at `-dev` [lib/decide.sh](../lib/decide.sh#L70-L74)). Bootstrapping at `-dev`
keeps the repo clear of it entirely. (`package-json` backend: the keeps the repo clear of it entirely. (`package-json` backend: the
`version` field, same rule.) `version` field, same rule.)
2. **An armed `CHANGELOG.md`**: a preamble plus an empty `## Unreleased` 2. **An armed changelog: a `CHANGELOG.md` preamble plus `changelog.d/`.**
section for the first entries to land under. The changelog file starts as preamble only — no section; the first
release writes the first one. The fragments directory beside it is the
arming (#112): it carries a `README.md` marker naming the assembler and
the doctrine — take ceremony's own
[changelog.d/README.md](../changelog.d/README.md) at the pin — which is
what keeps the directory tracked while it holds no fragments and what
`changelog-armed` asserts. Every behavior-change PR then writes
`changelog.d/<issue>.md` ([The changelog rule](#the-changelog-rule));
the release PR assembles the section
([Assembling a release section](#assembling-a-release-section)).
Fragment mode is available at `0.2.0` and later, and not in `0.1.0`.
A consumer pinned to `0.1.0` bootstraps the legacy shape instead — the
preamble plus an empty `## Unreleased` section for entries to land
under — and converts on the pin bump to `0.2.0` or later; never mix
refs to adopt it early.
3. **`drills/README.md`** defining what a drill *means* in this repo — 3. **`drills/README.md`** defining what a drill *means* in this repo —
each repo names its own each repo names its own
([the drill doctrine](../README.md#the-drill-doctrine)). Plain ([the drill doctrine](../README.md#the-drill-doctrine)). Plain
@ -62,14 +77,25 @@ the machinery at all:
```yaml ```yaml
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
# changelog-monotonic compares HEAD against the merge base; a # changelog-monotonic and changelog-assembled compare HEAD
# checkout that cannot resolve it is a hard failure in CI, not # against the merge base; a checkout that cannot resolve it is
# a skip (a guard that can quietly stop guarding is the failure # a hard failure in CI, not a skip (a guard that can quietly
# shape these checks exist to refuse). # stop guarding is the failure shape these checks exist to
# refuse).
fetch-depth: 0 fetch-depth: 0
- uses: heavy-duty/ceremony/actions/changelog-armed@<pinned-tag> - uses: heavy-duty/ceremony/actions/changelog-armed@<pinned-tag>
- uses: heavy-duty/ceremony/actions/changelog-monotonic@<pinned-tag> - uses: heavy-duty/ceremony/actions/changelog-monotonic@<pinned-tag>
# changelog-assembled is available at 0.2.0 and later, not in
# 0.1.0. Adopt this step with the pin bump to 0.2.0 or later;
# never mix refs. Green NOTICE on every non-release PR; on a
# release PR it asserts the stamped section is exactly the
# fragments it consumed.
- uses: heavy-duty/ceremony/actions/changelog-assembled@<pinned-tag>
- uses: heavy-duty/ceremony/actions/drill-recorded@<pinned-tag> - uses: heavy-duty/ceremony/actions/drill-recorded@<pinned-tag>
# runner-isolated is available at 0.2.0 and later, not in 0.1.0.
# Adopt this step with the pin bump to 0.2.0 or later; never mix
# refs.
- uses: heavy-duty/ceremony/actions/runner-isolated@<pinned-tag>
``` ```
`changelog-armed` and `drill-recorded` take `changelog-armed` and `drill-recorded` take
@ -77,17 +103,74 @@ the machinery at all:
inputs and defaults are in its `action.yml` inputs and defaults are in its `action.yml`
([actions/](../actions/)). Adopting the agent team flow adds the ([actions/](../actions/)). Adopting the agent team flow adds the
`docs-sync` step ([below](#adopting-the-agent-team-flow)). `docs-sync` step ([below](#adopting-the-agent-team-flow)).
6. **Labels automation** (optional but recommended): the caller from
[Labels automation](#labels-automation), plus `.github/labels.conf` `runner-isolated` asserts that no `pull_request`-triggered workflow
names a self-hosted runner — a PR workflow runs the branch's code, and
unreviewed fork code must never execute on your own hardware
([#58](https://github.com/heavy-duty/ceremony/issues/58)). It fires on
the PR that first mixes a PR trigger and a self-hosted `runs-on` in
one file; the unblock is splitting the workflow. A repo with **no**
self-hosted runner still wants it: the guard's value is the day
somebody adds one.
This guide documents `main`. A marker is the literal token
`**unreleased**` immediately followed by its issue citation (for example,
`(#238)`); whitespace between them may include a line break. A citation is
mandatory, because a marker the guard cannot trace is a marker it cannot
prove false. A token inside an inline-code span is a mention, not a marker;
spans are ignored individually, so unrelated inline code cannot hide one.
A marker for this repository's own issue uses bare `#N`. Cross-repo
citations such as `(crew#293)` satisfy the traceability rule but are not
compared with this repository's release section. The ceremony-only
`marker-check.sh` guard enforces these rules. The release PR that ships the machinery clears, in that same PR,
every marker its own assembled section makes false: the section cites its
issues, each marker cites the same issue, and the release PR's diff is the
one place both halves are visible at once (#221). If an action does not exist at the
consumer's pinned tag, adopt it with the pin bump to the first tag that
carries it; never mix a moving or newer ref into an otherwise exact-pin
consumer. In particular, `0.1.0` carries `changelog-armed`,
`changelog-monotonic` and `drill-recorded` plus `docs-sync`, but not
`changelog-assembled` or `runner-isolated`.
6. **`.github/workflows/refs-guard.yml`** — the body-aware guard is its own
caller because `edited` is load-bearing: #200 gained its accidental
closing keyword after the PR opened, with no push to wake ordinary CI.
It costs the consumer one read-only workflow file and no other machinery:
```yaml
name: Refs guard
on:
pull_request:
types: [opened, edited, reopened, synchronize]
permissions:
contents: read
pull-requests: read
jobs:
refs-not-closing:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: heavy-duty/ceremony/actions/refs-not-closing@<pinned-tag>
```
`refs-not-closing` is available at `0.6.0` and later (#218). Adopt this
caller with that ordinary pin bump; never point only this file at a
moving or newer ref.
7. **Labels automation** (optional but recommended): the two callers from
[Labels automation](#labels-automation) — the event-facing labels
caller and the sweep caller (#209) — plus `.github/labels.conf`
(panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the (panel + the repo's `scope:*` rows) and `.github/labeler.yml` (the
path→scope globs). Run `workflow_dispatch` once — **this bootstraps path→scope globs). Run the sweep caller's `workflow_dispatch` once —
the taxonomy, `release` label included**. **this bootstraps the taxonomy, `release` label included** — and use it
7. **The artifact hook** (optional): `.github/actions/release-artifact/` again whenever an operator needs a full-board sweep immediately.
8. **The artifact hook** (optional): `.github/actions/release-artifact/`
per [The artifact hook](#the-artifact-hook). No hook → the source per [The artifact hook](#the-artifact-hook). No hook → the source
tarball is the package. tarball is the package.
From there the flow is the doctrine: ordinary PRs add their changelog From there the flow is the doctrine: ordinary PRs write their fragment,
line, the ceremony PR makes the ceremony PR makes
[the three stamps](../README.md#what-a-release-is), a human merges, the [the three stamps](../README.md#what-a-release-is), a human merges, the
machine transcribes. machine transcribes.
@ -107,12 +190,27 @@ precisely so the machinery is safe to work on
sibling `push:` silently kills a door (rig's review catch). sibling `push:` silently kills a door (rig's review catch).
- [ ] Swap the guard *script* steps in `ci.yml` for the `uses:` steps in - [ ] Swap the guard *script* steps in `ci.yml` for the `uses:` steps in
the bootstrap list above (with `fetch-depth: 0` on the checkout). the bootstrap list above (with `fetch-depth: 0` on the checkout).
- [ ] Add `refs-guard.yml` from the bootstrap list with the same ceremony
pin as the release caller and CI guard steps.
- [ ] Replace `labels.yml` with the caller from - [ ] Replace `labels.yml` with the caller from
[Labels automation](#labels-automation); extract [Labels automation](#labels-automation) and add the sweep caller
`labels-sweep.yml` beside it (#209); extract
`.github/labels.conf` from the old reconciler's embedded config — `.github/labels.conf` from the old reconciler's embedded config —
the `panel=` roster line and the repo's `scope:*` rows the `panel=` roster line and the repo's `scope:*` rows
([the format](#labels-automation)). `.github/labeler.yml` stays as ([the format](#labels-automation)). `.github/labeler.yml` stays as
it is (path globs are inherently repo-specific). it is (path globs are inherently repo-specific).
- [ ] Convert the changelog to fragments (requires a pin at the first tag
carrying fragment mode — not `0.1.0`): move every entry under
`## Unreleased` to `changelog.d/<issue>.md`, verbatim — the filename
is derivable from the entry's own `(#N)`; an entry citing several
issues goes to the file for the first cited — delete the
`## Unreleased` heading, and add the `changelog.d/README.md` marker
([bootstrap step 2](#bootstrap-a-new-repo)). Published sections stay
byte-identical; `changelog-monotonic` proves that on the conversion
PR, and `changelog-armed` refuses a surviving `## Unreleased` the
moment the directory exists. Rewrite the repo's own contributor
docs that say "add a line under `## Unreleased`" in the same PR —
split either way, main lies for as long as the split lasts.
- [ ] Delete the now-shadowed copies — zero shared scripts remain: - [ ] Delete the now-shadowed copies — zero shared scripts remain:
`.github/scripts/release-notes.sh` (box, cast) or `.github/scripts/release-notes.sh` (box, cast) or
`release-lib.sh` (rig), `changelog-armed.sh` (box), `release-lib.sh` (rig), `changelog-armed.sh` (box),
@ -121,14 +219,26 @@ precisely so the machinery is safe to work on
- [ ] Trim the repo's test suite to repo-specific tests: the machinery - [ ] Trim the repo's test suite to repo-specific tests: the machinery
tests go — they live in this repo's `test/` now, run by its CI — tests go — they live in this repo's `test/` now, run by its CI —
while the repo's own surfaces stay (box/rig's install-channel halves while the repo's own surfaces stay (box/rig's install-channel halves
of `test/release.sh`, cast's `install-sh` tests). of `test/release.sh`, cast's `install-sh` tests). A machinery test
*file* goes whole when its subject moved (rig's
`test/labels-reconcile.sh` sourced the deleted reconciler), and so
do tests that pin the old workflow's shape — a grep or awk against
`release.yml`/`ci.yml` internals fails against the caller stub, not
because the stub is wrong (rig #13's conversion).
- [ ] Sweep the repo's other docs for pointers at the deleted paths —
`drills/README.md` and any labels doc typically cite the old
`.github/scripts/*.sh` by path; repoint them at the pinned actions.
A repo carrying its own copy of a doc the mirror vendors (rig's
root `LABELS.md`) retires it in the same PR: a hand-maintained
copy beside a machine-verified mirror is the drift the mirror
exists to end.
- [ ] Shrink CONTRIBUTING's release section to a pointer at - [ ] Shrink CONTRIBUTING's release section to a pointer at
[this repo's README](../README.md) plus what is genuinely per-repo: [this repo's README](../README.md) plus what is genuinely per-repo:
the drill meaning (`drills/README.md`), artifact notes, the the drill meaning (`drills/README.md`), artifact notes, the
changelog house style if it differs from changelog house style if it differs from
[the portable rule](#the-changelog-rule). [the portable rule](#the-changelog-rule).
- [ ] What stays, per repo, forever: `VERSION` (or the `package.json` - [ ] What stays, per repo, forever: `VERSION` (or the `package.json`
version), `CHANGELOG.md`, `drills/`, `.github/labeler.yml`, version), `CHANGELOG.md`, `changelog.d/`, `drills/`, `.github/labeler.yml`,
`.github/labels.conf`, the optional `.github/labels.conf`, the optional
`.github/actions/release-artifact/` — the full kept-vs-moved table `.github/actions/release-artifact/` — the full kept-vs-moved table
is in [#1](https://github.com/heavy-duty/ceremony/issues/1). is in [#1](https://github.com/heavy-duty/ceremony/issues/1).
@ -181,8 +291,9 @@ tag door instead (the known first-release edge, cast#111).
### The artifact hook ### The artifact hook
If the repository contains `.github/actions/release-artifact/action.yml`, If the repository contains `.github/actions/release-artifact/action.yml`,
both doors invoke it — after the tag exists, before `gh release create` both doors invoke it — after the tag exists, before the release is
with the release `version` as input and `RELEASE_ASSETS_DIR` exported. published — with the release `version` as input and `RELEASE_ASSETS_DIR`
exported.
Contract for hook authors: Contract for hook authors:
- Drop finished files into `$RELEASE_ASSETS_DIR`; every file there is - Drop finished files into `$RELEASE_ASSETS_DIR`; every file there is
@ -193,9 +304,28 @@ Contract for hook authors:
A failed hook leaves the tag created but no release published. Recovery is A failed hook leaves the tag created but no release published. Recovery is
the tag door's semantics: fix the cause, then delete and re-push the same the tag door's semantics: fix the cause, then delete and re-push the same
tag (the tag door publishes for it), or run `gh release create` by hand from tag — the tag door publishes for it. That path is forge-neutral and is the
a fixed tree. The merge door's nothing-exists assert will refuse a re-run of one to prefer.
the completed merge, by design.
If you must publish by hand instead, use whatever your forge provides;
ceremony itself no longer names a client here, because on a Forgejo runner
there is no `gh` to name (#191):
```sh
# GitHub
gh release create "$VER" --verify-tag --title "$VER" \
--notes-file notes.md -R "$OWNER/$REPO"
# Forgejo / Gitea — POST /repos/{owner}/{repo}/releases
curl -sS -X POST -H "Authorization: token $TOKEN" \
-H 'Content-Type: application/json' \
-d "$(jq -nc --arg t "$VER" --rawfile b notes.md \
'{tag_name:$t,name:$t,body:$b}')" \
"$FORGE/api/v1/repos/$OWNER/$REPO/releases"
```
The merge door's nothing-exists assert will refuse a re-run of the
completed merge, by design.
No hook → no assets: for a pure-bash tree, GitHub's source tarball for the No hook → no assets: for a pure-bash tree, GitHub's source tarball for the
tag IS the package. Worked examples land with the conversions: cast's tgz tag IS the package. Worked examples land with the conversions: cast's tgz
@ -203,64 +333,374 @@ build (#15) and incubator's GHCR image push (#16).
## Labels automation ## Labels automation
The reusable labels workflow owns two independent jobs: additive path-based The labels automation is two reusable workflows since #209, adopted
`scope:*` labels and reconciliation of PR state, blockers, handoff, and stale together at the same pin:
status. The consumer keeps its path mapping in `.github/labeler.yml` and its
- **`labels.yml`** — the event-facing half, called on PR and issue events.
Same-repository PRs keep two write-capable jobs: additive path-based
`scope:*` labels, and a few-seconds `trigger` job that wakes the sweep by
dispatching the consumer's sweep caller (a REST `POST` to the forge's own
`${GITHUB_API_URL}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches`,
plain `GITHUB_TOKEN``workflow_dispatch` is
one of the two documented exemptions from the token's no-retrigger rule,
so no PAT anywhere in the path and no loop: the sweep dispatches
nothing). On this Forgejo a fork-headed `pull_request_target` token is
read-only, so those two jobs do not run. A successful `fork_head` job names
the disposition: the scheduled sweep later reconciles state, blockers, and
handoff, while path-derived `scope:*` labels are not applied to fork heads.
Apply those scope labels manually when an outside contribution needs them.
- **`labels-sweep.yml`** — the reconcile sweep: PR state, blockers,
handoff, stale status, the issue work queue, and the `needs-ruling`
invariants on both surfaces — the bare-flag check and the 7-day
comment-only nudge (#52; the sweep reads that flag and never writes it).
Detached from PR-triggered runs on purpose: all sweeps serialize through
one shared concurrency group, and GitHub records every queue-displaced
run as CANCELLED — harmless (the surviving sweep does its work) until it
rode a `pull_request_target` run and the ❌ landed on that PR's checks
as fake red CI that GitHub refuses to rerun (crew#250: `gh run rerun`
and its `--failed`/`--job` forms all decline a queue-displaced run).
Behind its own caller, a displaced sweep cancels on the
Actions tab, attached to no PR. Same-repository PR checks show `scope` and
the green `trigger`; fork-headed PRs show the green `fork_head` disposition
and wait for scheduled state, blocker, and handoff reconciliation. The sweep
does not supply their path-derived scope labels.
The consumer keeps its path mapping in `.github/labeler.yml` and its
review panel plus scope taxonomy in `.github/labels.conf`. review panel plus scope taxonomy in `.github/labels.conf`.
The complete caller is: **Additive means additive** (available at `0.3.0` and later — #130): the
scope job's only label
write is `POST /issues/{n}/labels`, which adds the derived scopes and removes
nothing, so a label applied while the job runs survives it. Earlier tags used
`actions/labeler@v5`, which — even under `sync-labels: false` — replaces the
whole label set and silently drops a label written mid-job (ceremony#128 lost
its `release` that way). With the same pin bump, `.github/labeler.yml` keeps
its format but the accepted shape becomes exactly the one this guide has
always shown: label → `changed-files``any-glob-to-any-file`, block or flow
style, globs over `**`, `*` and `?` (`**` crosses `/`, the others do not; the
whole path must match). Any other labeler key — `all-globs-to-all-files`,
branch matchers, negations — fails the run loudly instead of being
half-honoured. The reconcile sweep also warns (never sets) when a non-draft
PR carries a bare `X.Y.Z` version differing from its base but no `release`
label — the merge door would refuse that merge, and the sweep says so first.
The complete event-facing caller is:
```yaml ```yaml
name: labels name: labels
on: on:
schedule: [{cron: "*/15 * * * *"}] # advisory; the handoff label is the real wake
workflow_dispatch: # bootstraps missing labels on a fresh repo
pull_request_target: pull_request_target:
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled] # These carry the head/draft/review facts state:* derives from. Same-repo
# heads take the instant write + sweep-dispatch path; this Forgejo gives
# fork heads a read-only token, so state, blocker, and handoff reconciliation
# waits for the scheduled sweep; path-derived scope labels require a manual
# write when wanted.
# labeled/unlabeled are the same-repo handoff wake; synchronize re-derives
# on every push. review_requested/review_request_removed shipped in 0.3.0
# (ceremony#137) and wake the same-repo sweep when the panel is asked.
types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
# Available at 0.2.0 and later (the first tag carrying ceremony#32); a
# consumer pinned to 0.1.0 omits this block.
issues:
# Narrowed (#199) to the actions carrying a queue-state change the hourly
# cron cannot wait one cadence for: opened → the mint→needs-triage check,
# closed → the blocker-closes→ready self-heal, edited → a body rewrite of the
# `Blocked by #N` declaration the sweep parses, reopened → a closed issue
# re-entering the queue. Dropped: labeled/unlabeled/assigned/unassigned —
# validation + the 48h claim clock, caught within one cadence, and
# labeled/unlabeled were the issues-churn source. The handoff wake is
# pull_request_target:labeled, not issues, so this leaves it intact.
types: [opened, closed, edited, reopened]
permissions: permissions:
contents: read contents: read
checks: read # mergeability/check-rollup read for PR state
statuses: read # commit-status rollup read for PR state
actions: write # the trigger job's dispatch of the sweep caller (#209, #205)
issues: write issues: write
pull-requests: write pull-requests: write
jobs: jobs:
labels: labels:
uses: heavy-duty/ceremony/.github/workflows/labels.yml@<pinned-tag> uses: heavy-duty/ceremony/.github/workflows/labels.yml@<pinned-tag>
# If the sweep caller below is named anything but labels-sweep.yml,
# say so: `with: { sweep_workflow: <filename> }`. Ceremony's own
# dogfood does (self-labels-sweep.yml).
``` ```
`pull_request_target` is intentional: fork PRs need the base repository's And the complete sweep caller, `labels-sweep.yml` beside it — the hourly
token to write labels. The reusable workflow executes no PR code. It checks cron lives HERE since #209, not on the labels caller:
out only the consumer's base branch and the pinned ceremony implementation.
`.github/labels.conf` has one mandatory panel setting followed by zero or ```yaml
more scope rows: name: labels-sweep
on:
# The consumer owns this cadence (#203). Hourly is the recommended default
# when no other engine drives board state: the cron is then the sweep's only
# wake for a review verdict landing (the labels caller has no
# pull_request_review trigger), blocker:ci-red
# set/cleared, blocker:conflict when another PR merges under this one, and
# time-based stale / 48h claim-reclaim, plus every state, blocker, and handoff
# transition for a fork-headed PR on this Forgejo. The sweep never applies
# path-derived scope labels. Issue events and same-repo PR events carry the
# rest in seconds, one trigger-job dispatch away. Hourly trades ≤1h of
# latency on the scheduled classes while cutting nominal
# sweeps from four an hour to one at GitHub's 1-minute floor. Do not delete
# the cron: it is their discovery path. If another engine writes some of
# those transitions, only the classes with no other writer bound the cadence;
# relax it only as that list shrinks.
schedule: [{cron: "0 * * * *"}]
# A manual full-board sweep. A bare dispatch (input default "yes") also
# bootstraps the taxonomy on a fresh repo. The labels caller's trigger job
# wakes this workflow with bootstrap=no on every issue and same-repo PR
# event, so the declared input is part of the contract: a dispatch naming an
# undeclared input is refused, and the trigger job goes loudly red.
workflow_dispatch:
inputs:
bootstrap:
description: Bootstrap the label taxonomy before sweeping
type: choice
options: ["yes", "no"]
default: "yes"
permissions:
contents: read
checks: read # mergeability/check-rollup read for PR state
statuses: read # commit-status rollup read for PR state
actions: read # workflow-run nodes inside the check rollup — private repos do not imply it (incubator#60)
issues: write
pull-requests: write
jobs:
sweep:
uses: heavy-duty/ceremony/.github/workflows/labels-sweep.yml@<pinned-tag>
with:
# Pass the dispatch input through the workflow_call boundary
# explicitly — a called workflow must not rely on reading the caller's
# event inputs (ceremony#215 measured that failing). Empty (schedule)
# maps to "no" explicitly, so a cron-woken sweep never re-upserts the
# taxonomy.
bootstrap: ${{ inputs.bootstrap || 'no' }}
# If this repo's PR-facing labels caller is named anything but `labels`,
# pass that name alongside: `pr_workflow_name: <name>`. The sweep exports
# it as SELF_WORKFLOW so the label machinery's own check entries (scope,
# trigger) never count toward blocker:ci-red — a red trigger means "fix
# the caller", which no PR edit can do (#208 reads it).
```
Naming any permission sets every unnamed permission to `none`. Public
repositories allow check data to be read regardless, but a private consumer
needs the explicit reads above; without them the failure appears as an empty
`state:*` axis on the board rather than a red workflow run. The labels
caller's `actions: write` is different — it is required everywhere, public
repos included: the trigger job's dispatch is a write. Without it, issue and
same-repository PR event runs go red at the trigger. Fork-headed PR runs do
not enter that write path on this Forgejo; they remain green and depend on a
healthy scheduled sweep for state, blocker, and handoff reconciliation. That
sweep does not apply their path-derived scope labels.
**The failure mode to know before bumping**: a consumer that bumps its pin
to a #209-carrying tag without adding the sweep caller gets a loud red trigger
on every issue and same-repository PR event (workflow-not-found; likewise on a
sweep caller missing its `bootstrap` input, or a labels caller missing
`actions: write`). Fork-headed PR runs deliberately skip that trigger and stay
green, so their correctness is proven by the sweep caller's presence and its
latest scheduled run, not by the PR check alone. Never read a green
`fork_head` disposition as evidence that the scheduled sweep exists. Make the
adoption one atomic PR — pin bump, sweep caller file, and `actions: write` line
together.
The `issues:` trigger is available at `0.2.0` and later — `0.2.0` is the
first tag carrying ceremony#32. A consumer pinned to `0.1.0` omits it. Adopt
it only by bumping every ceremony reference to `0.2.0` or later; never mix
refs to adopt it early. The type list has grown then narrowed across tags:
`0.2.0` (ceremony#32) shipped `[opened, labeled, unlabeled, assigned,
unassigned, closed]`; `0.3.0` (ceremony#144) added `edited` and `reopened`;
ceremony#199 narrows it to `[opened, closed, edited, reopened]` and relaxes the
cron to hourly, so a consumer picks up the smaller trigger surface at the pin
bump to the first tag carrying ceremony#199. The narrowing drops
`labeled`/`unlabeled`/`assigned`/`unassigned` — validation and the 48h claim
clock, which the hourly cron catches within one cadence, and `labeled`/
`unlabeled` were the issues-churn source — while **keeping** #144's `edited`/
`reopened`: those carry a queue-state change an event uniquely carries (a body
rewrite of `Blocked by #N`, and a closed issue re-entering the queue), so the
must-fail in ceremony#199 keeps them on events. `opened` drives the
mint→`needs-triage` check and `closed` the blocker-closes→`ready` self-heal;
the stub and ceremony's own caller stay byte-for-byte identical, the parity
#144 established.
The two-caller split (ceremony#209) is available at `0.4.1` and later. A
consumer pinned to `0.4.0` or earlier keeps the previous single-caller
shape — the labels caller carrying the cron, `workflow_dispatch`, and
`actions: read` — and adopts the split at the pin bump to `0.4.1` or
later. Never mix refs to adopt it early.
The migration is **one atomic PR** with exactly four edits — crew, the
consumer whose displaced-check evidence drove #209 (crew#227, crew#250),
is the worked example; written here against `0.4.1`, the first tag
carrying the split:
1. **Pin bump, every reference together** ([Version pinning](#version-pinning)):
`0.4.0``0.4.1` in the labels caller's `uses:` line **and in every
other ceremony `uses:` in the repo** — crew also pins in
`release.yml` and its `ci.yml` guard steps. A repo on the doctrine
mirror re-runs `docs-sync --fix` in the same PR.
2. **New file `.github/workflows/labels-sweep.yml`** — the sweep caller
stub above, verbatim, `bootstrap` input included (the trigger's
`-f bootstrap=no` dispatch is refused if the input is undeclared).
3. **The hourly cron RELOCATES — it is moved, never copied.** Delete the
`schedule:` block (and the bare `workflow_dispatch:`) from the labels
caller in the same edit that adds the sweep caller.
**Warning**: a consumer that copies the sweep caller and leaves the
old schedule on the labels caller gets DOUBLE sweeps — every cron tick
fires both callers into the one shared `labels-reconcile` group — so
displacement goes **up**, and the fix reads as the bug getting worse.
4. **`actions: write` on the labels caller** — consumers carry
`actions: read` today (crew does); the trigger job's dispatch is a
write. The sweep caller keeps `actions: read`.
Bump without the sweep caller and the trigger job goes red on every issue and
same-repository PR event. Fork-headed PRs stay green, receive state, blocker,
and handoff reconciliation only from the scheduled sweep, and never receive
path-derived scope labels automatically; apply those manually when wanted.
Never split these four edits across PRs.
`pull_request_target` is intentional: same-repository PRs keep the base
repository's write token without executing PR code. This Forgejo still gives
fork-headed `_target` runs a read-only token, so they attempt no writes. The
scheduled sweep later reconciles state, blockers, and handoff; it does not
apply path-derived scope labels to those heads. The reusable workflows check
out only the consumer's base branch and the pinned ceremony implementation.
The #52 ruling invariants ride exactly these triggers — but the caller above
is no longer the #18 shape, so adopting current triggers is a stub edit, not
a bare pin bump. `review_requested` and `review_request_removed` on
`pull_request_target:` shipped in `0.3.0` (ceremony#137). It clears
`blocker:unrequested` the moment the panel is asked on a same-repository head;
fork heads wait for the sweep cadence on this Forgejo. A consumer picks the
events up by pinning `0.3.0` or later, never through mixed refs.
`.github/labels.conf` has one mandatory panel setting, one mandatory
`triage-actors` setting, zero or more optional per-author panel rows, and
then zero or more scope rows:
```text ```text
panel=claude-bot example-codex-bot example-grok-bot panel=claude-bot example-codex-bot example-grok-bot
panel[example-builder]=example-codex-bot example-grok-bot
triage-actors=example-triage-bot
scope:cli|C5DEF5|The command-line surface scope:cli|C5DEF5|The command-line surface
scope:docs|C5DEF5|Documentation scope:docs|C5DEF5|Documentation
``` ```
The panel is whitespace-separated. Label rows use exactly The mandatory `triage-actors=` setting is likewise accepted at `0.2.0` and
later, and not by `0.1.0`. At that tag the file contains `panel=` plus scope
rows only; adding `triage-actors=` is a parse failure, not an ignored setting.
Add it at the same pin bump as the `issues:` trigger — `0.2.0` or later —
never before it and never through mixed refs.
The optional `panel[<login>]=` rows are available at `0.5.0` and later (#224). A row names
the effective panel for PRs authored by exactly that login — the reconciler
computes that PR's required set from the row, minus the author as always —
and every other author keeps the base `panel=`, which stays mandatory. The
panel is configured or it is the base one: ceremony never infers a reviewer
set from the model behind a login. On any earlier pin a bracketed row is a
**parse failure, not an ignored setting** — the same shape `triage-actors=`
bought at `0.2.0`, but harsher in practice: the reconcile job dies on every
PR event and every sweep until the row is removed, so the whole label board
goes down. Add the row only at or after the pin bump that carries it, never
before it and never through mixed refs.
Both actor lists are whitespace-separated. `triage-actors` names the identities
allowed to mint work issues without the sweep applying `needs-triage`. Label rows use exactly
`name|color|description`; blank lines are ignored and extra pipes are refused. `name|color|description`; blank lines are ignored and extra pipes are refused.
**Every account in `panel=` must be able to read the repository.** Requesting a
review from someone without read access is refused by the forge, not silently
dropped — on Forgejo with `422 Reviewer can't read`, naming the account
(#188). On a public repo this is satisfied already; on a **private** consumer
it is a real failure mode when a panel member is not on the collaborator
list, and the sweep will report it rather than sweep blind.
There are no comment lines: every non-blank line must be the `panel=`
setting, a `panel[<login>]=` row, the `triage-actors=` setting, or a label
row, so `#`-prefixed prose is a parse failure, not a comment (rig #13's
conversion found this the hard way — keep the file data only).
Core state, blocker, work-queue, and release labels come from ceremony. Scope Core state, blocker, work-queue, and release labels come from ceremony. Scope
rows remain consumer-owned because paths and surfaces differ by repository. rows remain consumer-owned because paths and surfaces differ by repository.
After adding the caller and configuration, run `workflow_dispatch` once to After adding the callers and configuration, dispatch the sweep caller once
bootstrap labels on a fresh repository. Scheduled and PR-triggered runs only to bootstrap labels on a fresh repository. A bare dispatch is also the
reconcile; they do not repeatedly upsert the taxonomy. operator's general manual full-board sweep — the answer when the board
looks wrong now rather than after the next scheduled cadence:
On GitHub, with the `gh` CLI:
```sh
gh workflow run labels-sweep.yml -R <owner>/<repo>
```
On any forge — including Forgejo, whose runners carry no `gh` — the same
dispatch over REST, which is what the trigger job itself sends (#205):
```sh
curl -sS -X POST \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"ref":"main","inputs":{"bootstrap":"yes"}}' \
"$API/repos/<owner>/<repo>/actions/workflows/labels-sweep.yml/dispatches"
```
`$API` is the forge's API root — `https://api.github.com` on GitHub,
`<instance>/api/v1` on Forgejo — and success is `204` with an empty body.
Ceremony dogfoods the callers under the filenames `self-labels.yml` and
`self-labels-sweep.yml`, so the equivalent command in this repository
substitutes that filename. Scheduled and trigger-driven runs only
reconcile; they do not repeatedly upsert the taxonomy (the trigger's
dispatch carries `bootstrap=no`). When a ceremony pin bump adds a core
label, bump the pin first and then re-dispatch; the scheduled sweep warns
when the pinned taxonomy declares a core label the repository lacks.
## Doctrine mirror ## Doctrine mirror
Machinery is consumed by reference — GitHub fetches the workflows and Machinery is consumed by reference — GitHub fetches the workflows and
actions above from the pin at run time — but documents have no runtime: an actions above from the pin at run time — but documents have no runtime: an
agent reads the working tree it stands in. So the agent-facing doc set agent reads the working tree it stands in. So the agent-facing doc set
(ceremony's `docs/VENDORED.txt`: AGENTS.md, TRIAGE.md, BUILDER.md, declared by ceremony's `docs/VENDORED.txt` is vendored into each consumer at **`.ceremony/`**,
REVIEWER.md, LABELS.md) is vendored into each consumer at **`.ceremony/`**,
byte-identical to ceremony at the pin, plus a generated `.ceremony/README.md` byte-identical to ceremony at the pin, plus a generated `.ceremony/README.md`
marking the directory machine-managed. `actions/docs-sync` owns the copy: marking the directory machine-managed. `actions/docs-sync` owns the copy:
`--fix` writes it (and deletes what the manifest dropped — mirror means `--fix` writes it (and deletes what the manifest dropped — mirror means
mirror), `--check` re-diffs it in CI on every PR, so a hand edit or a stale mirror), `--check` re-diffs it in CI on every PR, so a hand edit or a stale
pin goes red instead of quietly governing. pin goes red instead of quietly governing.
`RELEASES.md` joins that mirror with the first tag carrying ceremony#248,
and is available at `0.6.0` and later: consumers add `.ceremony/RELEASES.md`
only with the ordinary pin bump and re-sync, never by copying it ahead of
their pinned doctrine set.
### Read the manifest, never a copy of it
Anything on the consumer's side that needs to know *which* documents are
vendored — a re-vendor script, a `docs-sync` equivalent, the task list of a
conversion issue — reads **the pin's `docs/VENDORED.txt`** and never names
the files itself. The manifest is available at the pinned ref from `0.1.0`
and later — it shipped with `actions/docs-sync` itself (ceremony#19), in the
same commit, and that tool has read it rather than a list since — and it is
one path per line, relative to ceremony's root, blank lines ignored:
```sh
# the vendored doc set at the ref this repo is pinned to
curl -fsSL "https://raw.githubusercontent.com/heavy-duty/ceremony/<pinned-tag>/docs/VENDORED.txt"
```
That is the whole benefit: a doctrine file added in ceremony — `RELEASES.md`
was the last, ceremony#248 — reaches every consumer at its next **ordinary
pin bump**, with **zero list edits** anywhere. A hardcoded list propagates
nothing, and its staleness is silent rather than red: `docs-sync --check`
asserts byte-identity for the files the list names and says nothing at all
about one it omits, so a consumer keeps a green guard while governing
itself with doctrine it no longer has.
What makes reading the manifest *sufficient* — rather than merely better
than a copy — is that ceremony's CI now refuses a root doctrine file that is
declared in neither the manifest nor a short in-script exemption list
(`.github/scripts/vendored-check.sh`), so the manifest at a tag is the
complete set as of that tag. That guarantee holds at `0.6.0` and later
(#251); the manifest is worth reading at every earlier pin regardless, since
it is what `actions/docs-sync` has always mirrored.
The consumer's ci.yml gains the guard alongside the others: The consumer's ci.yml gains the guard alongside the others:
```yaml ```yaml
@ -292,6 +732,26 @@ Bumping the pin re-syncs the mirror in the same PR —
## Version pinning ## Version pinning
**Two ceremonies answer to the same version number.** `heavy-duty/ceremony`
exists on GitHub and on `forgejo.heavyduty.builders`, and the forge tree tracks
upstream's version numbers deliberately (ceremony#197 D2) — so `0.6.0` names a
different tree on each, differing by the forge-compatibility delta. They are
not forks that drifted: the forge tree carries upstream's content and adds to
it (`docs/UPSTREAM-SYNC.md`).
What that means for a consumer:
- **Name the forge you pinned, not just the tag.** `heavy-duty/ceremony@0.6.0`
is ambiguous on its own; the host in your `uses:` line is what disambiguates
it, so do not describe your pin anywhere without it.
- **A tag that exists upstream may not exist here yet.** The forge tree's
`CEREMONY_SELF_REF` takes upstream's number as soon as the sync lands, which
is *before* the release ceremony cuts that tag here. Do not bump a pin to a
version whose tag you have not confirmed on the forge you consume from.
- **The forge tree's `CHANGELOG.md` header names the upstream commit it
carries**, and `.upstream-ref` records the same SHA. That is how you tell
which `0.6.0` you are actually running.
- **Pin an exact ceremony release tag**`@0.1.0`, never a branch and - **Pin an exact ceremony release tag**`@0.1.0`, never a branch and
never a moving major pointer: the family pins things and reviews never a moving major pointer: the family pins things and reviews
updates ([#1 D2](https://github.com/heavy-duty/ceremony/issues/1)). updates ([#1 D2](https://github.com/heavy-duty/ceremony/issues/1)).
@ -303,11 +763,10 @@ Bumping the pin re-syncs the mirror in the same PR —
[releases page](https://github.com/heavy-duty/ceremony/releases) is [releases page](https://github.com/heavy-duty/ceremony/releases) is
that section, verbatim). One bump PR updates **every** ceremony `uses:` that section, verbatim). One bump PR updates **every** ceremony `uses:`
reference in the repo to the new tag — the workflow callers *and* each reference in the repo to the new tag — the workflow callers *and* each
guard step; a release-only setup already has four (the guard step. The exact count is tag-dependent: it is the workflow caller
[release caller](#release-workflow) plus the or callers plus the guards that the pinned tag carries. Changing only
[three CI guards](#bootstrap-a-new-repo)), and changing only one line one line leaves the consumer split across ceremony versions, which the
leaves the consumer split across ceremony versions, which the same-tag same-tag rule above forbids. A repo that has adopted the agent team flow
rule above forbids. A repo that has adopted the agent team flow
additionally bumps the mirror in the same PR — additionally bumps the mirror in the same PR —
[the pin-bump procedure](#the-pin-bump-procedure). [the pin-bump procedure](#the-pin-bump-procedure).
- **One pin governs machinery and doctrine.** The ref in the consumer's - **One pin governs machinery and doctrine.** The ref in the consumer's
@ -320,31 +779,88 @@ Bumping the pin re-syncs the mirror in the same PR —
The portable version of the family's contributor rule — the repo's own The portable version of the family's contributor rule — the repo's own
CONTRIBUTING may sharpen it, but this is the floor the guards assume: CONTRIBUTING may sharpen it, but this is the floor the guards assume:
- **Every PR that changes behavior adds one line** under `## Unreleased`. - **Every PR that changes behavior writes one fragment**:
- **Insert above the heading below — never type over it.** Replacing a `changelog.d/<issue>.md`, named for the authorizing issue —
shipped `## X.Y.Z` heading with your entry deletes that release's `<repo>-<issue>.md` for cross-repo work carrying `Part of <repo>#N`
section, silently; this exact edit is why the so the name is known at claim time and two builders can only collide by
[monotonic guard](../README.md#changelog-monotonic--shipped-headings-are-append-only) working the same issue (#112 D2). Never an edit to `CHANGELOG.md`: the
exists (box#122). release PR assembles the section
([below](#assembling-a-release-section)).
The sole exception is the release PR: it writes no fragment. It consumes
the directory and stamps the section, so a fragment it created would be
absent from
[`changelog-assembled`](https://github.com/heavy-duty/ceremony/blob/a602fd0/actions/changelog-assembled/changelog-assembled.sh)'s
merge-base replay if consumed, or refused by
[`changelog-armed`](https://github.com/heavy-duty/ceremony/blob/a602fd0/actions/changelog-armed/changelog-armed.sh)
if left to survive into the next release. A change that must ship inside
the release PR therefore ships without an entry. If it can wait and wants
an entry, land it as an ordinary PR before the release PR, then rebase and
re-assemble the release.
- **The fragment is the prose, not a description of it** (#112 D3): the
exact lines that will be published — no front-matter, no `## ` heading
(that one is the assembler's to write). `changelog-armed` refuses a
malformed fragment on the PR that wrote it.
- **Grouped repos group inside the fragment**: `### Added`, `### Changed`,
`### Fixed` headings with bullets under them; create `Deprecated`,
`Removed`, or `Security` only when a change genuinely needs that rarer
kind. A repo is grouped or flat, never both (#112 D4). The assembler
merges groups in canonical order — Added, Changed, Fixed, Removed,
Deprecated, Security, then anything else first-seen — and inside a
group entries read newest issue first (#112 D5). Which shape binds is
inferred from the newest published section, unless an optional sentinel
`changelog.d/shape` — one line, exactly `flat` or `grouped` — declares
it and outranks the inference (#182). To flip a repo's shape, land one
PR that adds the sentinel and converts every pending fragment to the
declared shape, bullets byte-identical; the sentinel stays after the
release, as the declaration a reader in the directory finds.
- **One line: say what changed, and stop.** Lead with the surface, not - **One line: say what changed, and stop.** Lead with the surface, not
the mechanism — "`state:needs-human` is set at handoff" beats "the the mechanism — "`state:needs-human` is set at handoff" beats "the
labels workflow now also wakes on `labeled`". The why and the how labels workflow now also wakes on `labeled`". The why and the how
belong in the PR body, where anyone chasing the reasoning already goes. belong in the PR body, where anyone chasing the reasoning already goes.
- **Cite the issue or PR**`(#141)`. - **Cite the issue or PR**`(#141)`.
- **Mark a breaking change** with a leading `BREAKING:`. - **Mark a breaking change** with a leading `BREAKING:`.
- Group under `### Added` / `### Changed` / `### Fixed` / `### Removed`. - A repo not yet on fragment mode — no `changelog.d/` — keeps the legacy
floor until its conversion: one line under `## Unreleased`, inserted
**above** the heading below it, never over it (replacing a shipped
heading deletes that release's section silently — box#122, why the
[monotonic guard](../README.md#changelog-monotonic--shipped-headings-are-append-only)
exists), appended under a standing `### ` heading where the repo groups.
## Assembling a release section
The ceremony PR's changelog stamp is one command, run **by hand, never in
CI** — the assembled section must land in the release PR's diff, where
the panel reads it (#112 D12). A consumer runs the tool from a ceremony
checkout at its own pin:
```sh
git clone --depth 1 --branch <pinned-tag> https://github.com/heavy-duty/ceremony /tmp/ceremony
/tmp/ceremony/bin/changelog-assemble <X.Y.Z>
```
Run it at the repo root. It folds every `changelog.d/` fragment into a
new `## X.Y.Z — DATE` section on top of `CHANGELOG.md` (DATE is today's
UTC date; pass one as a second argument to choose it) and deletes the
fragments it consumed — commit both halves together. `--check` prints the
would-be section body without touching anything; read it before running
the real thing. In CI, `changelog-assembled` replays the run from the
merge base and refuses a stamp that is not byte-for-byte what the
fragments assemble to — a mis-run hand step fails the PR, not the
published release.
## Adopting the agent team flow ## Adopting the agent team flow
The team flow (discussion → triage → issue → build → review → human The team flow (proposal → triage → work issue → build → review → human
merge) is **optional per repo and separable from the release ceremony**: merge) is **optional per repo and separable from the release ceremony**:
a repo can adopt release-only and take the team flow later — incubator's a repo can adopt release-only and take the team flow later — incubator's
initial posture (#16). The model is this repo's own initial posture (#16). The model is this repo's own
[CONTRIBUTING](../CONTRIBUTING.md) ("How the other repos use this"); [CONTRIBUTING](../CONTRIBUTING.md) ("How the other repos use this");
this is the checklist: this is the checklist:
- [ ] **Enable Discussions** — the triage door exists or the pipeline - [ ] **Open the intake door** — install `proposal.yml` and the automatic
has no intake. `needs-triage` flow. A repo whose forge provides Discussions may keep
them as its intake door and point `config.yml`'s contact link there.
- [ ] **Vendor the doctrine**: run `docs-sync --fix` (#19) to materialize - [ ] **Vendor the doctrine**: run `docs-sync --fix` (#19) to materialize
`.ceremony/{AGENTS,TRIAGE,BUILDER,REVIEWER,LABELS}.md` `.ceremony/{AGENTS,TRIAGE,BUILDER,REVIEWER,LABELS}.md`
byte-identical to this repo at the pinned ref — plus the generated byte-identical to this repo at the pinned ref — plus the generated
@ -378,7 +894,8 @@ this is the checklist:
`workflow_dispatch` once ([above](#labels-automation)), or the hand `workflow_dispatch` once ([above](#labels-automation)), or the hand
commands in [LABELS.md](../LABELS.md). commands in [LABELS.md](../LABELS.md).
- [ ] **State the single-writer rule** in the repo's own docs: only - [ ] **State the single-writer rule** in the repo's own docs: only
triage mints issues; everyone else opens discussions. triage mints work issues; anyone may file a proposal, which triage
converts or refuses.
### The pin-bump procedure ### The pin-bump procedure

404
docs/RUNNER-PROBES.md Normal file
View file

@ -0,0 +1,404 @@
# Runner probes
**Not a drill.** A drill rehearses the release doors on a disposable repo and
ends. This is the opposite shape: one **standing** repo that exists so that
runner-only facts can be measured on demand, and it is **never archived**.
`heavy-duty/ceremony-runner-probe` — private, standing, reset between probes.
Ruled by the operator as option (A) of ceremony#202 (#5631).
## Why a standing repo, when drills are disposable
Some facts are only true inside Actions, under the token Actions injects, and
no local harness or PAT can reproduce them. The worked example is ceremony#192:
```
DELETE /issues/{n}/labels/{id} -> 500 under ${{ github.token }} in a workflow
DELETE /issues/{n}/labels/{id} -> 204 under a maintainer PAT, same call
```
A probe that runs anywhere else passes and proves nothing. Before this venue
existed the answer was "un-archive a drill repo", which was requested three
times in two days across two issues and never became anything — the three
drill repos (`ceremony-drill-0.4.1`, `-0.4.1-final`, `-191`) are all archived,
and each was minted for one probe and then wanted again.
## The disposal rule above does NOT apply here
The rehearsal section says the builder archives the scratch repo and the
operator deletes it. **That rule is for drills.** Archiving this repo defeats
its entire purpose, and it is the failure mode the three archived drill repos
demonstrate — each was archived correctly, by the rule, and each then had to be
un-archived or replaced.
So: never archive it, never delete it, and if you find it archived, un-archive
it rather than minting a fourth one.
## Standing it up is the operator's step
Bot identities cannot create repositories in `heavy-duty`. Measured
2026-08-05 with a fleet identity holding the `repo` scope:
```
POST /api/v1/orgs/heavy-duty/repos -> 403 "not allowed to create repository in organization"
POST /api/v1/user/repos -> 201 (personal namespace only)
```
This is the same shape as the drill delete: a deliberate permission boundary,
not a misconfiguration. Do not retry it, and do not work around it by putting
the venue in a personal namespace — **not because a personal namespace is
proven unable to reach the org's runner** (that was not measured; the probe
repository above was deleted immediately, so nothing about runner or secret
reach was established), but because @andres ruled an **org-owned standing
venue** (#5631). A personally-owned repo is a different thing from the one that
was decided on, and cannot satisfy #202's named acceptance target.
If runner or secret reach turns out to matter, measure it once the venue
exists rather than assuming it here.
## Running a probe
1. Reset the repo to a clean state — the probe's own fixtures only, no
leftovers from the last one. A probe that inherits state is a probe whose
result you cannot attribute.
2. **Arm it against the candidate** (below) — two layers, candidate code and
armed workflow — if the probe is about ceremony's own machinery rather than
about a bare API call.
3. **Run it as an Actions job under `${{ github.token }}`.** This is the whole
point of the venue and the one step that cannot be shortcut. A `curl` from a
laptop with a PAT answers a different question — see the 204/500 split
above — and a probe run that way is worse than no probe, because it produces
a confident wrong answer.
4. **The job writes its raw results into an issue in the PROBE repo**
`heavy-duty/ceremony-runner-probe` — not into ceremony. Logs age out;
ceremony#192's run 701 survived only because the job wrote its findings
into an issue it created.
5. **A human then records the probe issue's URL and the Actions run number on
the ceremony issue the probe serves.** That hop is deliberate and is the
whole of the boundary: the probe workflow holds no credential and no code
path that can write to `heavy-duty/ceremony`, so "the probe reports its
findings" and "the probe cannot touch the live board" stay compatible
rather than contradicting each other (@codex-reviewer-andresmgsl, #202
review).
## Arming a candidate ref
A probe that exercises ceremony's own machinery needs the candidate tree
reachable from a `uses:` line. This is the fork-ref shape `drills/README.md`
step 2 points at, written out — and it has **two layers**, which is the part
that is easy to get wrong and impossible to fix afterwards.
**Why two.** The candidate's own workflows contain
`repository: heavy-duty/ceremony` beside `ref: ${{ env.CEREMONY_SELF_REF }}`,
so they must be rewritten to point at the fork and at the candidate. But
rewriting them **creates a new commit**, and a commit cannot contain its own
object ID. A single-layer arming is therefore self-referential: pin the callers
to the pre-rewrite SHA and they load the *unarmed* workflows; pin them to the
post-rewrite one and you are asking a commit to embed itself
(@codex-reviewer-andresmgsl, #202 review).
So:
| layer | what it is | what it carries |
|---|---|---|
| **candidate code SHA** | the immutable tree under test | `actions/`, `lib/` — untouched |
| **armed workflow SHA** | a small child commit on top of it | workflows rewritten to the fork + `CEREMONY_SELF_REF` = the candidate code SHA |
### The procedure
1. **Push the candidate tree** to a fork under the identity that will run the
probe — one branch, `<identity>/ceremony@probe-<issue>` — and record its
SHA. Steps 1 and 2 advance the tip of that **same** branch; there are two
commits, not two branches. That is
the **candidate code SHA**. Never create a branch on
`heavy-duty/ceremony` named like a tag: it shadows that tag for every
consumer until somebody remembers to delete it.
2. **Write the manifest FIRST, from the pre-arming tree, then commit the
arming.** The manifest enumerates the carriers *that must change*, so it is
generated before they do — running it afterwards would enumerate
already-rewritten rows and lose the canonical internal-checkout ones
entirely (@codex-reviewer-andresmgsl, #202 review). In that same
fork branch rewrite, for **every** carrier the manifest below enumerates:
ceremony's own internal `repository:` checkouts → `<identity>/ceremony`, and
**every** `CEREMONY_SELF_REF` value → the **candidate code SHA** from step 1.
There were three self-ref carriers on `main` at the time of writing and the
count is not a constant — derive it, do not remember it
(@glm-reviewer-andresmgsl, @codex-reviewer-andresmgsl, #202 review). The
**consumer** checkouts (`${{ github.repository }}`) are left alone. Record
the resulting SHA: that is the **armed workflow SHA**.
3. **Pin the probe repo's callers by layer**, because they are not the same
thing:
- composite-action callers →
`<identity>/ceremony/actions/<name>@<candidate-code-sha>`;
- reusable-workflow callers →
`<identity>/ceremony/.github/workflows/<file>@<armed-workflow-sha>`, since
that is the only revision whose inner checkout is rewritten.
4. **Gate the arming against a MANIFEST, byte for byte.** Every weaker shape
has a hole, and each of these was found in a published draft of this file
(@codex-reviewer-andresmgsl, #202 review):
| weaker check | what slips through |
|---|---|
| "the old literal is absent" | a carrier rewritten to the wrong fork, or to the *armed* SHA |
| "every extracted value equals X" | a carrier that **vanished** — nothing to compare |
| "each value is one of {fork, dynamic}" | a **role swap**: an internal checkout made dynamic, a consumer checkout pointed at the fork |
| "the SHA suffix matches" | `wrong-owner/ceremony/actions/foo@<right-sha>` |
| "known callers match" | an **unrecognised** caller, or none at all |
| "the owner and the sha are right for the kind" | a **layer swap**: `…/actions/x@<armed>` labelled a workflow caller satisfies both |
So the arming step **writes a manifest** — one line per carrier, `path`,
`kind`, `full expected value` — and the gate compares the tree's actual
carriers against it as a set. A deletion, a role swap, a wrong fork, a wrong
SHA, an extra carrier and a missing caller are then all the same kind of
failure: the sets differ.
**Generate it while arming**, from the tree you are arming, so the manifest
cannot drift from the repository:
```sh
#!/usr/bin/env bash
# write-manifest <candidate-checkout> <probe-checkout> <fork> <code-sha> <armed-sha>
#
# Run against the PRE-ARMING tree and the UNPINNED probe: this records what
# each carrier must BECOME, so it has to see them before they change.
#
# `|| true` on every extraction, for the same reason the checker needs it:
# git grep exits 1 on no-match and `set -e` would abort BEFORE the manifest
# is written — silently, which is how the first version of this generator
# produced no file and no diagnostic when a probe exercised only one layer
# (@codex-reviewer-andresmgsl). A probe need not use both.
set -euo pipefail
candidate="$1"; probe="$2"; fork="$3"; code_sha="$4"; armed_sha="$5"
# shellcheck disable=SC2016 # `${{ github.repository }}` is literal YAML, not a shell expansion
{
git -C "$candidate" grep -n 'CEREMONY_SELF_REF:' -- .github/workflows \
| cut -d: -f1,2 | sed "s|$|\tself_ref\t$code_sha|" || true
git -C "$candidate" grep -n 'repository: heavy-duty/ceremony' -- .github/workflows \
| cut -d: -f1,2 | sed "s|$|\tinternal_repo\t$fork|" || true
git -C "$candidate" grep -n 'repository: ${{ github.repository }}' -- .github/workflows \
| cut -d: -f1,2 | sed 's|$|\tconsumer_repo\t${{ github.repository }}|' || true
# Callers record the COMPLETE expected coordinate, not just the sha: the
# path is as rewritable as the owner, and a manifest that stores only the
# suffix cannot notice `…/actions/wrong-one@<right-sha>`.
git -C "$probe" grep -nE 'uses:[[:space:]]*[^[:space:]]*/ceremony/\.github/workflows/' -- .github \
| sed -E "s|^([^:]+):([0-9]+):.*/ceremony/(\.github/workflows/[^@[:space:]]+)@.*|\\1:\\2\\tworkflow_caller\\t$fork/\\3@$armed_sha|" || true
git -C "$probe" grep -nE 'uses:[[:space:]]*[^[:space:]]*/ceremony/actions/' -- .github \
| sed -E "s|^([^:]+):([0-9]+):.*/ceremony/(actions/[^@[:space:]]+)@.*|\\1:\\2\\taction_caller\\t$fork/\\3@$code_sha|" || true
} | sort >manifest.tsv
# Zero ceremony callers is a refusal by name; one layer only is fine.
callers="$(grep -cE '(workflow|action)_caller' manifest.tsv || true)"
[ "$callers" -gt 0 ] || { echo "manifest: no ceremony callers found in $probe" >&2; exit 1; }
```
Then arm — rewrite and commit — and check the result against it:
```sh
#!/usr/bin/env bash
# check-arming <armed-checkout> <probe-checkout> <fork> <code-sha> <armed-sha> <manifest.tsv>
set -euo pipefail
armed="$1"; probe="$2"; fork="$3"; code_sha="$4"; armed_sha="$5"; manifest="$6"
fail() { echo "arming incomplete: $*" >&2; exit 1; }
# `|| true` on every extraction: git grep exits 1 when nothing matches, and
# under `set -e` that would kill this script BEFORE the comparison — so a
# carrier class that vanished ENTIRELY produced silence instead of a
# refusal. Silence is the worst of the three outcomes; the comparison below
# is what must report it.
[ "$(grep -cE '(workflow|action)_caller' "$manifest" || true)" -gt 0 ] \
|| fail "manifest names no ceremony callers — it cannot prove an arming"
# THE MANIFEST ITSELF IS CHECKED AGAINST THE TARGET, not trusted. Comparing
# only tree-vs-manifest proves consistency, and a manifest generated with the
# armed SHA where the candidate SHA belonged — or with the wrong fork —
# describes a WRONG arming perfectly. The tree would then match it and the
# gate would pass (@codex-reviewer-andresmgsl, #202 review).
# shellcheck disable=SC2016 # `${{ github.repository }}` below is literal YAML
while IFS=$'\t' read -r loc kind want; do
case "$kind" in
self_ref) [ "$want" = "$code_sha" ] || fail "manifest $loc: self_ref should be the CANDIDATE sha" ;;
internal_repo) [ "$want" = "$fork" ] || fail "manifest $loc: internal repo should be $fork" ;;
consumer_repo) [ "$want" = '${{ github.repository }}' ] \
|| fail "manifest $loc: consumer checkout must stay dynamic" ;;
# THE KIND MUST BIND TO THE PATH CLASS, not only to the owner and the
# sha. The path class is what SAYS which layer a caller is, so checking
# the sha against the kind while letting the kind float free accepts a
# consistent layer swap — `…/actions/x@<armed>` declared workflow_caller
# passes every owner and sha test (@codex-reviewer-andresmgsl, #202
# review). Decompose once, then let the kind fix BOTH coordinates.
workflow_caller|action_caller)
owner="${want%%/ceremony/*}"; rest="${want#*/ceremony/}"
path="${rest%@*}"; sha="${want##*@}"
[ "$owner/ceremony" = "$fork" ] \
|| fail "manifest $loc: caller owner should be $fork"
case "$kind" in
workflow_caller)
case "$path" in .github/workflows/?*) : ;;
*) fail "manifest $loc: workflow_caller must resolve at .github/workflows/<file>, not '$path'" ;;
esac
[ "$sha" = "$armed_sha" ] || fail "manifest $loc: workflow caller should be the ARMED sha" ;;
action_caller)
case "$path" in actions/?*) : ;;
*) fail "manifest $loc: action_caller must resolve at actions/<name>, not '$path'" ;;
esac
[ "$sha" = "$code_sha" ] || fail "manifest $loc: action caller should be the CANDIDATE sha" ;;
esac ;;
*) fail "manifest $loc: unknown kind '$kind'" ;;
esac
done <"$manifest"
actual="$(mktemp)"; trap 'rm -f "$actual"' EXIT
{
git -C "$armed" grep -nP '(?<=CEREMONY_SELF_REF: ")[^"]+' -- .github/workflows \
| sed -E 's/^([^:]+):([0-9]+):.*CEREMONY_SELF_REF: "([^"]*)".*/\1:\2\tself_ref\t\3/' || true
git -C "$armed" grep -nE 'repository: .+' -- .github/workflows \
| sed -E 's|^([^:]+):([0-9]+):[[:space:]]*repository:[[:space:]]*(.*)$|\1:\2\t__repo__\t\3|' || true
# Only CEREMONY callers, matching the generator's domain exactly — a
# third-party `actions/checkout` is not this gate's business, and
# extracting it here while the generator ignores it made every probe fail
# as an "unrecognised carrier" (@codex-reviewer-andresmgsl). A wrong OWNER
# is still caught: `wrong-owner/ceremony/...` matches this pattern.
git -C "$probe" grep -nE 'uses:[[:space:]]*[^[:space:]]*/ceremony/' -- .github \
| sed -E 's|^([^:]+):([0-9]+):[[:space:]]*-?[[:space:]]*uses:[[:space:]]*(.*)$|\1:\2\t__uses__\t\3|' || true
} | sort >"$actual"
# every manifest line must be present with its EXACT expected value, and the
# kinds must match — a role swap changes the kind, not just the value.
while IFS=$'\t' read -r loc kind want; do
case "$kind" in
self_ref) have="$(awk -F'\t' -v l="$loc" '$1==l && $2=="self_ref"{print $3}' "$actual")" ;;
internal_repo|consumer_repo)
have="$(awk -F'\t' -v l="$loc" '$1==l && $2=="__repo__"{print $3}' "$actual")" ;;
workflow_caller|action_caller)
have="$(awk -F'\t' -v l="$loc" '$1==l && $2=="__uses__"{print $3}' "$actual")" ;;
esac
[ -n "$have" ] || fail "carrier vanished: $loc ($kind)"
# ONE comparison for every kind: the manifest already carries the complete
# expected value, so owner, path AND sha are checked at once. Checking the
# owner and the sha separately let `…/actions/wrong-one@<right-sha>`
# through (@codex-reviewer-andresmgsl).
[ "$have" = "$want" ] || fail "$loc ($kind): expected '$want', found '$have'"
done <"$manifest"
# and nothing UNRECOGNISED: every uses:/repository: in the trees must appear
# in the manifest, so an added carrier is a failure rather than a silence.
while IFS=$'\t' read -r loc _ _; do
grep -qF "$loc"$'\t' "$manifest" || fail "carrier not in manifest: $loc"
done <"$actual"
```
**Why a manifest rather than a longer list of assertions.** The carrier set
is a property of the tree at the moment of arming; any list written into
this document is stale the next time a workflow is added. The manifest is
generated from the tree, recorded in the result issue (step 6), and is the
thing a later reader compares against — so "what was armed" is evidence
rather than recollection.
5. **Invoke the probe by the event it is about**, and record which: a
`workflow_dispatch`, or the real board event under test. A probe that fires
a different event than the one under test proves something else.
6. **The result issue records all of it**: the fork repository, the candidate
code SHA, the armed workflow SHA, every rewritten carrier, the workflow
invoked and the run number. Those are what make the result reproducible;
without the two SHAs distinguished, a later reader cannot tell which tree
answered.
7. **Reset removes the candidate-specific EXECUTABLE state**: the caller stubs,
the probe workflow, and the fork's probe branch — whose tip carries both the
candidate commit and the armed commit on top of it — so the next probe
cannot inherit a pin it did not choose. **Result issues are never deleted.**
They may be closed or relabelled; deleting them would recreate the
expiring-log problem this venue exists to avoid.
## Who may reset it
**Operator-owned until ruled otherwise.** #202's task 4 asks who may reset the
venue, and creating the repo is the operator's step, so the access policy is
his to set at the same time (@codex-reviewer-andresmgsl, #202 review).
Two levels, deliberately separated:
- **content reset** — removing probe branches, workflows and fixtures; the
ordinary between-probes operation. It does **not** include deleting result
issues, which are the evidence and are immutable once written
(@codex-reviewer-andresmgsl, #202 review);
- **archive / delete / admin** — which is where the drill rule's damage came
from, and which no bot identity should hold here.
If fleet identities are given push access for content reset, this section
records that; until then, ask.
## What must never happen here
No probe touches `heavy-duty/ceremony`'s board. No labels, no comments, no
runs attributable to a probe. The venue exists so that the live board does not
have to be the test fixture.
## The probes this venue owes — and the records of those delivered
Delivered probes stay listed with their record: the venue's value is that a
claim like "the asymmetry reproduces" carries a URL a reader can open, not a
memory.
- **ceremony#192** — DELIVERED, first drill (2026-08-05). Under
`${{ github.token }}` in the venue:
`DELETE /issues/{n}/labels/{id}`**500**, the label still on the issue
afterward — the failure observable in the set, not merely a status — then
`PUT` full-set clear → **200**, set actually empty. Record:
[probe issue #1](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/1)
(run 1) and
[probe issue #2](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/2)
(run 4 — the clean independent repeat after the redaction incident below).
- **ceremony#205** — DELIVERED with a correction to the line above's
premise. The dispatch route answers **204** to a valid body carrying the
bare resolvable ref `main` — under the workflow token
([ceremony#205 comment #6262](https://forgejo.heavyduty.builders/heavy-duty/ceremony/issues/205#issuecomment-6262),
run 504, and again as
[probe issue #4](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/4),
run 6) and under a PAT
([probe issue #5](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/5),
run 7, and ceremony run 459). The earlier opaque `500` came from a bare
UNRESOLVABLE ref or an unknown/unparseable workflow — the diagnostic !213
ships now names this; a fully-qualified bad ref gets a clean 404 instead.
The `GET /actions/workflows` listing still 404s. Claims here are limited to
what those runs measured.
- **ceremony#215** — the discriminator drill: REST-body `inputs` DO reach a
top-level dispatched workflow, both contexts
(`inputs.*` and `github.event.inputs.*`), both identities. What loses the
value is the `workflow_call` boundary — a called workflow does not see the
caller's `event.inputs` on this instance. Records:
[probe issues #4 and #5](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/4)
(runs 6 and 7).
- **ceremony#217** — DELIVERED (2026-08-09). The standing venue used the
published consumer callers without rewrites or bypasses:
`labels.yml@0.6.1` and `labels-sweep.yml@0.6.1`. Opening fixture issue #7
drove event caller
[run 23](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/actions/runs/23)
and its dispatched sweep
[run 24](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/actions/runs/24);
the sweep wrote `needs-triage` on that issue under the workflow token. A
separate manual sweep was green in
[run 25](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/actions/runs/25).
Observer
[run 30](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/actions/runs/30)
recorded the released tag SHA `338cf5f754f0e87feefe9231b47910fb236ab4d0`,
both caller runs and the resulting label set in
[probe issue #10](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/10).
The event caller loaded its reusable workflow at `@0.6.1`; both sweep runs
completed the reusable workflow's internal `CEREMONY_SELF_REF` checkout at
the real `0.6.1` tag. There was no candidate SHA rewrite and no dogfood
bypass.
Two venue lessons from the first drills, kept where the next probe author will
look:
- **Rule 4 is load-bearing on this instance**: the probe repo's web log route
404s for non-admin reads, and a log-only observation (run 2) was lost where
issue-written ones were not.
- **Report content sent to the forge must never contain a credential
expression OR a credential value** — moving a secret from `${{ … }}` syntax
into a shell variable does not make it safe to emit. Name identities in
literal prose ("the workflow token"), allowlist the fields a report emits,
and treat anything else as unsendable. The incident this teaches from: an
escaped `${{ github.token }}` in a comment template was expanded by the
runner into the recorded text (the run's ephemeral token); it was redacted
in place and the drill repeated clean (probe issue #2, run 4).

308
docs/UPSTREAM-SYNC.md Normal file
View file

@ -0,0 +1,308 @@
# Syncing this tree with upstream ceremony
`heavy-duty/ceremony` exists on two forges and they diverge in opposite
directions on purpose:
- **upstream**`github.com/heavy-duty/ceremony`, where new ceremony features
are written. **Read-only from here.** No issue, PR, comment, review or
release is ever created there.
- **this tree**`forgejo.heavyduty.builders/heavy-duty/ceremony`, which
carries upstream's content plus the forge-compatibility delta and never
writes back.
This document is the procedure for bringing upstream's work across. It is
written to be followed without prior context; where it states a resolution, the
resolution is standing and does not get re-decided each sync.
Worked example throughout: the `0.6.0` sync (#197, #198), which merged upstream
`8c3a4d1` onto `dad99dd` and took four heads to get green.
## 0.6.2 port record
On 2026-08-24 this tree released the content carried by upstream
`upstream-0.6.1` through `upstream-0.6.3`. The content baseline is
`upstream-0.6.3`; the changes were ported onto the Forgejo-adapted tree by
#229 and #230 rather than merged from upstream.
The ancestry baseline therefore remains the full `.upstream-ref` value
`8c3a4d1dee2bdb5ac06a632a285bb65ab2615214` (upstream `0.6.0`, merged by
#198). No upstream ancestry moved in this release. Tags are disambiguated as
`upstream-0.6.x` for upstream's line and bare `0.6.x` for releases cut on this
forge.
Upstream's drill-record fixes and the upstream `0.7.x` line remain deferred to
the next sync campaign. That line has no ceiling this file can hold still —
upstream tags roughly one release a week — so what is recorded here is its
floor and the date it was last measured: `0.7.0` onward, `0.7.6` newest as
measured 2026-08-27. Run `git ls-remote --tags` for today's ceiling. The
sentence this replaced froze `0.7.4` and was already a release behind on the
day it was written.
## The next campaign merges
The next sync campaign is a merge, not a port, and it therefore advances
`.upstream-ref` to the commit it merges. The operator ruled this on #268 on
2026-08-27, when release-init found no forge-local work to fill an `0.6.4` and
opened no window.
The reason is the ancestry baseline. `.upstream-ref` has been pinned at
`8c3a4d1` — upstream `0.6.0`, merged by #198 — because 0.6.2 came across as a
port and a port moves no ancestry. Another port would leave it pinned and
guarantee a third, larger campaign against a delta that is still growing.
Size it before starting. With upstream's tags fetched read-only per step 1,
`git diff --shortstat 8c3a4d1dee2bdb5ac06a632a285bb65ab2615214 0.7.6`
was 92 files and +25,121/-971 over 488 commits when measured 2026-08-27, and
the three action scripts the forge delta is heaviest in all move:
`runner-isolated` +1619, `issueflow-reconcile` +913, `labels-reconcile` +889.
Step 4 of the procedure below — the audit of what merged *without* conflicting
— is where that size is actually paid for.
## The standing resolutions
These recur every sync. They are decided; re-deciding them is the cost this
list exists to remove.
| what | which side wins | decided by |
|---|---|---|
| `VERSION` | **upstream** — this tree tracks upstream's version numbers | #197 D2 |
| `CEREMONY_SELF_REF` (both carriers) | **upstream** | #197 D2 |
| `.github/labels.conf` | **this tree** — upstream's roster names identities that do not exist here | #195 |
| `drills/*.md` | **this tree** — a drill record is a record of a run *here* | #198 |
| `CHANGELOG.md` | **both**, upstream's new sections above this tree's | #198 |
| a section for a version **both** trees released | **this tree's** — ours is the published body of the tag that exists here | #198 |
Two consequences worth stating plainly:
- **Two trees answer to the same version number**, differing by the forge
delta. That is accepted, not accidental (#197 D2). The mitigation is
provenance in prose: `CHANGELOG.md`'s header names the upstream commit this
tree carries, and `.upstream-ref` records it in machine-readable form.
- **A tag that exists upstream may not exist here.** `CEREMONY_SELF_REF` takes
upstream's number, and both workflows carry the self-consumption bypass
(`if: github.repository != 'heavy-duty/ceremony'`), so ceremony's own CI is
unaffected. But **no consumer may bump its pin to that number until the
release ceremony cuts the tag here.**
## The procedure
### 1. Add the upstream remote, read-only, and confirm the merge base
```sh
git remote add gh https://github.com/heavy-duty/ceremony.git # if absent
git fetch gh
upstream_sha="$(git rev-parse gh/main)" # capture ONCE, in full
git merge-base main "$upstream_sha"
```
**Capture the full SHA immediately and use that value everywhere after** — the
merge, the provenance, the `.upstream-ref` write. `gh/main` is a moving
pointer: while this sync was being reviewed upstream advanced from `8c3a4d1`
to `08e2912`, and re-reading `gh/main` at recording time would have written a
commit this tree does not contain. The recorded ref is *what was merged*, never
*what upstream is now*.
**Confirm the merge base against `.upstream-ref` before merging anything.** If
it is not what the last sync recorded, something moved — stop and re-measure
rather than proceeding. A sync that starts from an unexpected base is a sync
whose conflict count means nothing.
### 2. Merge, never rebase
```sh
git merge "$upstream_sha"
```
One merge commit, conflicts resolved once (#197 D1). Rebasing the forge-only
commits onto upstream would rewrite every SHA, re-resolve the same conflicts
once per commit, and break any pin to them. A fresh re-import would discard the
provenance in this repo's issue comments, which is where its documentation
actually lives.
### 3. Resolve the conflicts
Apply the standing resolutions above. What is left is genuinely new and needs
judgement — in the `0.6.0` sync that was 5 hunks of 18.
### 4. Audit what the merge brought in that did NOT conflict
**This is the step the `0.6.0` sync nearly shipped without, and the one this
document exists for.**
`git merge` takes upstream's side wherever only upstream moved a region. So a
function upstream *added* to a file this tree already owns arrives with **no
conflict and no question asked**. Reviewing the conflict hunks cannot find
them: four reviewers read the same diff and each found a different subset.
In the `0.6.0` sync that was **eight** runtime `gh` call sites, in three files
and two file types, every one of which #188 had previously removed.
So, after resolving:
```sh
bash test/no-runtime-gh.test.sh
```
That guard is the mechanical form of #197's acceptance bar — no runtime `gh`
outside `lib/forge-github.sh` unless the file declares
`CEREMONY_FORGE_CLIENT=gh` **and** refuses when it cannot run. Do not satisfy
it by adding an exemption; a declaration without a refusal is a permission slip
for `gh: command not found`.
Then check the **variables** the same way, because the same mechanic applies to
state: if a conflicted region assigns something that auto-merged code consumes,
resolving it "to this tree's side" silently removes the producer. Every one of
those consumers degrades to empty rather than erroring, so nothing goes red.
The `0.6.0` sync had three such seams. Enumerate what each resolved region
assigns, and confirm each still has a producer.
### 5. Port or declare every new `gh` call site
Where a `forge_*` verb exists, port it in the merge itself. Where none does,
the file **declares** `CEREMONY_FORGE_CLIENT=gh` and refuses loudly, and the
port gets its own issue (#199 for `refs-not-closing`, #205 for the sweep
dispatch). "Never 'probably github'" applies to the sync as much as to a
runtime probe.
A workflow cannot call `forge_preflight`, so it declares in its `env:` block
and refuses inline — deciding the **forge** first and the **binary** second. A
guard that only asks whether `gh` is installed passes the moment a runner image
ships it.
### 6. Record the provenance
- `CHANGELOG.md`'s header: which upstream commit this tree now carries.
- `.upstream-ref`: the same **full 40-character** SHA, machine-readable,
checked by `test/upstream-delta.test.sh` — which refuses when the object is
absent or is not an ancestor, rather than reporting it unverifiable. `ci.yml`
fetches that exact object before the suite runs.
- A `changelog.d/` fragment for the sync issue.
### 7. Verify — and verify where it will actually run
`test/run.sh` green on your machine is the weakest of the checks below. The
`0.6.0` sync was "green locally" and red on the runner **three times, for three
different reasons**:
| what was green locally | why the runner disagreed |
|---|---|
| `shellcheck-all.sh` | it lints **tracked** files, and the new guard was untracked |
| the whole suite | CI pins **shellcheck 0.10.0**; a different local version reports differently |
| `issue_payload_valid` | `jq -e` on empty input exits **4** on jq 1.7 and **0** on jq 1.6 — and the runner image ships 1.6 |
That last one was not a test problem: on jq 1.6 the guard that refuses an
unreadable read was *accepting* one. **The distance between your environment
and the runner's is part of the sync's risk surface, not an inconvenience.**
So verify with the runner's own tooling:
```sh
git add -A # or shellcheck sees nothing new
CEREMONY_REQUIRE_NPM=1 CEREMONY_REQUIRE_YQ=1 bash test/run.sh
bash .github/scripts/shellcheck-all.sh # pinned 0.10.0, as ci.yml installs
bash .github/scripts/actionlint-all.sh
bash .github/scripts/self-ref-check.sh
bash .github/scripts/marker-check.sh
bash .github/scripts/vendored-check.sh
bash actions/changelog-armed/changelog-armed.sh
```
and run the suite once under the runner's `jq` as well as your own.
### Every branch that was open during the sync is now stale
Forgejo tests branch heads; it never tests what two branches produce together,
and it never re-tests an open PR when `main` moves under it. So after a sync
lands, **every PR that was open across it is green against a tree that no
longer exists** — its run did not contain the test files and rules the sync
introduced.
Both halves of that bit in this sync:
- `#206` and `#207` were cut from the pre-sync base. Their green suites had 22
test files; the merged tree has 28.
- `#206`'s changelog fragment was individually green and made the **combined**
tree red, because the terminal-citation rule (#262) arrives *with* the sync
and the fragment was written against a base without it.
So, for each PR still open:
```sh
git merge origin/main # in the branch — do not rewrite its commits
CEREMONY_REQUIRE_NPM=1 CEREMONY_REQUIRE_YQ=1 bash test/run.sh
```
or, if you are only checking rather than updating, merge them into a scratch
worktree together and run the full current suite and static guards there. A
prior approval is evidence about the tree it was given on; after a sync it is
not evidence about the tree the operator would merge.
### 8. After it merges — audit by executed steps, never by colour
The sync issue uses `Refs`, not `Closes`, and stays open until a real sweep on
the merged `main` is linked to it.
**A green run is not that evidence.** In this sync the first post-merge run was
green and had reconciled *nothing*: upstream's #209 restructure moved reconcile
out of the labels caller and behind a dispatch this forge cannot perform, so
the only job that ran was the refusal. Green, correct, and proof of the
refusal path only.
So before citing any run:
1. **Inventory what the sync changed about workflow triggers and jobs** — which
jobs exist now, which events fire them, and which of those this forge can
actually serve. A restructure upstream can move work between workflows
without touching a line of the code that does it.
2. **Read the run's executed steps**, not its status. Name the job that did the
thing, and quote the line that shows it did.
3. A green *skipped-or-refusing* path is valid evidence **for that path**, and
never evidence that the work happened.
Neither of these is caught by the no-runtime-`gh` scan in step 4: in this sync
both failures occurred with that guard green and CI green.
## Where the forge delta lives
Forge-specific behaviour is confined to the files below. Keeping it there is
what makes each sync cost 18 hunks instead of hundreds, and
`test/upstream-delta.test.sh` fails the PR that scatters it into a new file.
**What that guard actually checks**, stated precisely so the table is not read
as a stronger promise than it is: it walks every tracked file except prose
(`*.md`), the test harness and `changelog.d/`, and flags any that **decides**
the forge — the selector's verbs, `CEREMONY_FORGE*`, or a server-URL comparison
written inline. Discovery is derived from the tree rather than from a list of
directories and extensions, so a composite `action.yml` or a `.yaml` workflow
is seen without anyone remembering to add it.
It is a check on *forge decisions in executable and configuration files*. It is
**not** a diff against upstream, so it cannot see a file that differs from
upstream for some other forge-specific reason — `drills/` and
`.github/labels.conf` are in the table for that kind of reason and are listed
by judgement, not by scan.
| file | what is forge-specific about it |
|---|---|
| `lib/forge.sh` | the selector: `forge_detect`, `forge_client`, `forge_preflight` |
| `lib/forge-github.sh` | the gh backend — the one file allowed to speak `gh` |
| `lib/forge-forgejo.sh` | the Forgejo backend, `/api/v1` over curl + jq |
| `lib/closes_references.sh` | the closing-keyword parser that replaced GraphQL |
| `.github/labels.conf` | this instance's roster |
| `drills/` | records of runs on this instance |
| `actions/refs-not-closing/run.sh` | declares `CEREMONY_FORGE_CLIENT=gh` — its gather is GraphQL, which Forgejo does not serve. #199 removes the declaration |
| `.github/workflows/labels.yml` | the sweep dispatch decides the forge inline and declares a client; a workflow has no shell to call `forge_preflight` from. #205 ports it |
| `.github/workflows/refs-guard.yml` | schedules its job on GitHub only, so an action that can only refuse here does not stand red. #199 removes the gate |
| `.github/workflows/release-exercise.yml` | pins `CEREMONY_FORGE: github` deliberately: the exercise drives the GitHub path |
| `actions/docs-sync/docs-sync.sh` | fetches the doctrine mirror from the forge in `GITHUB_SERVER_URL`, and refuses rather than guessing one (#201) |
Four of those are **temporary** and say which issue removes them. That is the
point of listing them rather than exempting them: a forge-delta location with
no exit is indistinguishable from one nobody noticed.
A file that merely **calls** the shim is not a delta location — every
reconciler and `release.yml` call `forge_select`, and that is what the shim is
for. A file that **decides** or **declares** is, and belongs here.
If a sync needs forge branching somewhere else, that is a design decision, not
a detail: add the file to the inventory in the same PR, with the reason.

View file

@ -3,3 +3,4 @@ TRIAGE.md
BUILDER.md BUILDER.md
REVIEWER.md REVIEWER.md
LABELS.md LABELS.md
RELEASES.md

54
drills/0.2.0.md Normal file
View file

@ -0,0 +1,54 @@
# 0.2.0 — drill record
Run 2026-07-24 by `codex-bot-andresmgsl` against the release candidate at
PR #128 head `b632c19e97678653547252d3e402ca93907b2d50`.
Where: disposable private repo
`codex-bot-andresmgsl/ceremony-drill-0.2.0`, carrying the
`docs/CONSUMERS.md` release caller and a fragment-mode fixture armed at
`0.2.0-dev`. The fixture had `changelog.d/README.md`, one release fragment,
and a non-blank drill record. The repository is archived, pending the
operator's delete.
## Candidate-ref deviation
The pure consumer path still cannot resolve `CEREMONY_SELF_REF: "0.2.0"`
before the candidate creates that tag. No `0.2.0` branch was created in
`heavy-duty/ceremony`. The scratch caller instead used the already-existing
`claude-bot-andresmgsl/ceremony@drill/0.2.0` scaffold, whose parent candidate
tree is byte-identical to PR #128's tree and whose only additional change
rewrites both `CEREMONY_SELF_REF` carriers to candidate SHA
`682b9cb8929aa8c50a3101b64ca57fee7b09fef1`. All runtime machinery was
therefore fetched from the 0.2.0 candidate tree.
## Probes
| # | probe | run | result |
|---|---|---|---|
| 1 | merge-door ceremony | 30090148675 (attempt 1) | ✅ one `0.2.0` release; tag equals merge commit; main re-armed to `0.2.1-dev` with only `changelog.d/README.md` |
| 2 | mislabeled ordinary PR | 30090024126 | ✅ green NOTICE no-op; no tag or release |
| 3 | bare-version PR without `release` | 30090111403 | ✅ refused at decide; no tag or release |
| 4 | re-run completed ceremony | 30090148675 (attempt 2) | ✅ refused because tag/release already existed; release count stayed one |
| 5 | manual matching tag | 30090226056 | ✅ `0.3.0` published with its changelog section; main untouched |
| 6 | mismatched tag | 30090251726 | ✅ refused before publication; no `9.9.9` release |
The merge-door `0.2.0` tag and PR #4 merge commit were both
`f74a52447ae9abcbc61cf75d38ebf8f0ca28a427`. Its release body was exactly:
```text
- Fragment mode is exercised by the 0.2.0 drill.
```
## Failures and setup corrections
The scratch repository's root commit triggered run 30089943081 before a
parent commit existed, so fact gathering could not read a base version
(discussion #132). An empty armed-state baseline commit then ran green as
30089962334.
The first preparation of the unlabeled refusal probe assembled the fragment
but accidentally left `VERSION` at `0.2.0-dev`; run 30090059412 correctly
treated that merge as a green non-ceremony no-op. The merge was reverted,
the fixture was restored to armed fragment shape, and probe 3 was repeated
with the intended bare `0.2.0` transition. The corrected probe refused as
required and created nothing.

50
drills/0.3.0.md Normal file
View file

@ -0,0 +1,50 @@
# 0.3.0 — drill record
Run 2026-07-24 by `codex-bot-andresmgsl` against release PR #164 head
`da186729c5828fb4923e09d26aa2dd0077ec535f`.
Where: disposable private repo
`codex-bot-andresmgsl/ceremony-drill-0.3.0`, carrying the
`docs/CONSUMERS.md` release caller and a fragment-mode fixture armed at
`0.3.0-dev`. The fixture had `changelog.d/README.md`, one release fragment,
and a non-blank drill record. The repository is archived, pending the
operator's delete.
## Candidate-ref deviation
The pure consumer path cannot resolve `CEREMONY_SELF_REF: "0.3.0"` before
the candidate creates that tag. No `0.3.0` branch was created in
`heavy-duty/ceremony`. The scratch caller instead used
`codex-bot-andresmgsl/ceremony@drill/0.3.0`, whose parent is PR #164 head
`da186729c5828fb4923e09d26aa2dd0077ec535f` and whose only additional
commit rewrites both `CEREMONY_SELF_REF` carriers to that same canonical
candidate SHA. All runtime machinery was therefore fetched from the 0.3.0
candidate tree.
## Probes
| # | probe | run | result |
|---|---|---|---|
| 1 | merge-door ceremony | 30111977869 (attempt 1) | ✅ one `0.3.0` release; tag equals merge commit; main re-armed to `0.3.1-dev` with only `changelog.d/README.md` |
| 2 | mislabeled ordinary PR | 30111875574 | ✅ green NOTICE no-op; no tag or release |
| 3 | bare-version PR without `release` | 30111913669 | ✅ refused at decide; no tag or release |
| 4 | re-run completed ceremony | 30111977869 (attempt 2) | ✅ refused because the tag/release already existed; release count stayed one |
| 5 | manual matching tag | 30112046066 | ✅ `0.4.0` published with its changelog section; main untouched |
| 6 | mismatched tag | 30112072536 | ✅ refused before publication; no `9.9.9` release, and the probe tag was removed afterward |
The merge-door `0.3.0` tag and PR #3 merge commit were both
`b124af56ad713e47175992ca5025db6829b68ed7`. Its release body was exactly:
```text
- Fragment mode is exercised by the 0.3.0 drill.
```
## Setup
The armed fixture was committed before the caller so the first workflow run
had a real parent version to inspect. Installing the caller then produced
green baseline run 30111822421.
After the unlabeled bare-version refusal, its merge commit was reverted to
restore the armed fixture. That setup correction produced green no-op run
30111949683 before the merge-door probe began.

50
drills/0.4.0.md Normal file
View file

@ -0,0 +1,50 @@
# 0.4.0 — drill record
Run 2026-07-29 by `codex-bot-andresmgsl` against release PR #207 head
`7c755bcd402ba7f9a38ecd406a025c149c77aa57`.
Where: disposable private repo
`codex-bot-andresmgsl/ceremony-drill-0.4.0`, carrying the
`docs/CONSUMERS.md` release caller and a fragment-mode fixture armed at
`0.4.0-dev`. The fixture had `changelog.d/README.md`, one release fragment,
and a non-blank drill record. The repository is archived, pending the
operator's delete.
## Candidate-ref deviation
The pure consumer path cannot resolve `CEREMONY_SELF_REF: "0.4.0"` before
the candidate creates that tag. No `0.4.0` branch was created in
`heavy-duty/ceremony`. The scratch caller instead used
`codex-bot-andresmgsl/ceremony@drill/0.4.0`, whose parent is PR #207 head
`7c755bcd402ba7f9a38ecd406a025c149c77aa57` and whose only additional
commit rewrites both `CEREMONY_SELF_REF` carriers to that same canonical
candidate SHA. All runtime machinery was therefore fetched from the 0.4.0
candidate tree.
## Probes
| # | probe | run | result |
|---|---|---|---|
| 1 | merge-door ceremony | 30445585532 (attempt 1) | ✅ one `0.4.0` release; tag equals merge commit; main re-armed to `0.4.1-dev` with only `changelog.d/README.md` |
| 2 | mislabeled ordinary PR | 30445473977 | ✅ green NOTICE no-op; no tag or release |
| 3 | bare-version PR without `release` | 30445513860 | ✅ refused at decide; no tag or release |
| 4 | re-run completed ceremony | 30445585532 (attempt 2) | ✅ refused because the tag/release already existed; release count stayed one |
| 5 | manual matching tag | 30445658952 | ✅ `0.5.0` published with its changelog section; main untouched |
| 6 | mismatched tag | 30445684268 | ✅ refused before publication; no `9.9.9` release, and the probe tag was removed afterward |
The merge-door `0.4.0` tag and PR #3 merge commit were both
`2b2d592ffcc5c376e55bd5fcf2dd5ffbdd692d64`. Its release body was exactly:
```text
- Fragment mode is exercised by the 0.4.0 drill.
```
## Setup
The armed fixture was committed before the caller so the first workflow run
had a real parent version to inspect. Installing the caller then produced
green baseline run 30445432039.
After the unlabeled bare-version refusal, its merge commit was reverted to
restore the armed fixture. That setup correction produced green no-op run
30445544068 before the merge-door probe began.

125
drills/0.4.1.md Normal file
View file

@ -0,0 +1,125 @@
# 0.4.1 — drill record
Two runs. The first, 2026-08-04 against release PR !190 head `9a229ee`,
**failed**: both doors were inoperable and the release could not publish at
all. The second, after #191 landed as `fda5657`, **passed**. Both are
recorded, because the first is why the second exists.
## Run 2 — against merged `main` `fda5657` (the one that counts)
Where: disposable private repo `heavy-duty/ceremony-drill-0.4.1-final`,
armed at `0.4.1-dev`, carrying the `docs/CONSUMERS.md` release caller, a
fragment-mode fixture, and — unlike run 1 — an **artifact hook** dropping two
deliberately awkward filenames, `drill asset.tgz` and `a&b.tgz`. Archived at
the end; the operator's delete is pending, and cleanup gates nothing.
Candidate ref: `cluade-reviewer-andresmgsl/ceremony@drill-main`, parent
`fda5657`, whose only extra commit rewrites both `CEREMONY_SELF_REF`
carriers to that SHA — `release.yml`'s self-checkout is hardcoded to
`heavy-duty/ceremony`, so only a SHA that resolves there can stand in for a
tag that does not exist yet. No `0.4.1` branch was created on
`heavy-duty/ceremony`.
| # | probe | run | result |
|---|---|---|---|
| 1 | merge-door ceremony | 637 | ✅ exactly one release `0.4.1`; body is the version's own changelog section; tag and release on merge commit `4a83fa1b`; **main re-armed to `0.4.2-dev`**; both assets uploaded |
| 2 | mislabeled ordinary PR | 652 | ✅ green no-op in **7s** on merge commit `e71df4e3` — release count stayed **2**, no tag created |
| 3 | bare version, no `release` label | 638 | ✅ refused at `b091aff2` — release count stayed **1** |
| 4 | re-run of the completed ceremony | 654 | ✅ refused in **8s** on merge commit `82e7d11b` — release count stayed **2**, and tag `0.4.1` **stayed on `4a83fa1b`**, the original merge commit |
| 5 | tag door, matching tag | 639 | ✅ `0.5.0` published with its own section and both assets; **main VERSION untouched** |
| 6 | mismatched tag | 640 | ✅ `9.9.9` refused — release count stayed **2**; the tag exists, the release does not |
Probes 2 and 4 were run last, at 15:2315:28Z, on the same consumer: it was
un-archived for them and archived again at the end. Nothing else about the
run changed — same candidate ref, same caller pin.
**Probe 4 diverges in mechanism, not in what it proves.** The 0.3.0 and 0.4.0
siblings re-ran the completed ceremony's workflow run (GitHub's "attempt 2").
Forgejo 8.0.3 exposes no run-rerun API — there are no `actions/runs/{id}`
routes in its swagger at all — so the ceremony was re-run by reproducing its
input instead: main re-armed to `0.4.1-dev` (setup run 653), then a second
`release`-labeled PR stamping bare `0.4.1` merged on top. That is the same
state the door refuses on, reached by a push rather than a re-trigger, and it
is stricter than the sibling shape in one way — it re-enters through
`facts``decide` rather than replaying a decided run.
Which refusal fired is measurable even without run logs. Replaying the
door's own inputs against the live consumer at `82e7d11b`:
```
facts: ver='0.4.1' base_ver='0.4.1-dev' released='' labeled='yes'
decide: ceremony=yes
```
So `decide` said **go** — a labeled bare transition is row 6 — and the stop
came from the merge door's own pre-publish assert, `release.yml:216-219`,
whose comment names this exact probe ("what makes a re-run of a completed
ceremony refuse instead of clobber"): the tag existed, so it refused before
`forge_tag_create` ever ran. The second net behind it is #191's own verb,
and it reads this consumer correctly:
```
forge_release_exists 0.4.1 -> yes 0.5.0 -> yes 9.9.9 -> no
```
`9.9.9` is the probe-6 receipt in the same breath: the tag exists, the
release does not.
Evidence for every probe here is the run conclusion plus the repository
state before and after — this instance serves no run logs (the API 404s on
every log route), so no probe's result is quoted from a log line.
End state, as observed when this was written: releases `0.4.1` and `0.5.0`
and nothing else; tags `0.4.1` on `4a83fa1b`, `0.5.0` and `9.9.9` on
`b091aff2`; the consumer's `main` left at bare `0.4.1` where probe 4 stopped
it, private and archived.
**The asset names survived intact**: `a&b.tgz` and `drill asset.tgz` both
appear under those exact names on both releases. Before #191's fix the space
made curl reject the URL outright and the `&` split the query — the failure
landing *after* the tag exists, mid-publish, which is the worst place this
door has.
## Run 1 — against `9a229ee`, before the fix (FAILED)
Recorded because the failure is the reason #191 exists — including its
deviations, which no later success retires.
Where: disposable private repo `heavy-duty/ceremony-drill-0.4.1` — a
different consumer from run 2's, archived at the end with the operator's
delete pending. Candidate ref
`cluade-reviewer-andresmgsl/ceremony@drill-0.4.1` (`f148255`), parent
`9a229ee`, its only extra commit rewriting both `CEREMONY_SELF_REF` carriers
to that SHA.
**Deviation, disclosed and not retired by run 2:** run 1's scratch repo was
flipped **public for roughly 8 minutes** to read job logs — Forgejo's web log
route 404s for a token-authenticated private repo and the `/api/v1` log
routes 404 outright — then restored to private and archived. That is a real
departure from `drills/README.md`'s "scratch **private** repo", and it stays
in the record. Run 2 did not repeat it: it read no logs at all, which is why
every run-2 row is a repository-state measurement.
| # | probe | run | result |
|---|---|---|---|
| 1 | merge-door ceremony | 12 (and 7, at `0.4.1`) | ❌ **FAILED**`decide` refused; no tag, no release, main left bare |
| 2 | `-dev` push no-op | 9, and the fixture push | ✅ green no-op, nothing created |
| 3 | bare version, no `release` label | — | ⚠️ not run independently; probe 1 refused through exactly this path, because the label fact read `no` |
| 4 | re-run of a completed ceremony | — | ⚠️ **not reachable** — no ceremony ever completed to re-run |
| 5 | tag door | 14 | ❌ **FAILED**`gh: command not found` at *publish the release*; no release |
| 6 | mismatched tag | 16 | ✅ `tag '9.9.9' does not match the tree's version '0.4.2' — creating nothing` |
Root cause, both doors: `lib/facts.sh` and `release.yml` spoke `gh`, which
the runner image does not ship. `facts.sh` read *any* failure as a definite
`no`, so a missing binary silently demoted a release ceremony to "a bare
push". Release count across the whole of run 1: **0**.
## What changed between them
#191, merged as `fda5657`: both doors onto `lib/forge.sh`; a read that did
not complete refuses instead of fabricating a `no`; `forgejo_api_base`
refuses an empty `REPO` so `repos//…` cannot be addressed; release asset
names percent-encoded.
Every refusal path, in both runs, created nothing. That property never
broke — what broke was the doors' ability to say yes.

58
drills/0.5.0.md Normal file
View file

@ -0,0 +1,58 @@
# 0.5.0 — drill record
Run 2026-08-03 by `dan-claude-bot` against the release PR (Refs #233),
candidate branch `build/233-release-0-5-0` on `heavy-duty/ceremony` main at
`0ac3a6f`.
## Scope ruling — doors unchanged, no disposable-repo rehearsal
The 0.4.0 drill (drills/0.4.0.md) probed both release doors end-to-end in a
disposable repo; the live 0.4.0 and 0.4.1 releases then exercised the merge
door for real, twice, within the last three days. Measured at `0ac3a6f`,
stated as they are rather than as #233's D3 predicted them at `b18c0bc`:
- `git diff 0.4.1..main -- .github/workflows/release.yml`**empty**. No
door logic, no decide table, no publish step moved.
- `git diff 0.4.1..main -- bin`**empty**. No release tooling moved.
- `git diff 0.4.1..main -- lib`**not empty**: `lib/ruling.sh` +50/13,
#226's best-shaped escalation selection, merged this morning as PR #234.
That is the labels-sweep library the reconcilers source; nothing in the
release path reads it. The doors-unchanged ruling rests on the two empty
surfaces above, not on a lib claim this tag can no longer make.
Re-running a full disposable-repo drill would rehearse machinery this repo
proved in rehearsal at 0.4.0 and in production at both subsequent tags. This
record rests on the standing evidence below and says so honestly. The panel
reviews this claim like any other; if any reviewer rules a full drill owed,
that verdict wins (#233 D3).
## Standing evidence at the candidate head
| # | probe | where | result |
|---|---|---|---|
| 1 | decide + merge-door step-replay (dogfood) | `release-exercise / step-replay (dogfood)` on this PR | CI-gating; green required to merge |
| 2 | decide + merge-door step-replay (consumer) | `release-exercise / step-replay (consumer)` on this PR | CI-gating; green required to merge |
| 3 | fragment chain: armed → assemble → monotonic | `release-exercise / fixture-chain` on this PR | CI-gating; green required to merge |
| 4 | the real tree's own guards (armed, monotonic, drill-recorded, self-ref) | `self-guards` on this PR | CI-gating; green required to merge |
| 5 | the 0.5.0 payload's one lib delta: #226's best-shaped selection | `test/ruling.test.sh` in the `test` job at this head, plus claude-bot's independent reproduction in the #234 review round | green — suite coverage, **not** live dogfood; see below |
Probe 5 makes no live-exercise claim, and the reason is stated so the next
reader does not restore one: the post-merge sweeps (`30811983604`,
`30812172095`, both at `0ac3a6f`) do run the candidate's `lib/ruling.sh` on
the dogfood path, but every #226 call site sits inside `reconcile_ruling`,
whose single caller is behind the `needs-ruling` flag check
(`labels-reconcile.sh:865866`), and this board has no `needs-ruling` item,
standing or historical — so not one line of the delta this tag carries has
executed live here. Its coverage at this candidate is `test/ruling.test.sh`
(107 checks, including the crew#293 replay and the six regression proofs
named in PR #234) and the #234 round's independent re-run of that proof.
Per drills/README.md — the record is the evidence — and #135's defect
class, a smaller true probe stands where a larger unobserved one would not.
## Deviations
No candidate-ref deviation arises: this record stages no scratch caller, so
nothing needs to resolve `CEREMONY_SELF_REF: "0.5.0"` before the tag exists.
The sibling adoption proof is post-tag and crew's: crew#298's pin bump, then
its `panel[<login>]=` rows, then crew's first clean sweep — owned by that
issue's criteria, reported back on #233 per its post-merge criterion.

272
drills/0.6.0.md Normal file
View file

@ -0,0 +1,272 @@
# 0.6.0 — drill record
Run 2026-08-05 by `cndgrr` against the 0.6.0 release PR (Refs #249),
candidate branch `build/249-release-0-6-0`, canonical candidate SHA
`fb8f8282a9e7b317d4d028f8e8da50501a882d14`. All six probes ran; every row in
the table below was written from its own run.
## Scope ruling — a full rehearsal is owed, and doors-unchanged is refused
This record's shape was measured, not chosen. `drills/README.md` allows the
doors-unchanged shape only when all three of its conditions hold at the
candidate head; the first one does not.
The baseline is the last **rehearsed** tag, never the previous tag:
`drills/0.4.1.md` and `drills/0.5.0.md` are both doors-unchanged records, so
the anchor is **`0.4.0`**, whose record is a full disposable-repo rehearsal,
whose release is published, and after which `main` was re-armed to
`0.4.1-dev` (`84bb1a4`). Condition 3 holds.
The release path is exactly the output of `.github/scripts/release-path.sh`
at this head — `.github/workflows/release.yml`, `bin/`, `lib/version.sh`,
`lib/decide.sh`, `lib/facts.sh`, `lib/changelog.sh`. Condition 2 holds.
Condition 1 fails. Measured at this candidate:
```console
$ git diff 0.4.0..HEAD -- $(.github/scripts/release-path.sh)
.github/workflows/release.yml | 2 +-
lib/changelog.sh | 83 ++++++++++++++++++++++++++++++++++++++---
```
`release.yml`'s two lines are the `CEREMONY_SELF_REF` pin, which the
condition exempts. **`lib/changelog.sh` is not exempt and is not empty**: it
carries `72fa3e0` (the terminal issue-citation rule joining the fragment
guard, #262) and `75a5b68` (one fragment, one diagnosis, #262). That file is
on the release path because the merge door sources it to assemble and read
the release section — this is a door byte, not a neighbouring library, and
the last-rehearsed anchor exists precisely so an accumulated change like
this forces a new rehearsal rather than chaining a third doors-unchanged
assertion off the second.
So this release owes the disposable-repo rehearsal, and this record is it.
## Where
Disposable **private** repo `cndgrr/ceremony-drill-0.6.0`, created
2026-08-05T00:02:58Z. It carries the `docs/CONSUMERS.md` release caller
verbatim (`version-source: file`) over a fragment-mode fixture armed at
`0.6.0-dev`: a preamble-only `CHANGELOG.md`, `changelog.d/README.md` plus
one fragment, and a non-blank `drills/0.6.0.md`. The `release` label was
created there before the first ceremony PR, per the guide's prerequisite.
**Disposal, as this record's author observed it**: the repository is
**archived** — `PATCH /repos/cndgrr/ceremony-drill-0.6.0` with
`archived: true` returned `true`, and a fresh read afterwards reported
`archived=true private=true`. It is **pending the operator's delete**, which
this builder cannot perform: `delete_repo` is absent from fleet tokens by
doctrine (#135). No delete was attempted and none is claimed. Cleanup gates
nothing — not this PR's ready-for-review, not the panel, not the merge.
## Candidate-ref deviation
The pure consumer path cannot resolve this candidate's
`CEREMONY_SELF_REF: "0.6.0"`: that tag is the one this release has not
created yet. No `0.6.0` branch was created on `heavy-duty/ceremony`.
The scratch caller instead pins `cndgrr/ceremony/.github/workflows/release.yml@drill/0.6.0`.
That fork ref's parent is the canonical candidate SHA
`fb8f8282a9e7b317d4d028f8e8da50501a882d14`, and its one additional commit
(`775b4d1f6485ebdde924979ac2dce536643c6071`) rewrites all three
`CEREMONY_SELF_REF` carriers — `release.yml`, `labels.yml`,
`labels-sweep.yml` — to that same SHA. All runtime machinery in every probe
below was therefore fetched from the 0.6.0 candidate tree.
Commits pushed to the candidate after `fb8f828` are this record only; the
release path (`.github/scripts/release-path.sh`) is byte-identical at the
canonical SHA and at the final head.
## Probes
One row per probe, written from its run. Runs are in
`cndgrr/ceremony-drill-0.6.0`.
| # | probe | run | result |
|---|---|---|---|
| 1 | merge-door ceremony | 30992108742 (attempt 1) | ✅ exactly one `0.6.0` release; tag equals the merge commit; main re-armed to `0.6.1-dev` |
| 2 | mislabeled ordinary PR | 30991634654 | ✅ green NOTICE no-op; no tag, no release |
| 3 | bare-version PR without `release` | 30991832001 | ✅ refused at decide; no tag, no release |
| 4 | re-run completed ceremony | 30992108742 (attempt 2) | ✅ refused at the nothing-exists assert; the release count stayed one |
| 5 | manual matching tag | 30992258952 | ✅ `0.6.1` published from its own changelog section; main untouched |
| 6 | mismatched tag | 30992310031 | ✅ refused before publication; no `9.9.9` release, and the probe tag was removed afterwards |
### Probe 5 — a manual tag matching its tree
Branch `probe5-tag` carried `VERSION` at `0.6.1` and a
`## 0.6.1 — 2026-08-05` section; tag `0.6.1` was pushed at that commit
(`dfd0cfeaca772cf45bcb63a1a639829185510c60`) with a personal token, so it
fired the door — the anti-recursion property probe 1 relies on is exactly
what makes a hand-pushed tag the only way to reach this door. The
`release-on-merge` job skipped and `release-on-tag` ran: the version assert
passed, notes were extracted, the release published.
The branch, not main, carried the tagged tree on purpose — the tag door
takes no bump step, and pointing it at a side branch proves that without a
bare version ever sitting on main. Observed afterwards: `0.6.1` published
with exactly its own section's bullet, and main still reading `0.6.1-dev`,
untouched by the publish. Two releases now exist, `0.6.0` and `0.6.1`,
neither a draft, neither carrying assets.
### Probe 6 — a mismatched tag
Tag `9.9.9` was pushed at the same `0.6.1` commit. The door refused at its
first assert, before notes and before publication:
```text
tag '9.9.9' does not match the tree's version '0.6.1' — creating nothing.
```
Notes, the artifact hook and publish all skipped. `GET /releases` still
returned exactly `0.6.1` and `0.6.0`. The `9.9.9` ref was deleted afterwards
(`DELETE /git/refs/tags/9.9.9`); `GET /git/refs/tags` then listed `0.6.0`
and `0.6.1` only. The probe tag was the operator's artefact, never the
workflow's — the door created nothing, which is the whole assertion.
### Probe 1 — the merge-door ceremony
PR #4 (`probe1-ceremony`) bumped `0.6.0-dev` to bare `0.6.0` and stamped
`## 0.6.0 — 2026-08-05`, assembled from the three fixture fragments by the
candidate's own `bin/changelog-assemble` and committed with the deletions
in one commit. The `release` label was applied and confirmed before the
merge. Facts and verdict:
```text
VER: 0.6.0
BASE_VER: 0.6.0-dev
RELEASED:
LABELED: yes
ceremony=yes
```
Observed after the run:
- **Exactly one** release: `GET /releases` returned `0.6.0` alone, not a
draft, not a pre-release, zero assets (no artifact hook in the fixture —
the hook step skipped).
- `GET /tags` returned `0.6.0` alone, pointing at
`64d02539f4a20286afc08b9997f0f8a7d1dbfccd`, which is PR #4's merge commit
— the tag names the tree that was reviewed.
- The release body was byte-for-byte the assembled section's bullets:
```text
- A second ordinary fragment, written by probe 2 of the 0.6.0 drill (#249).
- An ordinary behavior change, landing under the release label (#249).
- Fragment mode is exercised by the ceremony 0.6.0 drill (#249).
```
- Main re-armed itself: commit `2d0e19a` ("bump main to 0.6.1-dev — a dev
install must not impersonate 0.6.0"), pushed by the job's own token. Main
reads `0.6.1-dev` and `changelog.d/` holds only `README.md`.
- **The anti-recursion property held.** Neither the tag create nor the bump
push started a workflow run — the run list after the ceremony ends at
30992108742. That is what makes the merge door the release's only chance
to publish, and it is the reason probe 4 below is the door's own guard
rather than a second run's.
### Probe 4 — a re-run of the completed ceremony
Re-running 30992108742 as attempt 2 re-decided `ceremony=yes` — the facts
at that merge commit have not changed — and then died at the assert:
```text
tag '0.6.0' already exists — this release already happened, or a manual tag won the race; refusing to re-release, creating nothing.
```
Tag, publish and bump all skipped. `GET /releases` still returned exactly
one `0.6.0`. The refusal is loud (the job is red) and creates nothing, which
is the required shape: the assert is what covers a manual tag racing the
merge, not only an operator's stray re-run.
### Probe 3 — a bare-version PR without the `release` label
PR #3 (`probe3-bare`) bumped `VERSION` to bare `0.6.0` and carried no label;
the label list was read as empty before merging. The merge run refused at
decide, row 5 of the table:
```text
VER: 0.6.0
BASE_VER: 0.6.0-dev
RELEASED:
LABELED: no
the version transitioned ('0.6.0-dev' -> '0.6.0') but no merged, release-labeled PR is behind this commit — a release is a labeled ceremony PR, not a bare push — creating nothing.
```
Notes, the assert, tag, hook, publish and bump all skipped; tags and
releases were both still empty afterwards. The merge was then undone and
main re-armed to `0.6.0-dev` before the ceremony probe ran (see Setup).
### Probe 2 — a mislabeled ordinary PR
PR #2 (`probe2b-mislabeled`) added one changelog fragment and touched no
version. The `release` label was applied through
`POST /repos/{owner}/{repo}/issues/2/labels` and confirmed present before
the merge. The merge run decided row 1 of the table and published nothing:
```text
VER: 0.6.0-dev
BASE_VER: 0.6.0-dev
RELEASED:
LABELED:
NOTICE: the version '0.6.0-dev' is -dev and unchanged by this PR — release-flow work under the release label, not a ceremony. Nothing to publish.
ceremony=no
```
`RELEASED` and `LABELED` are empty on purpose — the `-dev` rows never
consult them, which is precisely why the label alone cannot ship anything.
Notes, the nothing-exists assert, tag, artifact hook, publish and bump all
skipped; `GET /tags` and `GET /releases` were both empty afterwards.
An earlier merge (PR #1, run 30991571096) was intended as this probe but
landed **unlabeled**: `gh pr edit --add-label` failed against this repo's
projects-classic GraphQL surface, and the merge went ahead before the
failure was read. That run is a green no-op too, but it is not evidence for
this probe — an unlabeled ordinary merge proves less than a labeled one —
so the probe was re-run as PR #2 with the label applied through the REST
endpoint and verified before merging. Recorded here because the run exists
in the repo's history and a reader will find it.
## Setup, and the runs that are not probes
The armed fixture was committed before the caller, so the first door run had
a real parent version to inspect: run **30962040469** is that green baseline
no-op. The probes then ran in the order 2, 3, 1, 4, 5, 6 — the refusals
first, against an armed tree, so the ceremony itself ran last against a
fixture the refusals had already proven intact.
Three non-probe runs are on the board and are accounted for here rather than
left for a reader to guess at:
- **30991571096** (green) — PR #1, the unlabeled first attempt at probe 2,
described above.
- **30991892212** (green) — restoring `VERSION` to `0.6.0-dev` after probe
3's refusal, so the ceremony probe met an armed tree. Row 2 of the table:
the version changed and still ends `-dev`.
- **30991958967** (red) — **a builder error, not a door finding.** An
uncommitted `VERSION` bump left over from staging the ceremony branch rode
along into a setup commit that was meant to touch only the fragments, and
pushed bare `0.6.0` straight to main. The door refused it exactly as it
refused probe 3, by the same row-5 path, and created nothing: tags and
releases were both still empty when the failure was read. Main was re-armed
to `0.6.0-dev` (green run **30992046247**) before the ceremony probe. It is
written down because a red run on a drill repo that the record does not
explain is indistinguishable from a door that failed.
The fixture's three fragments were also rewritten mid-setup to carry
terminal issue citations. The candidate's own `bin/changelog-assemble`
refused them without one — `fragment 'changelog.d/1.md' has an entry with no
issue citation` — which is #262's rule, one of the two commits on
`lib/changelog.sh` that make this release owe a rehearsal at all. The
fixture had been written before that rule existed. The refusal is the guard
working; the correction is recorded because the fragments the ceremony
consumed are not the fragments the repo was created with.
## What the rehearsal establishes
Both doors ran live against the 0.6.0 candidate's own machinery. The merge
door published exactly one release from a labeled ceremony PR, tagged the
reviewed merge commit, and re-armed main itself; it refused a bare push
without a label, refused a re-run of its own completed ceremony, and stayed
a green no-op under a label carried by ordinary work. The tag door published
from a matching manual tag without touching main, and refused a mismatched
one before creating anything. Every refusal created nothing — no tag, no
release, on any of the four refusal paths.

116
drills/0.6.1.md Normal file
View file

@ -0,0 +1,116 @@
# 0.6.1 — drill record
Run 2026-08-09 by `codex-reviewer-andresmgsl` against release PR !226,
candidate branch `release-0.6.1`, canonical candidate SHA
`ba3b17af88e0fe1ccae9eefd4b37bf2666f6cdad`. All six probes ran on this
Forgejo instance. Each result below was read from its own run and from the
repository state after that run.
## Scope ruling — a full rehearsal was owed
The last rehearsed tag was `0.6.0`. The release path at this candidate is the
exact output of `.github/scripts/release-path.sh`. Its measured diff from
`0.6.0` is not pin-only: `.github/workflows/release.yml`, `lib/facts.sh`, and
the new `lib/forge.sh` changed. These are release-door bytes, so the
doors-unchanged record shape is refused and this release carries a full live
rehearsal.
## Where
Disposable **private** repository `heavy-duty/ceremony-drill-0.6.1`, created
by the operator on 2026-08-09 after the fleet identity's personal repository
could not see the organization runner. It carries the `docs/CONSUMERS.md`
release caller with `version-source: file`, a fragment-mode fixture armed at
`0.6.1-dev`, and a non-blank `drills/0.6.1.md`. The `release` label existed
before the first probe PR.
**Disposal as observed when this record was written:** the repository is
private and **not yet archived**. This builder's archive PATCH returned 403
because it has push but not admin permission. The operator was asked to
archive it; delete remains the operator's later step. No archive or delete is
claimed, and cleanup gates neither review nor merge.
## Candidate-ref deviation
The candidate tree pins `CEREMONY_SELF_REF` to `0.6.1`, a tag that did not
exist before this release. No branch named `0.6.1` was created on
`heavy-duty/ceremony`.
The scratch caller instead uses
`codex-reviewer-andresmgsl/ceremony/.github/workflows/release.yml@drill/0.6.1`.
That ref is commit `f766752090429241c20a0d86aba6f679d713fc2c`; its parent is the canonical
candidate SHA above, and its only change rewrites all three
`CEREMONY_SELF_REF` carriers to that canonical SHA. The release path is
therefore byte-identical to the candidate except for the required candidate
pin rewrite.
## Probes
| # | probe | run | result |
|---|---|---|---|
| 1 | merge-door ceremony | 5 | ✅ one `0.6.1` release; tag points to merge commit `438097fc75b1d294b448830a3a79c00b0ee7b83f`; main re-armed to `0.6.2-dev` |
| 2 | release-labelled ordinary PR | 2 | ✅ green no-op; zero tags and zero releases |
| 3 | bare-version PR without `release` | 3 | ✅ refused; zero tags and zero releases |
| 4 | completed ceremony repeated | 7 | ✅ refused; release count stayed one and tag `0.6.1` stayed on the original merge commit |
| 5 | manual matching tag | 8 | ✅ `0.6.2` published from its own changelog section; main was unchanged |
| 6 | mismatched tag | 9 | ✅ refused before publication; no `9.9.9` release, and the operator-created probe tag was removed afterwards |
### Probe 1 — merge door
PR #4 transitioned `VERSION` from `0.6.1-dev` to `0.6.1`, assembled both
fixture fragments, and carried the `release` label. Run 5 succeeded. The
published body is exactly the assembled `0.6.1` section, tag `0.6.1` points
to PR #4's merge commit, and the workflow pushed `0.6.2-dev` to main at
`1c74d76f035da4a13f7af0d2a43d611092061204`.
### Probe 2 — labelled ordinary work
PR #1 carried the `release` label and added only `changelog.d/2.md`.
`VERSION` stayed `0.6.1-dev`. Run 2 succeeded as a no-op; the tag and release
lists were both empty afterwards.
### Probe 3 — bare version without declared intent
PR #2 assembled the two fragments and transitioned to bare `0.6.1`, but had
no `release` label. Run 3 failed. Both tag and release lists remained empty.
Setup PR #3 then reverted that probe and restored the armed fixture; run 4
was green before the ceremony probe began.
### Probe 4 — completed ceremony repeated
Forgejo exposes no run-rerun API, so this probe reproduced the completed
door's input. Setup PR #5 restored `0.6.1-dev` and run 6 was green. Release-
labelled PR #6 transitioned to bare `0.6.1` again. Run 7 failed at the
already-existing tag guard. The release count stayed one and tag `0.6.1`
still pointed to the original ceremony merge commit, not PR #6.
### Probe 5 — matching manual tag
Side branch `probe5-tag` carried bare `VERSION 0.6.2` and a publishable
`0.6.2` changelog section. Tag `0.6.2` was created at
`a4ba62aa83fdd2e259ed3c3e906b13eaab1abcd0`. Run 8 succeeded and published
only that section. Main stayed at the state left by probe 4; the tag door did
not push a version change to it.
### Probe 6 — mismatched tag
Tag `9.9.9` was created at the same `0.6.2` tree. Run 9 failed before
publication. The release list remained exactly `0.6.1` and `0.6.2`. The
operator-created `9.9.9` ref was then deleted; the remaining tag list was
exactly `0.6.1` and `0.6.2`.
## Runs that are setup, not probes
- Run 1: the initial `0.6.1-dev` fixture push; green no-op.
- Run 4: PR #3 restored the armed fixture after probe 3; green.
- Run 6: PR #5 restored `0.6.1-dev` to reproduce the completed ceremony
input for probe 4; green.
## What this rehearsal establishes
Both release doors ran against the 0.6.1 candidate's Forgejo implementation.
The merge door published once, tagged the reviewed merge commit, and re-armed
main. It stayed a green no-op for labelled ordinary work, refused a bare
unlabelled transition, and refused a repeated ceremony. The tag door
published a matching tag without changing main and refused a mismatched tag
without publishing. Every refusal created no tag or release of its own.

38
drills/0.6.2.md Normal file
View file

@ -0,0 +1,38 @@
# 0.6.2 — drill record
Measured 2026-08-24 on release PR !250, candidate branch
`build/231-release-0-6-2`. The release-path measurement was taken at
`fdb7d7577b4b30c11f2db9b47f278e2a16783c8c`; this record is the only later
candidate-tree change and `drills/` is outside the release path.
## Scope ruling — doors unchanged, no disposable-repo rehearsal
The last rehearsed tag is `0.6.1`. All three doors-unchanged conditions in
`drills/README.md` hold at this candidate tree:
1. `git diff 0.6.1..HEAD -- $(sh .github/scripts/release-path.sh)` changes
only the `CEREMONY_SELF_REF` pin in `.github/workflows/release.yml`, from
`0.6.1` to `0.6.2`. No release-door logic, decision, fact gathering,
version handling, changelog handling, or publish step changed.
2. The measured release path is exactly the output of
`.github/scripts/release-path.sh`:
```text
.github/workflows/release.yml
bin/
lib/version.sh
lib/decide.sh
lib/facts.sh
lib/changelog.sh
lib/forge.sh
```
3. `drills/0.6.1.md` records a full six-probe rehearsal. The Forgejo release
API reported `0.6.1` published at `2026-08-09T19:46:56Z`, neither draft nor
prerelease, and `origin/main:VERSION` read `0.6.2-dev`, confirming that main
was re-armed after that release.
A new disposable-repo rehearsal would therefore execute the same release-door
bytes as the full 0.6.1 rehearsal. This record asserts only the mechanically
checked doors-unchanged case; the release panel may still rule that a full
rehearsal is owed.

74
drills/0.6.3.md Normal file
View file

@ -0,0 +1,74 @@
# 0.6.3 — drill record
Measured 2026-08-26 on the `release-0.6.3` candidate branch, canonical
candidate SHA `03cb69d`. All three measurements below were taken at that head,
not copied from an earlier record.
## Scope ruling — doors unchanged, no disposable-repo rehearsal
**The last rehearsed tag is `0.6.1`, not `0.6.2`.** `drills/0.6.2.md` is itself
a doors-unchanged assertion, and `drills/README.md` anchors the baseline to the
last *rehearsed* tag precisely so one such assertion cannot chain from another
while the doors drift a small diff at a time. The baseline used here is
therefore `0.6.1`, which widens the measured window rather than narrowing it.
All three conditions hold at the candidate head:
1. `git diff 0.6.1..HEAD -- $(sh .github/scripts/release-path.sh)` changes only
the `CEREMONY_SELF_REF` pin line in `.github/workflows/release.yml`, from
`0.6.1` to `0.6.3`:
```diff
@@ -129,7 +129,7 @@ env:
- CEREMONY_SELF_REF: "0.6.1"
+ CEREMONY_SELF_REF: "0.6.3"
```
No release-door logic, decision table, fact gathering, version handling,
changelog handling, forge adapter or publish step changed across two
releases. The measured diff is one line.
2. The measured release path is exactly the output of
`.github/scripts/release-path.sh` at this head:
```text
.github/workflows/release.yml
bin/
lib/version.sh
lib/decide.sh
lib/facts.sh
lib/changelog.sh
lib/forge.sh
```
3. `drills/0.6.1.md` records a **full rehearsal** — run 2026-08-09 by
`codex-reviewer-andresmgsl` against release PR !226 — its release is
published, and `main` was re-armed to `0.6.2-dev` after it by
`5693bee chore: bump main to 0.6.2-dev`.
## What this release contains
Nine fragments consumed into `## 0.6.3`: #234, #240, #241, #243, #247, #251,
#253, #263, #265. None of them touches the release path; the list is
board-flow, labels-reconcile, docs and forge-adapter work.
## What is NOT asserted
This record asserts only that a fresh disposable-repo rehearsal would execute
the same release-door bytes as `0.6.1`'s did. It does not assert that the
doors were re-exercised, and it is not a substitute for the rehearsal owed at
the next release-path change. Per `drills/README.md`, the release PR's panel
verifies this claim like any other evidence, and **if any reviewer rules a full
drill owed, that verdict wins.**
## Note on 0.6.2's red `self-guards`
Recorded here because the next reader of `drills/` will see it. The `0.6.2`
tag's commit `5a8fce83` is red on `CI / self-guards``changelog-armed`
correctly refused a tree in which `changelog.d/238.md` was still unconsumed,
because that fragment landed on `main` after `changelog-assemble 0.6.2` had
already run in the release branch. The content shipped correct (the #238 entry
is in the published `0.6.2` notes) and the strand was consumed on `main` by
`f221647`. The systemic guard against the race landed in !255, *"refuse release
PRs that strand target fragments"*, which is why this candidate was checked
against it before opening.

View file

@ -10,24 +10,36 @@ drill is where they run live *before* a version rests on them.
## The rehearsal ## The rehearsal
1. Create a scratch **private** repo. It is disposable by design — it gets 1. Create a scratch **private** repo. It is disposable by design — but the
deleted at the end. disposal is split, because the builder cannot perform the delete: at the
2. Install the docs/CONSUMERS.md caller stubs, pinned to the release end the builder **archives** it (`PATCH /repos/{owner}/{repo}` with
candidate ref. A branch ref works: refs are static identifiers — the `archived: true`, inside the `repo` scope every fleet identity holds),
family's own drill doctrine. and **deleting it is the operator's step**`delete_repo` is
deliberately absent from bot tokens, fleet doctrine and not a
misconfiguration, so no builder that will ever run a drill can do it.
Do not retry the delete and do not wait on it: both 0.2.0 drills ended
at that wall independently (#135) — one builder held its release draft
in `state:building` re-trying a 403 that cannot succeed, the other wrote
a record asserting a delete that had not happened. **Cleanup gates
nothing** — not ready-for-review, not the review panel, not the merge.
The archived leftover is safe to leave: private, no consumers, and
outside `heavy-duty/ceremony`'s ref namespace — the namespace the
"never a branch named like the tag" rule below protects.
2. Install the docs/CONSUMERS.md caller stubs, pinned to a fork ref carrying
the release candidate tree. The candidate's `CEREMONY_SELF_REF` is by
construction the tag this release has not created yet, so the consumer
path cannot resolve directly from the candidate. Rewrite that pin to a
canonical candidate SHA in every carrier on the fork ref, and record the
fork ref and rewritten pin in the drill record.
**Except for the first release** (learned drilling 0.1.0, #11): the Never create a branch named like the tag on `heavy-duty/ceremony` to
stubs' consumer path fetches ceremony at `CEREMONY_SELF_REF` — the very paper over this deadlock: it would shadow the tag for every consumer
ref the first drill exists to rehearse creating — so the pure pinned until someone remembers to delete it. The 0.1.0 drill (#11) is the
path cannot run before some `X.Y.Z` ref exists, and a branch named like worked example of this standing fork-ref shape.
the tag must NOT be created on the canonical repo to paper over it (it 3. Give it a fixture `VERSION` / `CHANGELOG.md` / `changelog.d/` /
would shadow the tag for every consumer until someone remembers to `drills/` in the armed state (`X.Y.Z-dev`, the fragments directory with
delete it). The first drill instead pins the callers to a fork ref its `README.md` marker plus at least one fragment for the ceremony to
carrying the candidate tree with `CEREMONY_SELF_REF` rewritten to the consume).
candidate SHA in every pin carrier, and records that one-line deviation
in its record. From the second release on, this paragraph is moot.
3. Give it a fixture `VERSION` / `CHANGELOG.md` / `drills/` in the armed
state (`X.Y.Z-dev`, `## Unreleased` on top).
4. Exercise both doors, one probe at a time: 4. Exercise both doors, one probe at a time:
1. a merge-door ceremony publishes exactly one release and re-arms main 1. a merge-door ceremony publishes exactly one release and re-arms main
@ -45,10 +57,64 @@ drill is where they run live *before* a version rests on them.
One file per version, `drills/X.Y.Z.md` — the shape the siblings use: what One file per version, `drills/X.Y.Z.md` — the shape the siblings use: what
was run, where, the result of each probe, failures written down plainly. The was run, where, the result of each probe, failures written down plainly. The
record is the evidence; the scratch repo is the evidence's scaffolding and record is the evidence; the scratch repo is the evidence's scaffolding. The
is deleted afterwards. record names the scratch repo by full `owner/name` and states its disposal
state **as its author observed it when the record was written** — archived
and pending the operator's delete, or deleted only if the author genuinely
performed the delete. Never a disposal the author did not observe: the
record is the only thing that survives the drill, and 0.2.0's record shipped
its first draft asserting a cleanup that had not happened (#135) — false
evidence in the one file whose job is to be evidence.
A record has one of three shapes. A **rehearsal** records the disposable-repo
run above. **Doors unchanged** records the mechanically checked claim below
when a new rehearsal would execute the same bytes as the last one. **WAIVED**
records a maintainer's judgement under the standing paragraph below. If the
doors-unchanged conditions do not all hold, the release owes a rehearsal or a
waiver; the narrower shape is never a substitute for either.
## Doors unchanged
The builder may assert that no disposable-repo rehearsal is owed only when
all three conditions below hold at the candidate head. The release PR's panel
verifies the claim like any other evidence, and if any reviewer rules a full
drill owed, that verdict wins.
1. `git diff <last-rehearsed-tag>..HEAD -- <release-path>` contains no change
except the `CEREMONY_SELF_REF` pin line in
`.github/workflows/release.yml`.
2. The release path is exactly the output of
`.github/scripts/release-path.sh`. Run the script and paste its output into
the record; measure the diff with
`git diff <last-rehearsed-tag>..HEAD -- $(sh .github/scripts/release-path.sh)`.
The script's contract test keeps its list and the workflow's direct and
transitive dependencies in agreement.
3. The last rehearsed tag's own record is a full rehearsal, its release is
published, and `main` was re-armed to `-dev` after it.
The baseline is the last **rehearsed** tag, never merely the previous tag. A
previous-tag baseline could chain one doors-unchanged assertion from another
while the doors drift a small diff at a time; the last-rehearsed anchor makes
any accumulated release-path change force a new rehearsal.
The record carries all three measurements as observed at its candidate head,
never copied from an earlier record. `drills/0.4.1.md` and
`drills/0.5.0.md` are the worked examples; the latter's amendment from a
predicted empty `lib/` diff to the observed `lib/ruling.sh` delta is why each
candidate is measured afresh (#233). Re-running its stricter baseline now is
also the path-enumeration proof: `git diff 0.4.0 0.5.0 -- <release-path>` is
only the `CEREMONY_SELF_REF` pin, while adding `lib/ruling.sh` makes the diff
non-empty even though neither release door reads that file (#217, #237).
`actions/drill-recorded` refuses any bare-version tree whose record is `actions/drill-recorded` refuses any bare-version tree whose record is
missing or blank. A waived drill is still a record: the file says WAIVED and missing or blank. A waived drill is still a record: the file says WAIVED and
why — a maintainer's call, visible and reviewable in the release PR's diff, why — a maintainer's call, visible and reviewable in the release PR's diff,
never a silent skip. never a silent skip.
---
**Standing runner probes are not drills.** The disposal rule above — builder
archives, operator deletes — is for the disposable scratch repo a drill runs
in. `heavy-duty/ceremony-runner-probe` is the opposite shape: it stands, and
archiving it is the failure mode that made all three previous drill repos
unavailable. See [docs/RUNNER-PROBES.md](../docs/RUNNER-PROBES.md) (#202).

104
lib/attention.sh Normal file
View file

@ -0,0 +1,104 @@
#!/usr/bin/env bash
# lib/attention.sh — the `attention` target invariants (#232, epic #229).
#
# Both reconcilers source this file. Pure decisions sit above the divider;
# the impure orchestrator below reads the current label episode and comments
# through the sourcing script's run()/log(). The machine diagnoses only: it
# never sets, clears, retargets or assigns anything on the strength of these
# checks (#229 D2).
ATTENTION_MARKER_PREFIX='<!-- ceremony:attention-malformed:'
# ---------------------------------------------------------------------------
# Pure decisions. Facts in, verdict out. No gh, no clock.
# ---------------------------------------------------------------------------
attention_target_decision() { # $1 pr|issue, $2 assignee count → MALFORMED_* | KEEP
case "$1" in
pr) echo MALFORMED_PR ;;
issue)
if [ "$2" -eq 0 ]; then echo MALFORMED_UNASSIGNED; else echo KEEP; fi ;;
*) return 2 ;;
esac
}
attention_comment_decision() { # $1 target verdict, $2 suppression → POST | SUPPRESS | KEEP
case "$1" in
KEEP) echo KEEP ;;
MALFORMED_UNASSIGNED)
if [ -n "$2" ]; then echo SUPPRESS; else echo POST; fi ;;
MALFORMED_PR) echo POST ;;
*) return 2 ;;
esac
}
attention_newest_flag() { # labeled-event ISO-8601 timestamps on stdin → newest
sort | tail -n1
}
attention_episode_marker() { # $1 current episode's labeled timestamp
printf '%s%s -->\n' "$ATTENTION_MARKER_PREFIX" "$1"
}
# ---------------------------------------------------------------------------
# The impure orchestrator. Called only behind a has-attention gate.
# Needs REPO; uses the caller's run() and log().
# ---------------------------------------------------------------------------
reconcile_attention() { # $1 item, $2 pr|issue, $3 assignees, $4 suppression
local n="$1" surface="$2" assignees="$3" suppression="${4:-}"
local target comment labeled_events labeled_at marker comments body timeline
: "${REPO:?reconcile_attention: REPO is required}"
target="$(attention_target_decision "$surface" "$assignees")"
comment="$(attention_comment_decision "$target" "$suppression")"
[ "$comment" != KEEP ] || return 0
if [ "$comment" = SUPPRESS ]; then
log "#$n: malformed attention detected; comment suppressed by $suppression precedence"
return 0
fi
# forge_timeline projects both forges into the GitHub event shape
# (.event / .label.name) — Forgejo's raw timeline carries neither. Capture
# its status BEFORE jq: a pipeline's status is the last command's, so
# `forge_timeline | jq` would collapse an unreadable timeline into an empty
# one, and those are the two states this function exists to tell apart
# (#188, and lib/ruling.sh does the identical dance).
if ! timeline="$(forge_timeline "$n" 2>/dev/null)"; then
log "#$n: attention timeline unreadable — no verdict invented this pass"
return 0
fi
labeled_events="$(jq -r '.[]
| select(.event == "labeled" and .label.name == "attention")
| .created_at' <<<"$timeline")"
if [ -z "$labeled_events" ]; then
log "#$n: attention flag has no visible labeled event — no verdict invented this pass"
return 0
fi
labeled_at="$(attention_newest_flag <<<"$labeled_events")"
marker="$(attention_episode_marker "$labeled_at")"
if ! comments="$(forge_api --paginate "repos/$REPO/issues/$n/comments" \
--jq '.[].body // ""' 2>/dev/null)"; then
log "#$n: attention comments unreadable — no verdict invented this pass"
return 0
fi
grep -qF "$marker" <<<"$comments" && return 0
case "$target" in
MALFORMED_PR)
body="$marker
This pull request carries \`attention\`, but that label is issue-only. Put
the label on the assigned issue that owns the claim. The sweep cannot infer
which issue that is, so it reports the malformed target without removing or
retargeting the label." ;;
MALFORMED_UNASSIGNED)
body="$marker
This issue carries \`attention\` but has no assignee to receive the demand.
Assign the intended builder or remove the flag. The sweep reports the board
bug without assigning anyone or changing the label." ;;
esac
run forge_issue_comment "$n" "$body" >/dev/null
log "#$n: malformed attention ($surface) — commented; no label or assignee changed"
}

View file

@ -20,3 +20,418 @@ changelog_section() {
found { body = 1; print } found { body = 1; print }
' "$1" ' "$1"
} }
# changelog_section_problem <file> <version>
#
# Print the first reason a version section cannot be published. Unreleased is
# a work-in-progress template, so its headings may deliberately be empty.
# A printed problem returns 1; silence returns 0.
changelog_section_problem() {
local file="$1" ver="$2" notes problem
if ! awk -v ver="$ver" '/^## / && $2 == ver { found = 1; exit } END { exit !found }' "$file"; then
printf "no section for '%s'\n" "$ver"
return 1
fi
[ "$ver" = "Unreleased" ] && return 0
notes="$(changelog_section "$file" "$ver")"
if ! printf '%s\n' "$notes" | awk '/^[[:space:]]*[-*][[:space:]]/ { found = 1; exit } END { exit !found }'; then
printf "section '%s' has no entries — a heading is not an entry\n" "$ver"
return 1
fi
problem="$(
printf '%s\n' "$notes" | awk '
/^### / {
if (heading != "" && !entry) {
reported = 1
print heading
exit
}
heading = $0
entry = 0
next
}
heading != "" && /^[[:space:]]*[-*][[:space:]]/ { entry = 1 }
END {
if (!reported && heading != "" && !entry) print heading
}
'
)"
if [ -n "$problem" ]; then
printf "section '%s' has an empty heading: '%s'\n" "$ver" "$problem"
return 1
fi
}
# changelog_fragments <dir>
#
# Print fragment paths in publication order, one per line: trailing issue
# number descending — newest issue first, the way every section in this
# family already reads — tie-broken on the filename. Considers *.md only
# and skips README.md, the marker that keeps the directory trackable when
# it holds no fragments (#112 D1). An absent or fragment-free directory
# prints nothing and succeeds: whether "no fragments" is a problem belongs
# to the caller — the assembler refuses an empty release, the arming guard
# is satisfied by the directory existing.
changelog_fragments() {
local dir="$1" f base num
[ -d "$dir" ] || return 0
for f in "$dir"/*.md; do
[ -e "$f" ] || continue
base="${f##*/}"
[ "$base" = "README.md" ] && continue
num="${base%.md}"
num="${num##*[!0-9]}"
[ -n "$num" ] || num=0
printf '%s\t%s\t%s\n' "$num" "$base" "$f"
done | sort -t "$(printf '\t')" -k1,1nr -k2,2 | cut -f3-
}
# changelog_fragment_problem <file>
#
# Print the first reason a fragment cannot publish and return 1; silence
# returns 0. The same contract as changelog_section_problem, moved onto the
# PR that writes the fragment (#112 D9): a fragment is checkable the moment
# it exists, so malformedness fails the PR that wrote it, not the release
# that consumes it. The rules, and the failure each refuses:
# - name '<issue>.md' or '<repo>-<issue>.md': anything else has no
# derivable order, and an invented name is the "two builders, one
# filename" collision the naming scheme exists to avoid (#112 D2);
# - no '## ' line: the section heading is the assembler's to write, and
# a smuggled one would split the published section;
# - at least one bullet: a heading is not an entry — the rule the
# publisher enforces at release time, moved onto the PR;
# - no '### ' heading without a bullet before the next heading or EOF:
# the dangling grouped heading #98 taught us to refuse.
# - no entry longer than 300 characters (#167): 0.3.0 shipped a cluster of
# 316789-character entries straight through the prose rule, so the
# bound moves onto the PR like every other fragment rule. Measured on
# the normalized entry — continuation lines joined, whitespace runs
# collapsed to one space, the '- '/'* ' marker stripped, the '(#N)'
# citation included — so wrapping alone can never red an entry. 300
# splits the measured history: every healthy entry passes untouched,
# the drift cluster does not. mawk's length() counts bytes; prose here
# is ASCII and the fuzz is acceptable.
# - every entry ends with its issue citation (#262): one '(' group of
# '#N', 'repo#N' or 'owner/repo#N' references separated by ', ', then
# ')', then the final '.' and nothing after it. Stated as style and
# enforced by nobody, this rule cost #255 a full four-bot round on a
# missing '(#248)'; the fragment rules that live in this guard drew no
# review comment at all across the same fifteen PRs. Measured on the
# same normalized entry as the bound above, so a citation that wraps
# onto a continuation line still counts. The repo token is the one the
# filename rule already admits, so '<repo>-<issue>.md' and its cite
# cannot drift apart; the two halves of one convention. A single group
# is what makes 'terminal' checkable — '(#236, #250).' lands two issues
# in one entry, '(#236) and (#250).' does not. The citation need not
# name the file's own issue: the filename already carries the
# authorizing one, so a fragment may cite the incident beside it.
changelog_fragment_problem() {
local file="$1" base problem kind detail rest
base="${file##*/}"
if ! printf '%s\n' "$base" | grep -qE '^([a-z][a-z0-9-]*-)?[0-9]+\.md$'; then
printf "fragment '%s' is not named for its issue — want <issue>.md or <repo>-<issue>.md\n" "$file"
return 1
fi
if grep -q '^## ' "$file"; then
printf "fragment '%s' carries a '## ' heading — the section heading is the assembler's to write\n" "$file"
return 1
fi
if ! grep -qE '^[[:space:]]*[-*][[:space:]]' "$file"; then
printf "fragment '%s' has no entries — a heading is not an entry\n" "$file"
return 1
fi
problem="$(
awk '
/^### / {
if (heading != "" && !entry) {
reported = 1
print heading
exit
}
heading = $0
entry = 0
next
}
heading != "" && /^[[:space:]]*[-*][[:space:]]/ { entry = 1 }
END {
if (!reported && heading != "" && !entry) print heading
}
' "$file"
)"
if [ -n "$problem" ]; then
printf "fragment '%s' has an empty heading: '%s'\n" "$file" "$problem"
return 1
fi
# One walk of the entries, two rules, and the order between them is
# deliberate: an over-long entry anywhere outranks a citation problem
# anywhere, so the length diagnosis a fragment already draws is the same
# one it drew before the citation rule existed. Both read the entry the
# same normalizer produces, which is the whole reason they share a pass.
problem="$(
awk -v max=300 '
# cite_problem <entry> — "", "uncited" or "misplaced". Counting the
# groups is what distinguishes the two admitted shapes: one group
# closing the entry passes however many references it carries, and a
# second group anywhere means no single group is terminal.
function cite_problem(e, rest, groups, consumed, group_end) {
rest = e
groups = 0
consumed = 0
while (match(rest, /\((([A-Za-z0-9._-]+\/)?[a-z][a-z0-9-]*)?#[0-9]+(, (([A-Za-z0-9._-]+\/)?[a-z][a-z0-9-]*)?#[0-9]+)*\)/)) {
groups++
group_end = consumed + RSTART + RLENGTH - 1
consumed = group_end
rest = substr(rest, RSTART + RLENGTH)
}
if (groups == 0) return e ~ /#[0-9]/ ? "misplaced" : "uncited"
if (groups > 1) return "misplaced"
return (group_end == length(e) - 1 && substr(e, group_end + 1) == ".") ? "" : "misplaced"
}
function excerpt(e) {
return length(e) > 60 ? substr(e, 1, 60) "…" : e
}
function flush( len, e, kind) {
if (entry == "") return 0
e = entry
entry = ""
gsub(/[[:space:]]+/, " ", e)
sub(/^ /, "", e)
sub(/ $/, "", e)
len = length(e)
if (len > max) {
reported = 1
printf "long\t%d\t%s\n", len, excerpt(e)
return 1
}
kind = cite_problem(e)
if (kind != "" && cite_kind == "") {
cite_kind = kind
cite_excerpt = excerpt(e)
}
return 0
}
/^### / { if (flush()) exit; next }
/^[[:space:]]*[-*][[:space:]]/ {
if (flush()) exit
entry = $0
sub(/^[[:space:]]*[-*][[:space:]]+/, "", entry)
next
}
/^[[:space:]]*$/ { next }
entry != "" { entry = entry " " $0 }
# An exit from a main rule still runs END, so a length row printed
# mid-file would be followed by the citation row it outranks — two
# lines spliced into one diagnosis, the internal protocol row landing
# inside the human-facing excerpt (#262 round 1). The reported flag is
# the same guard the empty-heading walk above uses, for the same
# reason: one diagnosis per fragment is the contract.
END {
if (flush()) exit
if (!reported && cite_kind != "") printf "%s\t\t%s\n", cite_kind, cite_excerpt
}
' "$file"
)"
if [ -n "$problem" ]; then
kind="${problem%%$'\t'*}"
rest="${problem#*$'\t'}"
detail="${rest%%$'\t'*}"
rest="${rest#*$'\t'}"
case "$kind" in
long)
printf "fragment '%s' has a %s-character entry — '%s' — the bound is 300: split it into multiple '- ' entries in this same fragment\n" \
"$file" "$detail" "$rest"
;;
uncited)
printf "fragment '%s' has an entry with no issue citation — '%s' — end it with the issue it comes from: '(#N).'\n" \
"$file" "$rest"
;;
*)
printf "fragment '%s' has an entry whose issue citation is not terminal — '%s' — exactly one '(#N)' group ends the entry, the final '.' after it\n" \
"$file" "$rest"
;;
esac
return 1
fi
}
# changelog_shape_problem <changelog> <fragments-dir>
#
# Print the first reason a fragment set cannot publish and return 1; silence
# returns 0. Shape is a set-level property (#157 D3), so this is the one
# definition shared by the PR-time guard and the release-time assembler:
# fragments may not mix grouped headings with ungrouped bullets, and a
# non-empty set must match the newest published section when one exists.
#
# The anchor is declarable (#182): an optional sentinel '<dir>/shape',
# holding exactly 'flat' or 'grouped' on one line, pins the set's shape and
# outranks the newest-published-section inference — the door a deliberate
# flip walks through, while undeclared drift stays red (#159). Absent, the
# inference binds unchanged. Any other content — empty, trailing junk, an
# unknown word — is a diagnosis naming the file, never a silent fallback.
# The sentinel lives in the fragments dir so it binds in both callers: the
# assembler calls with changelog="" and still sees it. It is not a fragment
# — changelog_fragments matches *.md only, so 'shape' never enters the list.
changelog_shape_problem() {
local changelog="$1" dir="$2"
local fragments f grouped_in="" ungrouped_in="" published="" published_body=""
local sentinel="$dir/shape" declared=""
if [ -f "$sentinel" ]; then
# The one-line contract is checked on the file itself: command
# substitution strips every trailing newline, so the captured word
# cannot tell 'grouped' from 'grouped' plus blank lines.
if [ "$(wc -l <"$sentinel")" -gt 1 ]; then
printf "'%s' declares neither shape — its whole content must be 'flat' or 'grouped', one line\n" "$sentinel"
return 1
fi
declared="$(cat "$sentinel")"
case "$declared" in
flat | grouped) ;;
*)
printf "'%s' declares neither shape — its whole content must be 'flat' or 'grouped', one line\n" "$sentinel"
return 1
;;
esac
fi
fragments="$(changelog_fragments "$dir")"
[ -n "$fragments" ] || return 0
while IFS= read -r f; do
if [ -z "$grouped_in" ] && grep -q '^### ' "$f"; then
grouped_in="$f"
fi
if [ -z "$ungrouped_in" ] && awk '
/^### / { exit(found ? 0 : 1) }
/^[[:space:]]*[-*][[:space:]]/ { found = 1 }
END { exit(found ? 0 : 1) }' "$f"; then
ungrouped_in="$f"
fi
done <<<"$fragments"
if [ -n "$grouped_in" ] && [ -n "$ungrouped_in" ]; then
if [ "$grouped_in" = "$ungrouped_in" ]; then
printf "fragment '%s' mixes grouped headings and ungrouped bullets — a repo is one shape or the other\n" "$grouped_in"
else
printf "fragment '%s' is grouped but fragment '%s' is not — a repo is one shape or the other\n" "$grouped_in" "$ungrouped_in"
fi
return 1
fi
if [ -n "$declared" ]; then
if [ "$declared" = "grouped" ] && [ -n "$ungrouped_in" ]; then
printf "fragment '%s' is flat but '%s' declares grouped — a repo is one shape or the other\n" \
"$ungrouped_in" "$sentinel"
return 1
fi
if [ "$declared" = "flat" ] && [ -n "$grouped_in" ]; then
printf "fragment '%s' is grouped but '%s' declares flat — a repo is one shape or the other\n" \
"$grouped_in" "$sentinel"
return 1
fi
return 0
fi
if [ -f "$changelog" ]; then
published="$(awk '$1 == "##" && $2 != "Unreleased" { print $2; exit }' "$changelog")"
fi
[ -n "$published" ] || return 0
published_body="$(changelog_section "$changelog" "$published")"
if printf '%s\n' "$published_body" | grep -q '^### '; then
if [ -n "$ungrouped_in" ]; then
printf "fragment '%s' is flat but newest published section '%s' in '%s' is grouped — a repo is one shape or the other\n" \
"$ungrouped_in" "$published" "$changelog"
return 1
fi
elif [ -n "$grouped_in" ]; then
printf "fragment '%s' is grouped but newest published section '%s' in '%s' is flat — a repo is one shape or the other\n" \
"$grouped_in" "$published" "$changelog"
return 1
fi
}
# changelog_assemble <dir>
#
# Print the assembled section body — no '## ' line; that heading belongs to
# the caller — for every fragment in changelog_fragments order. Assumes each
# fragment already passed changelog_fragment_problem; the one property only
# the whole set can show is shape: a repo is grouped or flat, never both
# (#112 D4), because merging the shapes would silently strand ungrouped
# bullets, so a mix prints a diagnosis naming the offending fragments and
# returns 1. Group order is canonical (#112 D5): Added, Changed, Fixed,
# Removed, Deprecated, Security, then any other group in first-seen order —
# appended, never dropped. Inside a group, fragment order is preserved, and
# a bullet's continuation lines travel with it verbatim: entries in this
# family wrap, and reflowing someone's prose is not this tool's business.
# An empty directory prints nothing and succeeds; refusing an empty release
# is the caller's stance, not this function's.
changelog_assemble() {
local dir="$1" nl=$'\n'
local fragments f grouped_in="" chunk g seen="" ordered="" body first=1 diagnosis
fragments="$(changelog_fragments "$dir")"
[ -n "$fragments" ] || return 0
if ! diagnosis="$(changelog_shape_problem "" "$dir")"; then
printf '%s\n' "$diagnosis"
return 1
fi
grouped_in="$(printf '%s\n' "$fragments" | while IFS= read -r f; do
if grep -q '^### ' "$f"; then
printf '%s\n' "$f"
break
fi
done)"
if [ -z "$grouped_in" ]; then
while IFS= read -r f; do
chunk="$(awk 'body || !/^[[:space:]]*$/ { body = 1; print }' "$f")"
[ -n "$chunk" ] || continue
printf '%s\n' "$chunk"
done <<<"$fragments"
return 0
fi
while IFS= read -r f; do
while IFS= read -r g; do
printf '%s' "$seen" | grep -qFx -- "$g" || seen="$seen$g$nl"
done < <(awk '/^### / { name = substr($0, 5); sub(/[[:space:]]+$/, "", name); print name }' "$f")
done <<<"$fragments"
for g in Added Changed Fixed Removed Deprecated Security; do
printf '%s' "$seen" | grep -qFx -- "$g" && ordered="$ordered$g$nl"
done
while IFS= read -r g; do
[ -n "$g" ] || continue
case "$g" in
Added | Changed | Fixed | Removed | Deprecated | Security) ;;
*) ordered="$ordered$g$nl" ;;
esac
done <<<"$seen"
while IFS= read -r g; do
[ -n "$g" ] || continue
body=""
while IFS= read -r f; do
chunk="$(awk -v want="$g" '
/^### / { name = substr($0, 5); sub(/[[:space:]]+$/, "", name); ingroup = (name == want); next }
ingroup' "$f" | awk 'body || !/^[[:space:]]*$/ { body = 1; print }')"
[ -n "$chunk" ] || continue
body="${body:+$body$nl}$chunk"
done <<<"$fragments"
[ -n "$body" ] || continue
[ "$first" = 1 ] || printf '\n'
printf '### %s\n\n%s\n' "$g" "$body"
first=0
done <<<"$ordered"
return 0
}

79
lib/closes_references.sh Normal file
View file

@ -0,0 +1,79 @@
#!/usr/bin/env bash
# lib/closes_references.sh — "which issues does this PR body close?", parsed
# here rather than asked of a forge (issue #188, term 3).
#
# Sourced, never executed: no set -e/-u — the sourcing script owns its shell
# options, as lib/version.sh and lib/forge.sh do.
#
# WHY THIS EXISTS. issueflow-reconcile asked GitHub's GraphQL API for
# `closingIssuesReferences` — GitHub's own parse of the closing keywords in
# a PR body. **Forgejo has no GraphQL API at all**, and the runner confirms
# it from the other side: a real forgejo-runner job arrives with
# GITHUB_GRAPHQL_URL set to the empty string (probe task 278, 2026-08-02).
# So that call site could not be translated to a Forgejo endpoint — there is
# nothing to translate it to. It had to be replaced by a parse this repo
# owns, over a field both forges already return:
# `GET /repos/{owner}/{repo}/pulls` carries `number` and `body` on
# /api/v3 and /api/v1 alike (measured on both).
#
# That the replacement is honest is the point. The sibling half of the same
# GraphQL query, MERGED_REF_PR_RECORDS, was ALREADY a body parse — it pulled
# `number` and `body` and ran them through refs_references. GraphQL was
# buying pagination convenience there, nothing semantic. This file makes the
# other half symmetric: one parser this repo controls and can test, for both
# link kinds, on both forges.
#
# THE ACCEPTED DELTA, stated so it is not rediscovered as a bug: GitHub also
# records closing links attached through the pull request's development
# sidebar, which live in no body and which no body parse can see. This
# family declares its links in the body — that is what BUILDER.md's PR
# template asks for — so the delta is zero in practice here. A consumer that
# links through the sidebar would see those issues go unclosed by the sweep;
# they would need to say so in the body instead.
#
# DEPENDENCY: issue_references, from issueflow-reconcile.sh — the LOCAL /
# CROSS classifier that keeps rig#112 from ever being read as local #112
# (#61). Bash resolves function calls at call time, so the order of sourcing
# does not matter; both must simply be defined before closes_references runs.
# refs_references depends on it exactly the same way.
# closes_references — PR body on stdin -> local issue numbers this body
# declares it CLOSES, sorted, unique.
#
# The keyword set is GitHub's documented one, all three verbs in all three
# tenses. Matching is case-insensitive because bodies are written by humans
# and agents both ("Closes", "closes", "CLOSES").
#
# Deliberately NOT matched: "Refs #N". That is the other relation entirely —
# refs_references owns it, and conflating them would make every referenced
# issue look closeable, which is the post-merge transition #151 had to be
# reopened by hand over.
closes_references() {
awk '
{
line = $0
lower = tolower(line)
# Every occurrence contributes, not just the first: a body that says
# "Closes #1. Closes #2." declares two, and binding to the first
# occurrence dropped the later ones — the same defect #184 fixed in
# blocked_reference_records, kept fixed here by construction.
while (match(lower, /(^|[^[:alnum:]_-])(close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]:]+/)) {
# BOTH cursors advance together. Advancing only `lower` left the
# next match offset indexing the ORIGINAL line, so the second
# declaration on a line came back as garbage — caught by the
# "two closes on one line" case, which is why it is a case.
rest = substr(line, RSTART + RLENGTH)
line = rest
lower = tolower(rest)
if (rest ~ /^(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/) {
token = rest
# Stop at the first thing that cannot be part of a reference, so
# "Closes #12, and more prose" yields #12 and not the sentence.
sub(/[^[:alnum:]_.\/#-].*/, "", token)
print token
}
}
}
' | issue_references \
| awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
}

View file

@ -4,7 +4,7 @@
# lib/decide.sh (issue #8) is pure: it consumes four facts and renders the # lib/decide.sh (issue #8) is pure: it consumes four facts and renders the
# 5-state verdict. This script is the impure half that establishes those # 5-state verdict. This script is the impure half that establishes those
# facts. It runs inside the consumer's checkout (the working directory), # facts. It runs inside the consumer's checkout (the working directory),
# talks to git and gh, and prints the facts in $GITHUB_OUTPUT form: # talks to git and the forge shim, and prints the facts in $GITHUB_OUTPUT form:
# #
# ver=… base_ver=… released=(yes|no|empty) labeled=(yes|no|empty) # ver=… base_ver=… released=(yes|no|empty) labeled=(yes|no|empty)
# #
@ -16,7 +16,8 @@
# MERGE_SHA the pushed head (github.sha) # MERGE_SHA the pushed head (github.sha)
# EVENT_BEFORE github.event.before — may be empty or all-zeros # EVENT_BEFORE github.event.before — may be empty or all-zeros
# GITHUB_REPOSITORY for the two API facts # GITHUB_REPOSITORY for the two API facts
# GH_TOKEN for gh (unused when no API state is consulted) # GH_TOKEN for the forge client (unused when no API state is
# consulted)
# #
# The API calls run only in the states that consult them (decide tolerates # The API calls run only in the states that consult them (decide tolerates
# empty facts — issue #8): RELEASED only for a bare unchanged version, # empty facts — issue #8): RELEASED only for a bare unchanged version,
@ -24,8 +25,11 @@
# decides on the two versions alone and never touches the API. # decides on the two versions alone and never touches the API.
set -euo pipefail set -euo pipefail
_facts_lib="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/version.sh # shellcheck source=lib/version.sh
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/version.sh" . "$_facts_lib/version.sh"
# shellcheck source=lib/forge.sh
. "$_facts_lib/forge.sh"
: "${VERSION_SOURCE:?facts: VERSION_SOURCE is required}" : "${VERSION_SOURCE:?facts: VERSION_SOURCE is required}"
: "${MERGE_SHA:?facts: MERGE_SHA is required}" : "${MERGE_SHA:?facts: MERGE_SHA is required}"
@ -44,56 +48,95 @@ ver="$(version_read "$VERSION_SOURCE")"
# event.before is all-zeros on a branch-create push, and absent outside push # event.before is all-zeros on a branch-create push, and absent outside push
# events; the pushed head's first parent is main the instant before, either # events; the pushed head's first parent is main the instant before, either
# way (#1 constraint 10; cast's `*[!0]*` test — "contains a non-zero char"). # way (#1 constraint 10; cast's `*[!0]*` test — "contains a non-zero char").
# One branch-create has no instant before: the repository's FIRST push to
# main, whose head is a root commit — both 0.2.0 drills died here at exit
# 128, the first release-flow event either scratch consumer ever saw (#134).
# The parent count is read as a fact (`rev-list --parents` prints the head
# alone for a root commit) rather than inferred from a failed rev-parse, so
# an unresolvable MERGE_SHA still dies loudly instead of masquerading as
# "(none)".
base_sha="${EVENT_BEFORE:-}" base_sha="${EVENT_BEFORE:-}"
case "$base_sha" in case "$base_sha" in
*[!0]*) ;; *[!0]*) ;;
*) base_sha="$(git rev-parse "$MERGE_SHA^1")" ;; *)
parents="$(git rev-list --parents -n 1 "$MERGE_SHA")"
case "$parents" in
*" "*) base_sha="$(git rev-parse "$MERGE_SHA^1")" ;;
*) base_sha="" ;; # a root commit: no base tree exists at all
esac
;;
esac esac
# Belt-and-braces (cast's precedent): the workflow's fetch-depth: 2 resolves # Belt-and-braces (cast's precedent): the workflow's fetch-depth: 2 resolves
# the first parent, but event.before can predate it when pushes raced. If # the first parent, but event.before can predate it when pushes raced. If
# the fetch still cannot produce it, the git show below is the loud failure. # the fetch still cannot produce it, the git show below is the loud failure.
git cat-file -e "$base_sha" 2>/dev/null \ # Skipped entirely when there is no base: with an empty rev the fetch is
|| git fetch --depth=1 origin "$base_sha" >&2 \ # meaningless and `git show ":$src"` would read the INDEX — reporting the
|| true # head's own version as the base, a wrong fact worse than any crash (#134).
if [ -n "$base_sha" ]; then
git cat-file -e "$base_sha" 2>/dev/null \
|| git fetch --depth=1 origin "$base_sha" >&2 \
|| true
fi
base_dir="$(mktemp -d)" base_dir="$(mktemp -d)"
trap 'rm -rf "$base_dir"' EXIT trap 'rm -rf "$base_dir"' EXIT
if git show "$base_sha:$src" >"$base_dir/$src" 2>/dev/null; then if [ -n "$base_sha" ] && git show "$base_sha:$src" >"$base_dir/$src" 2>/dev/null; then
base_ver="$(version_read "$VERSION_SOURCE" "$base_dir")" base_ver="$(version_read "$VERSION_SOURCE" "$base_dir")"
else else
# The base tree has no version source at all: the merge that ADDS the # No base tree (a root commit — the repository's first push, the 0.2.0
# version machinery (a consumer's adoption PR, a greenfield repo's first # drills' wall, #134), or a base tree with no version source in it: the
# caller). "(none)" is not a version, so decide sees a changed version # merge that ADDS the version machinery (a consumer's adoption PR, a
# and the table still governs: a -dev head is work (row 2, the guided # greenfield repo's first caller). "(none)" is not a version, so decide
# bootstrap path), a bare head still demands the merged release label # sees a changed version and the table still governs: a -dev head is work
# (rows 56). Nothing releases silently either way. # (row 2, the guided bootstrap path), a bare head still demands the
# merged release label (rows 56). Nothing releases silently either way.
base_ver="(none)" base_ver="(none)"
fi fi
released="" released=""
labeled="" labeled=""
if ! version_is_dev "$ver"; then if ! version_is_dev "$ver"; then
# The forge is selected only in the states that consult the API — a -dev
# tree, every ordinary merge, still decides on the two versions alone and
# touches no forge at all (#8's tolerance for empty facts).
# The forgejo backend addresses the repo through REPO; the github backend
# reads GITHUB_REPOSITORY directly. Set it here from the one this script
# already documents, so the two backends address the same repository —
# missing it made every forgejo read refuse with "REPO: unbound variable"
# (caught by release-exercise on !193).
REPO="${REPO:-${GITHUB_REPOSITORY:?facts: GITHUB_REPOSITORY is required for the API facts}}"
export REPO
# "" means decide from the environment; forge_select takes an explicit
# forge only in tests.
forge_select "" || exit 1
if [ "$base_ver" = "$ver" ]; then if [ "$base_ver" = "$ver" ]; then
# Any gh failure reads as "not released" — the sources' semantics; the # Row 4's input. Before #191 any failure here read as "not released",
# verdict this feeds (row 4) is a refusal, and the ceremony path # which is safe only because row 4 refuses either way. It is still a
# re-checks existence in the nothing-exists assert before creating # lie about what was observed, so an unreadable answer refuses.
# anything. if ! released="$(forge_release_exists "$ver")"; then
if gh release view "$ver" -R "$GITHUB_REPOSITORY" --json name >/dev/null 2>&1; then echo "facts: could not read whether '$ver' is already released — refusing rather than reporting 'no' (#191)" >&2
released=yes exit 1
else
released=no
fi fi
else else
# The sources' exact jq: merged PRs only, `release` among the label # Row 5's input, and the one that cost a release: a push event carries
# names. Read via the API because a push event carries no PR payload — # no PR payload, so the label is read from the API. The old code turned
# and the PR itself lives on a fork (the trigger comment in the # ANY failure into labeled=no, and on a Forgejo runner — no `gh` — that
# workflow). A failed API call reads as "no label", which row 5 # demoted a correctly labeled, correctly merged ceremony PR into "a bare
# refuses: fail-closed. # push", refusing the release and creating nothing. Measured in the
if gh api "repos/$GITHUB_REPOSITORY/commits/$MERGE_SHA/pulls" \ # 0.4.1 drill, twice (drills/0.4.1.md).
-q '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' \ #
| grep -qx true; then # Now: a completed read that finds no merged release-labeled PR is still
# `no` and still fail-closed. A read that did not complete refuses.
if ! pulls="$(forge_commit_pulls "$MERGE_SHA")"; then
echo "facts: could not read the pull requests behind '$MERGE_SHA' — refusing rather than reporting 'no label' (#191)" >&2
exit 1
fi
# One jq expression for both forges: the backends agree on the shape.
if printf '%s' "$pulls" \
| jq -e '[.[] | select(.merged_at != null) | .labels[].name] | index("release") != null' >/dev/null 2>&1; then
labeled=yes labeled=yes
else else
labeled=no labeled=no

891
lib/forge-forgejo.sh Normal file
View file

@ -0,0 +1,891 @@
#!/usr/bin/env bash
# lib/forge-forgejo.sh — the Forgejo backend: /api/v1 over curl + jq
# (issue #188, term 1). Sourced by lib/forge.sh when forge_detect says
# forgejo; never sourced directly, and never at the same time as the github
# backend — they define the same verbs on purpose.
#
# curl+jq rather than a CLI because that is what the runner has. The image
# this instance runs jobs in (ghcr.io/catthehacker/ubuntu:act-22.04, probe
# task 278) carries curl, jq and node, and has neither `gh` nor `stoke`.
# forgejo_api_base — the /api/v1 root, from the runner's own environment.
# GITHUB_API_URL already IS the /api/v1 root on a Forgejo runner (measured:
# https://forgejo.heavyduty.builders/api/v1). CEREMONY_FORGE_API overrides
# it for tests and for anyone driving this outside Actions.
forgejo_api_base() {
local base="${CEREMONY_FORGE_API:-${GITHUB_API_URL:-}}"
if [ -z "$base" ]; then
echo "forgejo_api_base: no GITHUB_API_URL or CEREMONY_FORGE_API — cannot reach the forge (#188)" >&2
return 1
fi
# Every verb in this backend interpolates $REPO into its path, and every
# one of them reaches the network through here — so this is the one place
# that can make `repos//…` impossible.
#
# THE TRAP, measured on this instance with REPO unset (#191, caught by
# @kimi on !193 before it shipped):
#
# forge_release_exists 0.4.1 -> "no", rc 0 (repos//releases/tags/0.4.1
# 404s; a repo-less path read
# as "the published release
# does not exist" — and the
# nothing-exists assert would
# then proceed to CREATE)
# forge_commit_pulls <sha> -> "[]", rc 0 (a commit that HAS a merged
# PR behind it, read as none)
#
# A workflow `run:` shell carries no `set -u`, so an unset REPO expands
# empty and 404s into a fabricated fact instead of crashing. That is the
# exact failure #191 exists to remove, so it refuses here rather than
# anywhere later.
if [ -z "${REPO:-}" ]; then
echo "forgejo_api_base: REPO is empty — refusing to address 'repos//…', whose 404 would read as a fact (#191)" >&2
return 1
fi
printf '%s\n' "${base%/}"
}
# forgejo_page_url <endpoint> <page> — pure, so the page-size contract is
# testable without a network. Returns the endpoint with this backend's OWN
# paging parameters applied.
#
# THE TRAP THIS EXISTS TO REMOVE, measured 2026-08-02 against
# heavy-duty/rig (137 issues and PRs) and heavy-duty/ceremony on GitHub:
#
# ?per_page=100 GitHub: 100 items Forgejo: 30 items (IGNORED)
# ?limit=100 GitHub: 30 items Forgejo: 50 items (capped)
#
# Each forge silently ignores the other's page-size parameter, answers
# HTTP 200 with valid JSON, and says nothing. Every call site in this repo
# was written GitHub-shaped, so a verbatim port would have swept 30 of
# rig's 137 and printed "reconciled." — acceptance criterion 2 failing
# green, and the same "degraded read that does not report it degraded"
# failure class this whole issue exists to kill.
#
# So NO CALL SITE NAMES A PAGE SIZE. The backend owns it. Fixing the
# boundary once beats fixing nine call sites and trusting the tenth — the
# same argument that chose shape C over B, one level down.
#
# 50 is not a preference: Forgejo caps a page at MAX_RESPONSE_ITEMS (50 on
# this instance) whatever you ask for, so asking for more cannot help and
# pagination is mandatory rather than an optimisation.
forgejo_page_url() {
local endpoint="${1:?forgejo_page_url: endpoint required}" page="${2:?forgejo_page_url: page required}"
# Strip any page-size parameter a caller left behind, in either dialect,
# rather than trusting that none did: this function is the one place that
# decides paging, and a stray per_page= would be exactly the silent
# truncation above.
local clean="$endpoint"
clean="$(printf '%s' "$clean" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')"
case "$clean" in
*\?) printf '%slimit=50&page=%s\n' "$clean" "$page" ;;
*\?*) printf '%s&limit=50&page=%s\n' "$clean" "$page" ;;
*) printf '%s?limit=50&page=%s\n' "$clean" "$page" ;;
esac
}
# forge_api [--paginate | --paginate-exhaustive] <endpoint> [--jq <expr>]
#
# --paginate walks page= until a short page, then PROVES the walk was
# complete by comparing what it collected against the server's declared
# x-total-count. @kimi-reviewer-andresmgsl's hardening (#4699): a MISSING
# header is a loud refusal, not a pass. Header exposure is a server setting
# (access-control-expose-headers), and an instance that withholds it would
# make the completeness check compare null to a number — the guard itself
# degrading silently, which is the failure class re-entering through the
# door built to stop it.
#
# --paginate-exhaustive is the narrow alternative for an endpoint whose
# x-total-count is known not to describe the collection. It proves completion
# by reading through the first short page and never consults that header.
forge_api() {
local paginate=false paginate_exhaustive=false method=GET endpoint="" jqexpr="" have_jq=false
while [ $# -gt 0 ]; do
case "$1" in
--paginate) paginate=true ;;
--paginate-exhaustive) paginate_exhaustive=true ;;
-X | --method)
[ "$#" -ge 2 ] || { echo "forge_api: $1 requires a value" >&2; return 1; }
method="$2"
shift
;;
-X?*) method="${1#-X}" ;;
--method=*)
method="${1#*=}"
[ -n "$method" ] || { echo "forge_api: --method requires a value" >&2; return 1; }
;;
--jq) jqexpr="$2"; have_jq=true; shift ;;
-*) ;;
*) [ -n "$endpoint" ] || endpoint="$1" ;;
esac
shift
done
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
if [ "$paginate" = true ] && [ "$paginate_exhaustive" = true ]; then
echo "forge_api: --paginate and --paginate-exhaustive are mutually exclusive" >&2
return 1
fi
if { [ "$paginate" = true ] || [ "$paginate_exhaustive" = true ]; } && [ "$method" != GET ]; then
echo "forge_api: pagination is available only for GET requests" >&2
return 1
fi
local base token
base="$(forgejo_api_base)" || return 1
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
local hdr body
hdr="$(mktemp)"; body="$(mktemp)"
# shellcheck disable=SC2064 # the paths are fixed at trap time on purpose
trap "rm -f '$hdr' '$body'" RETURN
if [ "$paginate" = false ] && [ "$paginate_exhaustive" = false ]; then
if ! curl -sS -D "$hdr" -o "$body" \
-H "Authorization: token $token" -H 'Accept: application/json' \
"$base/$endpoint"; then
echo "forge_api: request failed: $endpoint" >&2
return 1
fi
forgejo_http_ok "$hdr" "GET $endpoint" || return 1
if [ "$have_jq" = true ]; then jq -r "$jqexpr" <"$body"; else cat "$body"; fi
return 0
fi
# Paginated: accumulate into ONE array and apply --jq once at the end.
# gh --paginate applies --jq per page and concatenates; for the `.[] | …`
# shapes every call site here uses, the two are identical, and merging
# first is what makes the completeness assert possible at all.
local page=1 total="" got=0 n all="[]" pagejson
while :; do
if ! curl -sS -D "$hdr" -o "$body" \
-H "Authorization: token $token" -H 'Accept: application/json' \
"$base/$(forgejo_page_url "$endpoint" "$page")"; then
echo "forge_api: request failed: $endpoint (page $page)" >&2
return 1
fi
forgejo_http_ok "$hdr" "GET $endpoint" || return 1
if [ "$paginate_exhaustive" = false ]; then
# Re-read on EVERY page, not once (#4712). A board that changes size
# under the walk was invisible: page 1 declaring 4 and page 2 declaring
# 9 stopped at 4 believing itself whole. A moving total means the read
# cannot have been atomic, so it is refused rather than reconciled.
local page_total
page_total="$(forgejo_total_count "$hdr")" || return 1
if [ -z "$total" ]; then
total="$page_total"
elif [ "$page_total" != "$total" ]; then
cat >&2 <<EOF
forge_api: the declared total for '$endpoint' changed between pages — $total then $page_total (#188).
The collection moved under the walk, so no page set can be proven whole.
Refusing rather than reconciling a board that is already out of date.
EOF
return 1
fi
fi
pagejson="$(cat "$body")"
# A 200 whose body is not a collection counted as zero items (#4712),
# so an error object or a scalar arriving where a list belongs read as
# a complete EMPTY collection whenever the declared total was 0.
if [ "$(jq -r 'type' <<<"$pagejson" 2>/dev/null)" != array ]; then
cat >&2 <<EOF
forge_api: '$endpoint' did not return a collection (#188).
Expected a JSON array; got: $(head -c 200 <<<"$pagejson")
Refusing: a body this shim cannot count must not be counted as empty.
EOF
return 1
fi
n="$(jq 'length' <<<"$pagejson")"
[ "$n" -gt 0 ] || break
all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")"
got=$((got + n))
if [ "$paginate_exhaustive" = true ]; then
[ "$n" -eq 50 ] || break
else
[ "$got" -lt "$total" ] || break
fi
page=$((page + 1))
done
# The assert. A short read here is the silent-truncation bug arriving by
# another route, so it is fatal rather than a warning.
if [ "$paginate_exhaustive" = false ] && [ "$got" -ne "$total" ]; then
cat >&2 <<EOF
forge_api: incomplete gather for '$endpoint' — collected $got of $total declared (#188).
Refusing rather than reconciling a partial board: a sweep over part of the
queue that reports success is the failure this shim exists to prevent.
EOF
return 1
fi
if [ "$have_jq" = true ]; then jq -r "$jqexpr" <<<"$all"; else printf '%s\n' "$all"; fi
}
# forgejo_total_count <header-file> — the declared size of the collection.
# Absent is fatal (#4699): without it the completeness assert cannot run,
# and an assert that cannot run must not silently pass.
forgejo_total_count() {
local hdr="$1" total
total="$(tr -d '\r' <"$hdr" | awk 'tolower($1) == "x-total-count:" { print $2 }' | tail -n1)"
if [ -z "$total" ]; then
cat >&2 <<EOF
forge_api: this forge did not send x-total-count — cannot prove the gather is complete (#188).
The header is exposed by a server setting (access-control-expose-headers).
Refusing: an unprovable read must not be reported as a whole one.
EOF
return 1
fi
# Validate before it reaches arithmetic (#4712). `X-Total-Count:
# not-a-number` used to sail through and become the bound the walk was
# compared against — a guard whose own input was never checked.
case "$total" in
'' | *[!0-9]*)
cat >&2 <<EOF
forge_api: x-total-count is not a non-negative integer: '$total' (#188).
Refusing: the completeness bound must be a number, or the assert that
uses it proves nothing.
EOF
return 1
;;
esac
printf '%s\n' "$total"
}
# forgejo_http_ok <header-file> <verb-and-endpoint> — a non-2xx is named, not
# swallowed. gh exits non-zero on HTTP failure; curl does not without -f,
# and -f would throw away the body that says why.
# The second argument carries the VERB as well as the path — "GET repos/…",
# "PUT repos/…". #192's acceptance criterion is that a failure names the verb,
# the path and the status, and reads used to omit the verb: a caller reading
# `HTTP 500 from 'repos/o/r/issues/5'` could not tell a failed read from a
# failed write of the same path (@codex-reviewer-andresmgsl).
forgejo_http_ok() {
local hdr="$1" endpoint="$2" code
code="$(tr -d '\r' <"$hdr" | awk '/^HTTP\// { c = $2 } END { print c }')"
case "$code" in
2*) return 0 ;;
*)
echo "forge_api: HTTP $code from '$endpoint'" >&2
return 1
;;
esac
}
# --- the verbs the reconcilers use, over /api/v1 --------------------------
# Three asymmetries with gh, all measured against this instance on
# 2026-08-02 using a scratch repo (never a live board):
#
# 1. Adding labels takes NAMES POST /issues/{n}/labels {"labels":["x"]} -> 200
# Removing one takes a numeric ID DELETE /issues/{n}/labels/x -> 422
# DELETE /issues/{n}/labels/149 -> 204
# So a removal must resolve name -> id first. gh hides this; the shim
# cannot.
#
# 2. Assignees are SET, not added and removed. PATCH /issues/{n} takes the
# whole list ({"assignees":[]} clears it, 201), so --remove-assignee is
# a read-modify-write rather than a delete.
#
# 3. There is no statusCheckRollup. The portable equivalent is the
# combined commit status, GET /commits/{sha}/status, which returns
# {state, statuses[]}.
# forgejo_label_ids — name<TAB>id for every label in the repo, read once per
# call site that needs it. Paginated through forge_api, so a repo with more
# than one page of labels cannot silently lose the tail (#188).
forgejo_label_ids() {
forge_api --paginate "repos/$REPO/labels" --jq '.[] | "\(.name)\t\(.id)"'
}
# forge_issue_edit <n> [--add-label X]… [--remove-label X]… [--add-assignee U]… [--remove-assignee U]…
# gh's flag surface, translated. Accepts comma-separated values, as gh does.
forge_issue_edit() {
local n="${1:?forge_issue_edit: number required}"
shift
local add_labels=() rm_labels=() add_assignees=() rm_assignees=() v
# Unknown flags REFUSE (#4743). The github backend forwards whatever it is
# given to `gh`, which fails on a flag it does not know; dropping it here
# instead would turn a port typo into a green no-op — a mutation that
# silently did not happen, which is precisely this issue's failure class
# arriving inside the fix for it.
while [ $# -gt 0 ]; do
case "$1" in
--add-label | --remove-label | --add-assignee | --remove-assignee)
if [ "$#" -lt 2 ]; then
echo "forge_issue_edit: $1 requires a value (#188)" >&2
return 1
fi
IFS=, read -ra v <<<"$2"
case "$1" in
--add-label) add_labels+=("${v[@]}") ;;
--remove-label) rm_labels+=("${v[@]}") ;;
--add-assignee) add_assignees+=("${v[@]}") ;;
--remove-assignee) rm_assignees+=("${v[@]}") ;;
esac
shift
;;
*)
echo "forge_issue_edit: unknown flag '$1' — refusing rather than silently skipping the edit (#188)" >&2
return 1
;;
esac
shift
done
# THE LABEL DELTA (#192). Removal used to be a per-label
# `DELETE .../labels/{id}` loop. On this instance that call returns HTTP 500
# for EVERY removal under the token the sweep actually holds — measured
# under a real Actions token inside a workflow, probe run 701:
#
# POST /issues/{n}/labels ["probe-a","probe-b"] -> 200
# DELETE /issues/{n}/labels/{id} -> 500 labels unchanged
# PUT /issues/{n}/labels {"labels":[<id>]} -> 200
# PUT /issues/{n}/labels {"labels":[]} -> 200 (full clear)
#
# A PAT gets 204 on the same DELETE, which is why this survived a week
# unseen: it fails only for `${{ github.token }}`, and only inside Actions.
# Net effect before this fix: on Forgejo the state machine could only ever
# ADD labels — every `state:*` transition needing the previous state cleared,
# and every `blocker:*` that should lift, was inert.
#
# So a removal is expressed as a full-set PUT, exactly as the assignee branch
# below expresses its own delta as one PATCH — read current, compute wanted,
# write once.
#
# AN ADD-ONLY CALL KEEPS ITS ADDITIVE POST, deliberately. ceremony#128 lost
# its `release` label — the merge door's declared-intent read — to a
# read-modify-write that clobbered a label set two seconds after a builder
# wrote it, and `forge_labels_add` is pinned against ever doing that
# (test/forge-backends.test.sh). The read-modify-write window is real and is
# accepted HERE and only here, where the caller has asked to REMOVE something
# and no additive verb can express that.
if [ "${#rm_labels[@]}" -gt 0 ]; then
local current want_pairs ids id name payload
local want_ids=() missing=()
# name<TAB>id straight from the ISSUE payload. Preserved labels carry
# their authoritative id here already, so they need no second lookup —
# re-resolving them through the repository-wide list would make
# preservation depend on a paginated read that has nothing to do with
# this issue, and an incomplete one would drop a bystander
# (@codex-reviewer-andresmgsl). Only ADDED names need forgejo_label_ids.
current="$(forge_api "repos/$REPO/issues/$n" \
--jq '[.labels[]? | "\(.name)\t\(.id)"] | join("\n")')" || return 1
# The rows are name<TAB>id, so removals filter on the NAME field — a
# whole-line match would never fire against a pair.
want_pairs="$(
awk -F '\t' 'NR==FNR { drop[$0]=1; next } !($1 in drop)' \
<(printf '%s\n' "${rm_labels[@]}") \
<(printf '%s\n' "$current" | grep -v '^$')
)"
while IFS=$'\t' read -r name id; do
[ -n "$name" ] || continue
want_ids+=("$id")
done <<<"$want_pairs"
if [ "${#add_labels[@]}" -gt 0 ]; then
ids="$(forgejo_label_ids)" || return 1
for name in "${add_labels[@]}"; do
# already on the issue? its id is in want_ids already
awk -F '\t' -v want="$name" '$1 == want { found=1 } END { exit !found }' \
<<<"$want_pairs" && continue
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
if [ -z "$id" ]; then missing+=("$name"); continue; fi
want_ids+=("$id")
done
fi
# An add-label the repo does not carry refuses BEFORE the write. A PUT
# that silently dropped an unresolvable name would remove a label nobody
# asked to remove — a destructive write dressed as a partial success.
if [ "${#missing[@]}" -gt 0 ]; then
echo "forge_issue_edit: #$n: no label id on $REPO for: ${missing[*]} — refusing to PUT a set that would drop it" >&2
return 1
fi
# NOTHING TO CHANGE, NOTHING TO WRITE. The reconcilers call
# --remove-label unconditionally to converge state, so most calls here ask
# to remove a label the issue does not carry. Writing the unchanged set
# back would open the read-modify-write window of ceremony#128 for no
# state change at all; the GET above is already the proof the sweep
# reached the forge (@codex-reviewer-andresmgsl). gh's own behaviour on an
# absent --remove-label is likewise a no-op.
local current_ids
current_ids="$(printf '%s\n' "$current" | grep -v '^$' | cut -f2 | sort -n | tr '\n' ' ')"
if [ "$(printf '%s\n' ${want_ids[@]+"${want_ids[@]}"} | grep -v '^$' | sort -n | tr '\n' ' ')" = "$current_ids" ]; then
return 0
fi
payload="$(printf '%s\n' ${want_ids[@]+"${want_ids[@]}"} \
| jq -R 'select(. != "") | tonumber' | jq -sc '{labels: .}')"
forgejo_write PUT "repos/$REPO/issues/$n/labels" "$payload" >/dev/null || return 1
elif [ "${#add_labels[@]}" -gt 0 ]; then
local payload
payload="$(printf '%s\n' "${add_labels[@]}" | jq -R . | jq -sc '{labels: .}')"
forgejo_write POST "repos/$REPO/issues/$n/labels" "$payload" >/dev/null || return 1
fi
if [ "${#add_assignees[@]}" -gt 0 ] || [ "${#rm_assignees[@]}" -gt 0 ]; then
local current want payload
current="$(forge_api "repos/$REPO/issues/$n" --jq '[.assignees[]?.login] | join("\n")')" || return 1
want="$(
{
printf '%s\n' "$current"
[ "${#add_assignees[@]}" -gt 0 ] && printf '%s\n' "${add_assignees[@]}"
} | grep -v '^$' | sort -u
)"
if [ "${#rm_assignees[@]}" -gt 0 ]; then
want="$(grep -vxF -f <(printf '%s\n' "${rm_assignees[@]}") <<<"$want" || true)"
fi
payload="$(printf '%s' "$want" | jq -R . | jq -sc '{assignees: [.[] | select(. != "")]}')"
forgejo_write PATCH "repos/$REPO/issues/$n" "$payload" >/dev/null || return 1
fi
}
forge_issue_comment() {
local n="${1:?forge_issue_comment: number required}" body="${2?forge_issue_comment: body required}"
forgejo_write POST "repos/$REPO/issues/$n/comments" "$(jq -nc --arg b "$body" '{body: $b}')" >/dev/null
}
forge_pr_list() {
forge_api --paginate "repos/$REPO/pulls?state=open" --jq '.[].number'
}
# forge_pr_view <n> — the {mergeable, statusCheckRollup} shape the state
# machine reads, assembled from the two places Forgejo keeps it. The rollup
# is mapped into the node shape checks_state already parses, so the decision
# code is untouched.
forge_pr_view() {
local n="${1:?forge_pr_view: number required}" pr sha status
pr="$(forge_api "repos/$REPO/pulls/$n")" || return 1
sha="$(jq -r '.head.sha // ""' <<<"$pr")"
[ -n "$sha" ] || { echo "forge_pr_view: PR $n has no head sha" >&2; return 1; }
status="$(forge_api "repos/$REPO/commits/$sha/status")" || return 1
jq -n --argjson pr "$pr" --argjson st "$status" '
{
# Forgejo folds checking, conflict, check error, and WIP into false.
# Draft must win because WIP makes the boolean carry no merge result (#236).
mergeable: (if $pr.draft == true then "UNKNOWN"
elif $pr.mergeable == true then "MERGEABLE"
elif $pr.merge_base == $pr.base.sha then "UNKNOWN"
else "CONFLICTING" end),
statusCheckRollup: [
$st.statuses[]? | {
__typename: "StatusContext",
context: .context,
# Forgejo carries the workflow name only as the context prefix;
# no separator means no proven workflow, so never guess (#243).
workflowName: ((.context // "")
| if contains(" / ") then split(" / ")[0] else "" end),
state: (.status | ascii_upcase),
# checks_state groups repeated contexts and takes the NEWEST by
# [.startedAt, .createdAt, .completedAt]. Without a timestamp the
# winner would be decided by incidental array order, so a stale
# re-run could outrank the live verdict (#4743). The combined
# status carries both fields; measured on this instance.
createdAt: .created_at,
completedAt: .updated_at
}
]
}'
}
# forge_pr_review_requests <n> — logins with a live review request.
# Forgejo review.go deletes REQUEST_REVIEW rows when the reviewer submits any
# review, so these rows are the exact live set rather than review history (#238).
forge_pr_review_requests() {
local n="${1:?forge_pr_review_requests: number required}"
forge_api --paginate "repos/$REPO/pulls/$n/reviews" \
--jq '.[] | select(.state == "REQUEST_REVIEW") | .user.login' | sort -u
}
forge_label_list() { forge_api --paginate "repos/$REPO/labels" --jq '.[].name'; }
# forge_label_create — an UPSERT, matching `gh label create --force` (#4743).
# bootstrap_labels creates every declared label on every workflow_dispatch, so
# the second dispatch must update rather than conflict; a plain POST onto an
# existing name aborts the bootstrap under set -e.
forge_label_create() {
local name="${1:?}" color="${2:?}" desc="${3:-}" ids id payload
payload="$(jq -nc --arg n "$name" --arg c "$color" --arg d "$desc" '{name:$n,color:$c,description:$d}')"
ids="$(forgejo_label_ids)" || return 1
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
if [ -n "$id" ]; then
forgejo_write PATCH "repos/$REPO/labels/$id" "$payload" >/dev/null
else
forgejo_write POST "repos/$REPO/labels" "$payload" >/dev/null
fi
}
forge_label_delete() {
local name="${1:?}" ids id
ids="$(forgejo_label_ids)" || return 1
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
[ -n "$id" ] || return 0
forgejo_write DELETE "repos/$REPO/labels/$id" '' >/dev/null
}
# forgejo_write <method> <endpoint> <json-body> — every mutation goes through
# here so a non-2xx is named rather than swallowed, the same contract
# forgejo_http_ok gives reads.
forgejo_write() {
local method="$1" endpoint="$2" payload="$3" base token hdr body rc
base="$(forgejo_api_base)" || return 1
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
hdr="$(mktemp)"; body="$(mktemp)"
if [ -n "$payload" ]; then
curl -sS -X "$method" -D "$hdr" -o "$body" \
-H "Authorization: token $token" -H 'Content-Type: application/json' \
-d "$payload" "$base/$endpoint"
else
curl -sS -X "$method" -D "$hdr" -o "$body" \
-H "Authorization: token $token" "$base/$endpoint"
fi
rc=$?
if [ "$rc" -ne 0 ]; then
rm -f "$hdr" "$body"
echo "forge: $method $endpoint failed to send" >&2
return 1
fi
if ! forgejo_http_ok "$hdr" "$method $endpoint"; then
head -c 300 "$body" >&2; echo >&2
rm -f "$hdr" "$body"
return 1
fi
cat "$body"
rm -f "$hdr" "$body"
}
# forge_labels_add <n> <label…> — the additive label write (ceremony#128; see
# the github twin). POST /issues/{n}/labels adds the named labels and removes
# nothing, and it takes NAMES — measured, unlike the removal path, which
# needs ids.
forge_labels_add() {
local n="${1:?forge_labels_add: number required}"
shift
[ "$#" -gt 0 ] || return 0
forgejo_write POST "repos/$REPO/issues/$n/labels" \
"$(printf '%s\n' "$@" | jq -R . | jq -sc '{labels: .}')" >/dev/null
}
# forge_request_reviewer <n> <user> — ask <user> for a verdict.
#
# This endpoint DOES exist here, contrary to an earlier reading of mine
# (#4698) which recorded requested_reviewers as having no sub-resource at
# all. What is true is narrower: Forgejo serves POST and DELETE on it and no
# GET, so a GET probe answers 404 — and a POST naming a user who does not
# exist answers 404 as well, for a different reason. Measured on a scratch
# repo: POST with a real user who lacks read access is 422 ("Reviewer can't
# read"), and 201 once they have it.
#
# The READ stays retired regardless (term 4): the field is stale here even on
# merged PRs, so outstanding verdicts come from /pulls/{n}/reviews at the
# current head SHA. It is the write that has an answer.
forge_request_reviewer() {
local n="${1:?}" user="${2:?}"
forgejo_write POST "repos/$REPO/pulls/$n/requested_reviewers" \
"$(jq -nc --arg u "$user" '{reviewers: [$u]}')" >/dev/null
}
# forge_timeline <n> — JSON array of timeline events projected into the
# GitHub shape the reconcilers already select on. Measured mapping (#4849):
#
# | | GitHub | Forgejo |
# | event kind | .event == "labeled"/"unlabeled"| .type == "label" |
# | add vs remove | the two event names | .body "1" / "" |
# | actor | .actor.login (no .user) | .user.login (no .actor) |
#
# Status is captured BEFORE jq so an unreadable read cannot report as an
# empty timeline — the two states the ruling ladder must tell apart (#4853).
forge_timeline() {
local n="${1:?forge_timeline: number required}" raw
# Measured on this instance: limit=10 reports x-total-count=10 and limit=50
# reports 50, while crew!96 held 151 events and strict pagination returned
# only its first 50. No other measured endpoint echoes its page size this
# way. Timelines are append-only, so exhaustion can include concurrent new
# events but cannot create a deletion hole; that is why only this call site
# may bypass the header-bound completeness proof (#240).
raw="$(forge_api --paginate-exhaustive "repos/$REPO/issues/$n/timeline")" || return 1
jq '
[.[]
| select(.type == "label")
| {
event: (if .body == "1" then "labeled" else "unlabeled" end),
actor: {login: (.user.login // "")},
label: {name: (.label.name // "")},
created_at: .created_at
}
]
' <<<"$raw"
}
# forge_pr_activity <n> — one ISO timestamp per line of real PR activity.
# Forgejo has no flat /pulls/{n}/comments (HTTP 404, measured #4844); inline
# review comments live under /pulls/{n}/reviews/{id}/comments. Only reviews
# with comments_count > 0 are fetched, so a board with none costs zero
# extra requests.
forge_pr_activity() {
local n="${1:?forge_pr_activity: number required}" reviews rid
forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' || return 1
forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' || return 1
reviews="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews")" || return 1
while IFS= read -r rid; do
[ -n "$rid" ] || continue
forge_api --paginate "repos/$REPO/pulls/$n/reviews/$rid/comments" \
--jq '.[].created_at' || return 1
done < <(jq -r '.[] | select((.comments_count // 0) > 0) | .id' <<<"$reviews")
}
# --- the release door's facts (#191) --------------------------------------
# Two reads the merge and tag doors depend on. Both answer a QUESTION, and
# both distinguish "the read completed and the answer is no" from "the read
# did not complete" — the distinction lib/facts.sh got wrong before #191,
# where any failure became a definite `no` and a release ceremony was
# silently demoted to a bare push.
#
# Measured on forgejo.heavyduty.builders (8.0.3+gitea-1.22.0), 2026-08-04:
#
# GET /repos/{o}/{r}/releases/tags/0.4.0 -> 200 (present)
# GET /repos/{o}/{r}/releases/tags/9.9.9 -> 404 (absent — a real answer)
#
# GET /repos/{o}/{r}/commits/{sha}/pull -> 200, a SINGLE PR object
# GET /repos/{o}/{r}/commits/{sha}/pulls -> 404 page not found
# ...on a commit with no PR -> 404 {"message":"pull request
# does not exist …"}
#
# The singular/plural split is the asymmetry: GitHub serves an ARRAY at
# /pulls, Forgejo serves one OBJECT at /pull. Both verbs below emit the
# GitHub shape — a JSON array — so lib/facts.sh carries one jq expression
# for both forges, which is the whole point of the shim.
# forgejo_read_code <endpoint> <body-file> — the raw GET, printing the HTTP
# status on stdout. Separate from forge_api because these two call sites
# must SEE a 404 rather than have it collapsed into a failure.
forgejo_read_code() {
local endpoint="$1" body="$2" base token hdr rc
base="$(forgejo_api_base)" || return 1
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
hdr="$(mktemp)"
curl -sS -D "$hdr" -o "$body" -H "Authorization: token $token" "$base/$endpoint"
rc=$?
if [ "$rc" -ne 0 ]; then
rm -f "$hdr"
echo "forge: GET $endpoint failed to send" >&2
return 1
fi
tr -d '\r' <"$hdr" | awk '/^HTTP\// { c = $2 } END { print c }'
rm -f "$hdr"
}
# forge_release_exists <tag> — prints `yes` for a published release and `no`
# for a draft or a 404. A non-zero exit means the read did not complete and
# the answer is UNKNOWN; the caller must not treat that as `no` (#191, #271).
forge_release_exists() {
local tag="${1:?forge_release_exists: tag required}" body code draft
body="$(mktemp)"
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$body")" || { rm -f "$body"; return 1; }
case "$code" in
2*)
if ! draft="$(jq -r 'if has("draft") then .draft else false end' "$body" 2>/dev/null)" \
|| [[ "$draft" != true && "$draft" != false ]]; then
rm -f "$body"
echo "forge_release_exists: unreadable draft state for release '$tag' — the answer is unknown, not 'no'" >&2
return 1
fi
[ "$draft" = true ] && echo no || echo yes
;;
404) echo no ;;
*)
rm -f "$body"
echo "forge_release_exists: HTTP $code reading release '$tag' — the answer is unknown, not 'no'" >&2
return 1
;;
esac
rm -f "$body"
}
# forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as
# a JSON ARRAY in GitHub's shape. An empty array is a completed read that
# found nothing; a non-zero exit is a read that did not complete.
# forge_commit_at <sha> — the commit's committer date, ISO-8601, or empty.
#
# THE FOURTH ASYMMETRY (#209), measured 2026-08-05:
#
# GET /repos/{o}/{r}/commits/{sha} -> 404 (200 on GitHub)
# GET /repos/{o}/{r}/git/commits/{sha} -> 200 date under `.created`
#
# Found by the first post-merge sweep after the 0.6.0 merge, not by review:
# #198 ported this call site onto the shim with GitHub's path unchanged, and
# the block it lives in had never executed here before. Every sweep printed
# `could not read the head commit's date` and left blocker:unrequested
# unjudged.
#
# `.created` and not `.commit.committer.date`: the /git/commits payload is the
# git object, whose top-level `created` is the committer date. The verb hides
# both differences so the caller keeps asking for one timestamp.
forge_commit_at() {
local sha="${1:?forge_commit_at: sha required}"
forge_api "repos/$REPO/git/commits/$sha" --jq '.created'
}
forge_commit_pulls() {
local sha="${1:?forge_commit_pulls: sha required}" body code out
body="$(mktemp)"
code="$(forgejo_read_code "repos/$REPO/commits/$sha/pull" "$body")" || { rm -f "$body"; return 1; }
case "$code" in
2*)
# One object -> a one-element array, so the call site's jq is the
# same expression it runs against GitHub.
if ! out="$(jq -c '[.]' <"$body" 2>/dev/null)"; then
rm -f "$body"
echo "forge_commit_pulls: unreadable JSON for '$sha'" >&2
return 1
fi
printf '%s\n' "$out"
;;
404) printf '[]\n' ;;
*)
rm -f "$body"
echo "forge_commit_pulls: HTTP $code reading the PR for '$sha' — the answer is unknown, not 'none'" >&2
return 1
;;
esac
rm -f "$body"
}
# --- the release door's writes (#191) -------------------------------------
# Confirmed against this instance's own swagger, 2026-08-04:
#
# POST /repos/{o}/{r}/tags -> exists (tag creation)
# GET /repos/{o}/{r}/git/refs -> GET ONLY (no POST)
# POST /repos/{o}/{r}/releases -> exists
# POST /repos/{o}/{r}/releases/{id}/assets -> exists
#
# The asymmetry worth naming: GitHub creates a tag by POSTing a ref to
# /git/refs; Forgejo does not serve POST there at all and creates tags at
# /tags instead. A 1:1 port of the gh call would 404 forever.
# forge_tag_create <tag> <sha>
forge_tag_create() {
local tag="${1:?forge_tag_create: tag required}" sha="${2:?forge_tag_create: sha required}"
forgejo_write POST "repos/$REPO/tags" \
"$(jq -nc --arg t "$tag" --arg s "$sha" '{tag_name:$t,target:$s}')" >/dev/null
}
# forgejo_urlencode <string> — percent-encode one query VALUE. jq is already
# a hard dependency of this backend, and @uri is its one correct answer; a
# hand-rolled sed class is how the next unescaped character gets through.
forgejo_urlencode() {
jq -rn --arg s "${1-}" '$s|@uri'
}
# forgejo_release_cleanup_draft <id> <tag> — best-effort rollback after a
# post-create failure. The caller has already printed the original failure;
# cleanup can add evidence but must never replace that diagnosis (#271).
forgejo_release_cleanup_draft() {
local id="${1:?forgejo_release_cleanup_draft: id required}"
local tag="${2:?forgejo_release_cleanup_draft: tag required}" cleanup
if ! cleanup="$(forgejo_write DELETE "repos/$REPO/releases/$id" '' 2>&1)"; then
[ -z "$cleanup" ] || printf '%s\n' "$cleanup" >&2
echo "forge_release_create: cleanup failed; stranded draft $id for tag '$tag'" >&2
fi
return 0
}
# forge_release_create <tag> <title> <notes-file> [asset…] — creates a draft,
# uploads every asset, then publishes it. Any failure after create removes the
# draft, so the function leaves either a complete published release or nothing.
forge_release_create() {
local tag="${1:?forge_release_create: tag required}" title="${2:?forge_release_create: title required}"
local notes="${3:?forge_release_create: notes file required}" out id base token
local existing code draft existing_id
shift 3
# A previous rollback whose DELETE failed must not wedge the next attempt.
# Remove only a draft for this exact tag; a published release is never
# touched and its create will retain Forgejo's ordinary conflict refusal.
existing="$(mktemp)"
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$existing")" || { rm -f "$existing"; return 1; }
case "$code" in
2*)
if ! draft="$(jq -r 'if has("draft") then .draft else false end' "$existing" 2>/dev/null)" \
|| [[ "$draft" != true && "$draft" != false ]]; then
rm -f "$existing"
echo "forge_release_create: unreadable draft state for release '$tag' — refusing to replace it" >&2
return 1
fi
if [ "$draft" = true ]; then
existing_id="$(jq -r '.id // empty' "$existing")"
if [ -z "$existing_id" ]; then
rm -f "$existing"
echo "forge_release_create: the stranded draft for tag '$tag' has no release id — refusing to replace it" >&2
return 1
fi
echo "forge_release_create: removing stranded draft $existing_id for tag '$tag' before publish" >&2
if ! forgejo_write DELETE "repos/$REPO/releases/$existing_id" '' >/dev/null; then
rm -f "$existing"
echo "forge_release_create: could not remove stranded draft $existing_id for tag '$tag'" >&2
return 1
fi
fi
;;
404) ;;
*)
rm -f "$existing"
echo "forge_release_create: HTTP $code checking for a stranded draft for tag '$tag' — refusing to publish" >&2
return 1
;;
esac
rm -f "$existing"
out="$(forgejo_write POST "repos/$REPO/releases" \
"$(jq -nc --arg t "$tag" --arg n "$title" --rawfile b "$notes" \
'{tag_name:$t,name:$n,body:$b,draft:true,prerelease:false}')")" || return 1
id="$(printf '%s' "$out" | jq -r '.id // empty')"
[ -n "$id" ] || { echo "forge_release_create: the create returned no release id" >&2; return 1; }
if ! base="$(forgejo_api_base)"; then
forgejo_release_cleanup_draft "$id" "$tag"
return 1
fi
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
local f name
for f in "$@"; do
[ -e "$f" ] || continue
# The asset name is a QUERY VALUE, and the hook contract permits any
# file the consumer drops in RELEASE_ASSETS_DIR. Raw interpolation broke
# on a space (curl exits 3 on the malformed URL) and silently changed
# the name on '&', '#', '+' and '%' — `gh release create` handled those,
# so a 1:1 port had to as well (#191, found by @codex on !193).
name="$(forgejo_urlencode "$(basename "$f")")"
curl -sS -f -X POST -H "Authorization: token $token" \
-F "attachment=@$f" \
"$base/repos/$REPO/releases/$id/assets?name=$name" >/dev/null \
|| {
echo "forge_release_create: asset upload failed for '$f'" >&2
forgejo_release_cleanup_draft "$id" "$tag"
return 1
}
done
if ! forgejo_write PATCH "repos/$REPO/releases/$id" '{"draft":false}' >/dev/null; then
forgejo_release_cleanup_draft "$id" "$tag"
return 1
fi
}
# forge_pr_create <head> <base> <title> <body> <label…> — POST /pulls takes
# label IDs, not names (the same asymmetry the issue-label writes carry), so
# the names are resolved first through forgejo_label_ids.
forge_pr_create() {
local head="${1:?forge_pr_create: head required}" base="${2:?forge_pr_create: base required}"
local title="${3:?forge_pr_create: title required}" body="${4:?forge_pr_create: body required}"
shift 4
local ids='[]' map name id
if [ "$#" -gt 0 ]; then
map="$(forgejo_label_ids)" || return 1
ids='['
for name in "$@"; do
id="$(printf '%s\n' "$map" | awk -F'\t' -v n="$name" '$1 == n { print $2; exit }')"
[ -n "$id" ] || { echo "forge_pr_create: no label '$name' in this repo" >&2; return 1; }
ids="$ids$id,"
done
ids="${ids%,}]"
fi
forgejo_write POST "repos/$REPO/pulls" \
"$(jq -nc --arg h "$head" --arg b "$base" --arg t "$title" --arg d "$body" \
--argjson l "$ids" '{head:$h,base:$b,title:$t,body:$d,labels:$l}')" >/dev/null
}

253
lib/forge-github.sh Normal file
View file

@ -0,0 +1,253 @@
#!/usr/bin/env bash
# lib/forge-github.sh — the GitHub backend (issue #188, term 1). Sourced by
# lib/forge.sh when forge_detect says github; never at the same time as the
# forgejo backend — they define the same verbs on purpose.
#
# This file is the CURRENT call set, extracted 1:1 and nothing more. Term 5
# of the frozen Spec is "GitHub consumers are unchanged", and the cheapest
# way to keep that true is for every verb here to be a thin pass-through to
# the `gh` invocation the call site used before the port. No behaviour is
# added, fixed or tidied on this path; anything that looks like an
# improvement here is a regression risk against a forge nobody is currently
# reporting bugs on.
# forge_api [--paginate] <endpoint> [--jq <expr>]
#
# The one deliberate difference from a pure pass-through: the caller no
# longer names a page size, because the page-size parameter is not portable
# and is therefore the backend's to own (#188).
#
# ?per_page=100 GitHub: 100 items Forgejo: 30 items (IGNORED)
# ?limit=100 GitHub: 30 items Forgejo: 50 items (capped)
#
# Both answer HTTP 200 either way, so a call site that names one is a silent
# truncation waiting for the other forge. per_page=100 is injected here —
# exactly what the call sites said before — so the GitHub path is unchanged
# in behaviour while the parameter stops being a call-site concern.
forge_api() {
local paginate=false endpoint="" jqexpr="" have_jq=false
while [ $# -gt 0 ]; do
case "$1" in
--paginate) paginate=true ;;
--jq) jqexpr="$2"; have_jq=true; shift ;;
-*) ;;
*) [ -n "$endpoint" ] || endpoint="$1" ;;
esac
shift
done
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; }
if [ "$paginate" = true ]; then
endpoint="$(github_page_url "$endpoint")"
if [ "$have_jq" = true ]; then
gh api --paginate "$endpoint" --jq "$jqexpr"
else
gh api --paginate "$endpoint"
fi
else
if [ "$have_jq" = true ]; then
gh api "$endpoint" --jq "$jqexpr"
else
gh api "$endpoint"
fi
fi
}
# github_page_url <endpoint> — pure, so the page-size contract is testable
# without a network. Strips any page-size parameter a caller left behind in
# either dialect, then applies GitHub's own.
github_page_url() {
local endpoint="${1:?github_page_url: endpoint required}" clean
clean="$(printf '%s' "$endpoint" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g')"
case "$clean" in
*\?) printf '%sper_page=100\n' "$clean" ;;
*\?*) printf '%s&per_page=100\n' "$clean" ;;
*) printf '%s?per_page=100\n' "$clean" ;;
esac
}
# --- the verbs the reconcilers use, extracted 1:1 -------------------------
# Every one of these is the exact `gh` invocation the call site carried
# before the port. Term 5 is kept by making this file boring.
# forge_issue_edit <n> <gh-style flags…> — labels and assignees on an issue
# or a PR (gh treats them interchangeably, and so do the call sites).
forge_issue_edit() {
local n="${1:?forge_issue_edit: number required}"
shift
gh issue edit "$n" -R "$REPO" "$@"
}
# forge_issue_comment <n> <body>
forge_issue_comment() {
local n="${1:?forge_issue_comment: number required}" body="${2?forge_issue_comment: body required}"
gh issue comment "$n" -R "$REPO" --body "$body"
}
# forge_pr_list — open PR numbers, one per line. Note this used
# `gh pr list --limit 100`: a page size in gh's OWN flag namespace, which no
# URL-parameter strip could have caught, so it moves behind the shim with
# the rest (#188).
forge_pr_list() {
gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'
}
# forge_pr_view <n> — {mergeable, statusCheckRollup} as JSON, or non-zero
# with the reason on stderr. `gh pr view` rather than the REST PR object:
# the API's `mergeable` is a tri-state boolean GitHub computes lazily, while
# this returns the MERGEABLE/CONFLICTING/UNKNOWN string the UI shows.
forge_pr_view() {
local n="${1:?forge_pr_view: number required}"
gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup
}
# forge_pr_review_requests <n> — logins with a live review request.
forge_pr_review_requests() {
local n="${1:?forge_pr_review_requests: number required}"
forge_api "repos/$REPO/pulls/$n" --jq '.requested_reviewers[].login' | sort -u
}
# forge_label_list — every label name in the repo.
forge_label_list() {
gh label list -R "$REPO" --limit 200 --json name --jq '.[].name'
}
forge_label_create() {
local name="${1:?}" color="${2:?}" desc="${3:-}"
gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
}
forge_label_delete() {
local name="${1:?}"
gh label delete "$name" -R "$REPO" --yes
}
# forge_labels_add <n> <label…> — an ADDITIVE label write, and deliberately
# not forge_issue_edit --add-label. The distinction is ceremony#128: the
# labeler action computed (labels-at-job-start derived) and PUT the whole
# set, so a label applied while the job ran was silently removed. This is the
# raw POST, which adds the named labels, ignores ones already present, and
# removes nothing — a concurrent label survives by construction.
forge_labels_add() {
local n="${1:?forge_labels_add: number required}" args=() label
shift
for label in "$@"; do args+=(-f "labels[]=$label"); done
gh api "repos/$REPO/issues/$n/labels" "${args[@]}" --silent
}
# forge_request_reviewer <n> <user> — ask <user> for a verdict.
forge_request_reviewer() {
local n="${1:?}" user="${2:?}"
gh api "repos/$REPO/pulls/$n/requested_reviewers" -f "reviewers[]=$user" --silent
}
# forge_timeline <n> — JSON array of timeline events in the GitHub shape
# (.event, .actor.login, .label.name, .created_at). The GitHub path is a
# pass-through: that shape is what the forge already returns (#188 batch).
# Callers must capture the status of THIS function before piping into jq —
# a pipeline's status is the last command's, so `forge_timeline | jq`
# collapses an unreadable timeline into an empty one (#4853).
forge_timeline() {
local n="${1:?forge_timeline: number required}"
forge_api --paginate "repos/$REPO/issues/$n/timeline"
}
# forge_pr_activity <n> — one ISO timestamp per line of real PR activity
# (issue comments, inline review comments, commits). GitHub serves the
# flat /pulls/{n}/comments collection; the forgejo twin re-derives it from
# reviews with comments_count > 0 because that endpoint 404s there (#4844).
forge_pr_activity() {
local n="${1:?forge_pr_activity: number required}"
forge_api --paginate "repos/$REPO/issues/$n/comments" --jq '.[].created_at' || return 1
forge_api --paginate "repos/$REPO/pulls/$n/comments" --jq '.[].created_at' || return 1
forge_api --paginate "repos/$REPO/pulls/$n/commits" --jq '.[].commit.committer.date' || return 1
}
# --- the release door's facts (#191) --------------------------------------
# The github twins of the forgejo backend's two release-door reads. Term 5
# discipline applies: these are the `gh` calls lib/facts.sh carried before
# the port, with one behaviour added — a read that did not complete is
# reported as such instead of collapsing into a definite `no`.
# forge_release_exists <tag> — prints `yes` or `no`; non-zero exit means the
# read did not complete and the answer is UNKNOWN (#191).
forge_release_exists() {
local tag="${1:?forge_release_exists: tag required}" errf err rc
errf="$(mktemp)"
if gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq .tag_name >/dev/null 2>"$errf"; then
rm -f "$errf"
echo yes
return 0
fi
rc=$?
err="$(cat "$errf")"; rm -f "$errf"
# gh's 404 text is stable and is the only failure that is an ANSWER.
case "$err" in
*"HTTP 404"*) echo no; return 0 ;;
esac
echo "forge_release_exists: gh exited $rc reading release '$tag' — the answer is unknown, not 'no': $err" >&2
return 1
}
# forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as
# a JSON array. GitHub serves the array directly; the forgejo twin builds
# one from its single-object endpoint so this call site is identical.
# forge_commit_at <sha> — the commit's committer date, ISO-8601, or empty.
#
# A VERB rather than a path at the call site, because the two forges do not
# agree on where a single commit lives: GitHub serves it at /commits/{sha},
# Forgejo 404s there and serves it at /git/commits/{sha} with the timestamp
# under a different field (#209). The caller wants one timestamp; it should not
# have to know either shape.
forge_commit_at() {
local sha="${1:?forge_commit_at: sha required}"
forge_api "repos/$REPO/commits/$sha" --jq '.commit.committer.date'
}
forge_commit_pulls() {
local sha="${1:?forge_commit_pulls: sha required}" errf out rc err
errf="$(mktemp)"
if out="$(gh api "repos/$GITHUB_REPOSITORY/commits/$sha/pulls" 2>"$errf")"; then
rm -f "$errf"
printf '%s\n' "$out"
return 0
fi
rc=$?
err="$(cat "$errf")"; rm -f "$errf"
case "$err" in
*"HTTP 404"*) printf '[]\n'; return 0 ;;
esac
echo "forge_commit_pulls: gh exited $rc reading the PRs for '$sha' — the answer is unknown, not 'none': $err" >&2
return 1
}
# --- the release door's writes (#191) -------------------------------------
# The gh calls the workflow carried before the port, moved behind the shim
# so the call sites stop naming a client. Term 5: same flags, same order.
# forge_tag_create <tag> <sha>
forge_tag_create() {
local tag="${1:?forge_tag_create: tag required}" sha="${2:?forge_tag_create: sha required}"
gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$tag" -f "sha=$sha" >/dev/null
}
# forge_release_create <tag> <title> <notes-file> [asset…]
forge_release_create() {
local tag="${1:?forge_release_create: tag required}" title="${2:?forge_release_create: title required}"
local notes="${3:?forge_release_create: notes file required}"
shift 3
gh release create "$tag" --verify-tag --title "$title" \
--notes-file "$notes" -R "$GITHUB_REPOSITORY" "$@"
}
# forge_pr_create <head> <base> <title> <body> <label…> — the release's
# bump-fallback PR (#191). gh takes repeated --label flags.
forge_pr_create() {
local head="${1:?forge_pr_create: head required}" base="${2:?forge_pr_create: base required}"
local title="${3:?forge_pr_create: title required}" body="${4:?forge_pr_create: body required}"
shift 4
local args=() l
for l in "$@"; do args+=(--label "$l"); done
gh pr create -R "$GITHUB_REPOSITORY" --head "$head" --base "$base" \
--title "$title" --body "$body" "${args[@]}"
}

224
lib/forge.sh Normal file
View file

@ -0,0 +1,224 @@
#!/usr/bin/env bash
# lib/forge.sh — one forge abstraction, two backends (issue #188).
#
# Sourced, never executed: no set -e/-u here — the sourcing script owns its
# own shell options, exactly as lib/version.sh does. This file is the
# selector only; the backends live beside it in lib/forge-github.sh and
# lib/forge-forgejo.sh, and nothing here talks to a network.
#
# WHY THIS FILE EXISTS, stated once. Until #188 the reconcilers were `gh`
# all the way down — 61 runtime call sites, no indirection, no forge check.
# Pointed at a Forgejo instance (heavy-duty/rig, which moved here and runs
# its CI on a Forgejo Actions runner) they did not fail usefully. Measured
# against forgejo.heavyduty.builders on 2026-08-02, at ceremony 84bb1a4:
#
# labels-scope exit 0 "no .github/labeler.yml at main — nothing
# to derive" — the file exists (HTTP 200)
# labels-reconcile exit 0 "reconciled." — having enumerated ZERO PRs
# issueflow-reconcile exit 1 "unexpected end of JSON input"
#
# Two of the three reported SUCCESS having read nothing. labels-reconcile's
# own blind-sweep warning (#96) could not fire, because it counts unreadable
# PRs against a list `gh pr list` never produced — and a process
# substitution's failure does not trip set -e, so `total` stayed 0 and the
# sweep called itself reconciled. rig run 979 is the log.
#
# The tempting fix — install gh on the runner — makes it WORSE. gh speaks
# GitHub's /api/v3 against api.github.com; Forgejo serves /api/v1 and no
# GraphQL at all. With gh present and GH_HOST set to the Forgejo host, the
# one loud failure goes quiet (`gh pr list` hits /api/graphql -> HTTP 405,
# prints nothing, exits into the same empty loop) and all three actions go
# green while reading nothing. That is this repo's own doctrine — an
# unreadable rollup reads as "nothing is failing" — being violated by the
# repo that wrote it.
#
# So: the forge is decided ONCE, before any sweep, and a client that cannot
# speak it refuses loudly. Never "probably github".
# forge_detect — print "github" or "forgejo"; exit 1 loudly when it cannot
# tell. Order matters and every signal below was measured, not read from
# docs: a real forgejo-runner v6.3.1 job on forgejo.heavyduty.builders
# (probe task 278, 2026-08-02) dumped its environment, and a GitHub-hosted
# runner's is the control.
#
# The trap that makes this non-obvious: **the Forgejo runner populates the
# whole GITHUB_* namespace.** GITHUB_ACTIONS=true, GITHUB_REPOSITORY,
# GITHUB_SHA, GITHUB_TOKEN — all set, all correct-looking. Detecting on
# "GITHUB_ACTIONS is set" would answer "github" on both forges, which is
# precisely the bug. What actually differs:
#
# signal GitHub Forgejo (measured)
# GITHUB_API_URL https://api.github.com https://<host>/api/v1
# GITHUB_GRAPHQL_URL https://api.github.com/… (empty)
# GITEA_ACTIONS (unset) true
#
# GITHUB_GRAPHQL_URL being empty on Forgejo is not a curiosity — it is the
# forge telling us the two `gh api graphql` sites #188 retired can never
# work here. It is deliberately NOT a detection signal, though: an empty
# variable is also what a hand-rolled harness leaves behind, and a signal
# that fires on absence is a signal that fires by accident.
forge_detect() {
# 1. The explicit override outranks every probe — the escape hatch for a
# forge this file has not met, and the handle the tests drive. A typo
# in it is fatal on purpose: the operator said something and it was
# wrong, and falling through to a probe that guesses right by accident
# would hide that until the guess was wrong too.
if [ -n "${CEREMONY_FORGE:-}" ]; then
case "$CEREMONY_FORGE" in
github | forgejo) printf '%s\n' "$CEREMONY_FORGE"; return 0 ;;
*)
echo "forge_detect: unknown forge: CEREMONY_FORGE=$CEREMONY_FORGE (expected github or forgejo)" >&2
return 1
;;
esac
fi
# 2. Forgejo's and Gitea's own positive marker. Unambiguous where a
# hand-set GITHUB_API_URL might not be, so it is read first.
if [ "${GITEA_ACTIONS:-}" = true ] || [ "${FORGEJO_ACTIONS:-}" = true ]; then
printf 'forgejo\n'
return 0
fi
# 3. The API URL's shape. /api/v3 is GitHub's (github.com and GitHub
# Enterprise Server alike — GHES is a github backend on a non-github.com
# host, and routing it to the forgejo backend would regress term 5's
# "GitHub consumers are unchanged"). /api/v1 is the Gitea shape Forgejo
# serves.
case "${GITHUB_API_URL:-}" in
https://api.github.com | https://api.github.com/*) printf 'github\n'; return 0 ;;
*/api/v3 | */api/v3/*) printf 'github\n'; return 0 ;;
*/api/v1 | */api/v1/*) printf 'forgejo\n'; return 0 ;;
esac
# 4. Last resort, the server host. Only github.com itself is conclusive
# here: a bare hostname says nothing about which API it serves.
case "${GITHUB_SERVER_URL:-}" in
https://github.com | https://github.com/*) printf 'github\n'; return 0 ;;
esac
# 5. Refuse. "Nothing to read" is not "probably github" — guessing here
# reinstates the exact blind sweep this file exists to end. Name what
# was inspected and the escape hatch, so the log answers "why" without
# a second run (#101 D5, one layer up: report, do not diagnose).
cat >&2 <<EOF
forge_detect: cannot determine which forge this is — refusing to guess (#188).
GITHUB_API_URL='${GITHUB_API_URL:-}'
GITHUB_SERVER_URL='${GITHUB_SERVER_URL:-}'
GITEA_ACTIONS='${GITEA_ACTIONS:-}'
Set CEREMONY_FORGE=github or CEREMONY_FORGE=forgejo to say so explicitly.
EOF
return 1
}
# Where the backends live. Captured at source time, not call time: a
# function that resolves BASH_SOURCE later would resolve its own file, not
# this one.
FORGE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# forge_select [forge] — source the backend for this forge, so the forge_*
# verbs exist. Exactly one backend is ever loaded; both define the same
# names, which is what keeps the branching out of the 61 call sites (term 1)
# and the single-forge assumption from growing back.
#
# Idempotent, because the actions call it once and the tests call it per
# case. Pass a forge explicitly to load a specific backend; omit it and the
# environment decides via forge_detect.
forge_select() {
local forge="${1:-}"
# One default for every consumer: the forgejo backend addresses the repo
# through REPO, the github backend reads GITHUB_REPOSITORY. Defaulting
# here means no call site — workflow step or script — can forget it and
# get a repo-less path (#191). The reconcilers still assert their own.
REPO="${REPO:-${GITHUB_REPOSITORY:-}}"
export REPO
if [ -z "$forge" ]; then
forge="$(forge_detect)" || return 1
fi
case "$forge" in
github | forgejo) ;;
*)
echo "forge_select: unknown forge: $forge (expected github or forgejo)" >&2
return 1
;;
esac
# shellcheck source=/dev/null
. "$FORGE_LIB_DIR/forge-$forge.sh" || return 1
# Read by callers and tests to assert which backend is loaded, so the
# choice is inspectable rather than implied by which functions exist.
# shellcheck disable=SC2034 # consumed by sourcing scripts, not this file
FORGE="$forge"
}
# forge_client <forge> — print the client that backend requires.
#
# github -> gh the current call set, extracted 1:1 (term 5)
# forgejo -> rest /api/v1 over curl+jq
#
# forgejo is "rest" by MEASUREMENT, not preference. The image the Forgejo
# instance actually runs jobs in (ghcr.io/catthehacker/ubuntu:act-22.04,
# probe task 278) carries curl, jq and node — and has neither `gh` NOR
# `stoke` on PATH. That second absence is what retired option A from the
# ruling: porting the call sites to the stoke CLI would have put a binary
# on the critical path that the runner does not have and that would need
# installing before every job.
forge_client() {
case "${1:?forge_client: forge required}" in
github) printf 'gh\n' ;;
forgejo) printf 'rest\n' ;;
*)
echo "forge_client: unknown forge: $1 (expected github or forgejo)" >&2
return 1
;;
esac
}
# forge_preflight — the gate. Run it BEFORE any sweep: it decides the forge
# and proves the client can speak it, or exits non-zero with a named reason.
#
# CEREMONY_FORGE_CLIENT declares the client the caller will actually use —
# how a call site that still hard-codes `gh` announces itself honestly while
# the backends are being ported. Two checks run, in order:
#
# 1. the declaration, when made, must match what this forge needs;
# 2. that client's binaries must actually be on PATH — checked whether or
# not a declaration was made, because a call site that declares the
# right client on a runner that lacks it is still a blind sweep waiting
# to happen.
forge_preflight() {
local forge want
forge="$(forge_detect)" || return 1
want="$(forge_client "$forge")" || return 1
if [ -n "${CEREMONY_FORGE_CLIENT:-}" ] && [ "$CEREMONY_FORGE_CLIENT" != "$want" ]; then
cat >&2 <<EOF
forge_preflight: this is a '$forge' forge and the '$CEREMONY_FORGE_CLIENT' client cannot speak it (#188).
gh speaks GitHub's /api/v3 against api.github.com; Forgejo serves /api/v1
and has no GraphQL surface at all. Pointing one at the other does not
fail usefully — it reads nothing and reports success.
This forge needs the '$want' client.
EOF
return 1
fi
# Then prove the tools are actually here — declared or not. A missing
# binary is the rig failure verbatim, "line 692: gh: command not found",
# and it must be a refusal before the sweep, not a 127 halfway through
# one. Checked on BOTH paths deliberately: a call site that declares the
# right client on a runner that lacks it is still a blind sweep waiting
# to happen.
local missing_bins=() bin
case "$want" in
gh) command -v gh >/dev/null 2>&1 || missing_bins+=(gh) ;;
rest) for bin in curl jq; do command -v "$bin" >/dev/null 2>&1 || missing_bins+=("$bin"); done ;;
esac
if [ "${#missing_bins[@]}" -gt 0 ]; then
cat >&2 <<EOF
forge_preflight: this is a '$forge' forge, which needs the '$want' client, and ${missing_bins[*]} is not installed (#188).
Refusing before the sweep: a reconciler that cannot read the board must
not report that it reconciled one.
EOF
return 1
fi
return 0
}

28
lib/issue_references.sh Normal file
View file

@ -0,0 +1,28 @@
#!/usr/bin/env bash
# lib/issue_references.sh — the LOCAL / CROSS reference classifier (#61).
#
# Sourced, never executed: no set -e/-u — the sourcing script owns its shell
# options, as lib/closes_references.sh and lib/forge.sh do.
#
# WHY IT LIVES HERE. It was defined inside actions/issueflow-reconcile's
# executable, and lib/closes_references.sh's header recorded the resulting
# wart in prose: "DEPENDENCY: issue_references, from issueflow-reconcile.sh".
# That was tolerable while the reconciler was its only caller. #199 makes
# actions/refs-not-closing a second one, and a composite action cannot source
# another action's program to borrow one function — sourcing a reconciler
# runs a reconciler.
#
# So the dependency the comment described is now a file, and both callers
# source it the same way. Nothing about the classifier changed.
#
# A qualified reference belongs to another repository. The whole token is
# classified BEFORE any number is extracted, so `rig#112` can never be read
# as local `#112` — which is the entire point of the function.
issue_references() { # text on stdin -> LOCAL/CROSS<TAB>reference
{ grep -Eo '([[:alnum:]_.-]+/)?[[:alnum:]_.-]+#[0-9]+|#[0-9]+' || true; } \
| awk '
index($0, "#") == 1 { print "LOCAL\t" substr($0, 2); next }
{ print "CROSS\t" $0 }
'
}

81
lib/preflight.sh Executable file
View file

@ -0,0 +1,81 @@
#!/usr/bin/env bash
# lib/preflight.sh — the merge door's resume decision, pure and exhaustively
# tested (issue #273).
#
# A merge-door run creates the tag before the artifact hook and release. A
# failed hook or publish therefore leaves a tag but no release. Re-running the
# same merge commit must resume after that irreversible step; a published
# release or a tag naming another commit must still refuse.
#
# Pure: no repository or forge reads. The workflow establishes four facts:
#
# VER the version being released
# MERGE_SHA the commit this door would tag
# TAG_SHAS object names returned for the direct and peeled tag refs,
# newline-separated; empty means the tag is absent
# RELEASED yes|no — whether a published release for VER exists
#
# Output: resume=yes or resume=no on stdout, notices to stdout, refusals to
# stderr, return 1 on refusal.
#
# The decision table (this IS the spec — issue #273):
#
# | # | RELEASED | TAG_SHAS contains MERGE_SHA | result |
# |---|----------|------------------------------|---------------------------|
# | 1 | yes | either | REFUSE: already released |
# | 2 | no | empty | resume=no: ordinary run |
# | 3 | no | yes | resume=yes + resume NOTICE |
# | 4 | no | non-empty, no | REFUSE: tag is elsewhere |
release_preflight() {
local tag_sha sha
if [ -z "${VER:-}" ]; then
printf '%s\n' "VER is empty — the caller failed to establish the release version. Refusing to decide — creating nothing." >&2
return 1
fi
if [ -z "${MERGE_SHA:-}" ]; then
printf '%s\n' "MERGE_SHA is empty — the caller failed to establish the merge commit. Refusing to decide — creating nothing." >&2
return 1
fi
if [ -z "${RELEASED:-}" ]; then
printf '%s\n' "RELEASED is empty — the caller failed to establish whether release '$VER' exists. Refusing to decide — creating nothing." >&2
return 1
fi
case "$RELEASED" in
yes | no) ;;
*)
printf '%s\n' "RELEASED='$RELEASED' — expected yes or no. Refusing to decide — creating nothing." >&2
return 1
;;
esac
# Row 1 comes first: deleting a tag under a standing release never makes
# that release safe to recreate.
if [ "$RELEASED" = yes ]; then
printf '%s\n' "release '$VER' already exists — this release already happened; refusing to re-release, creating nothing." >&2
return 1
fi
# Row 2: an absent tag is the ordinary first run.
if [ -z "${TAG_SHAS:-}" ]; then
printf '%s\n' 'resume=no'
return 0
fi
# Row 3: compare each object name as a whole line. For an annotated tag the
# direct ref names the tag object and the peeled ref names MERGE_SHA.
while IFS= read -r sha; do
if [ "$sha" = "$MERGE_SHA" ]; then
printf '%s\n' "NOTICE: tag '$VER' already stands at this merge commit and no release exists — a previous run of this door tagged and then failed to publish. Resuming: the tag is not recreated; the artifact hook and the publish run."
printf '%s\n' 'resume=yes'
return 0
fi
done <<<"$TAG_SHAS"
# Row 4: the first object name is enough to diagnose the conflicting tag;
# MERGE_SHA is printed beside it so the operator sees both sides.
tag_sha="${TAG_SHAS%%$'\n'*}"
printf '%s\n' "tag '$VER' already exists at $tag_sha but this run would tag $MERGE_SHA — a manual tag won the race, or it names a different commit; refusing to re-release, creating nothing. Delete that tag, or re-tag the merge commit." >&2
return 1
}

62
lib/read.sh Normal file
View file

@ -0,0 +1,62 @@
#!/usr/bin/env bash
# lib/read.sh — the guarded read: an unreadable fact never invents a verdict.
#
# Both reconcilers source this file. The rule is the family's oldest one
# (#101, #95) and it has now been bought twice: the PR surface learned it
# when a permissions denial and a network hiccup left byte-identical
# evidence, and the ISSUE surface learned it when an HTTP 504 whose body is
# GitHub's JSON error object flowed straight into a decision function —
# `gh api` prints that body to stdout *and* exits non-zero, so the payload
# reaching the guards was valid JSON, `.labels[]` came back empty, and the
# sweep wrote `needs-triage` onto a healthy epic and called the pass a
# success (crew#329, #247).
#
# Two helpers:
# - guarded_read — run a read, keep its stderr, report its status
# - read_failure_reason — render that stderr into one bounded log line
#
# `read_failure_reason` is called from both surfaces. `guarded_read` is
# called from the issue surface only, and that is deliberate rather than
# unfinished: labels-reconcile's two capture sites are byte-identical to each
# other and predate this file, and converting them is a cleanup #247 does not
# own. Do not go looking for a labels-side caller — there is none yet.
#
# What the CALLER does with a failed read is the caller's: labels-reconcile
# leaves the PR alone for the pass, issueflow-reconcile skips the issue. The
# one thing neither may do is carry a degraded value into a decision.
guarded_read() { # $1 = variable to fill, rest = the read; sets READ_FAILURE_STDERR
# The status check and the captured stderr are one operation on purpose: a
# read whose failure is noticed but whose reason is thrown away is what
# #95 had to infer a cause from a control case for — wrongly, it turned
# out (#101 D2). Captured into a file rather than merged into stdout, so
# an unlucky error line can never be read back as the read's own payload.
local __var="$1" __err __out __rc=0
shift
__err="$(mktemp)" || return 1
__out="$("$@" 2>"$__err")" || __rc=$?
# shellcheck disable=SC2034 # the out-parameter: every caller reads it beside the status
READ_FAILURE_STDERR="$(cat "$__err")"
rm -f "$__err"
printf -v "$__var" '%s' "$__out"
return "$__rc"
}
read_failure_reason() { # $1 = captured stderr → one bounded line; pure (#101)
# Verbatim, collapsed, bounded (D3): gh emits multi-line errors and GraphQL
# blobs. Collapsed so the reason is exactly one log line — a raw newline
# inside the captured per-item output block could collide with a matched
# string — and truncated because an unbounded paste per item per sweep is
# noise, and annotations are capped anyway.
local reason
reason="$(printf '%s' "${1-}" | tr '\n' ' ')"
if [ -z "$reason" ]; then
# Empty stderr is itself a fact (D4): a read that failed silently is a
# different observation from a denial, and must not read as one.
echo "no error output"
elif [ "${#reason}" -gt 300 ]; then
printf '%s…\n' "${reason:0:300}"
else
printf '%s\n' "$reason"
fi
}

432
lib/ruling.sh Normal file
View file

@ -0,0 +1,432 @@
#!/usr/bin/env bash
# lib/ruling.sh — the `needs-ruling` sweep invariants (issues #52 and #73,
# epic #50).
#
# Both reconcilers source this file: the bare-flag check, the escalation
# shape check, the ladder's rung comments and the 7-day nudge are ONE
# implementation serving both surfaces — two copies of a 7-day rule is how
# the family got here in the first place (#50). Pure decisions sit above
# the divider (facts in, verdict out); the one impure orchestrator below
# talks to gh and posts through the sourcing script's run()/log().
#
# Standing rules this file lives under:
# - The machine never sets or clears `needs-ruling` (#50 D9). Nothing here
# mutates a label; the only writes are comments. Pinned by a sweep-probe
# test and a grep-level check over the mutation calls.
# - The machine never judges prose (#50 D4). "Did the escalation contract
# accompany the flag" is decided by a mechanical proxy: the actor who
# applied the label has a comment timestamped no earlier than 15 minutes
# before the `labeled` event. The back-window exists because the natural
# ordering is post-the-escalation-then-set-the-label, seconds apart; a
# strictly-after rule would flag every correctly-formed escalation.
# - The failure direction is always *flag*, never *act*: a bare flag is
# commented on, never removed (removing would delete somebody's
# escalation on the strength of a timestamp heuristic), and an
# unreadable timeline does nothing at all — an unreadable fact must
# never invent a verdict (the reconciler's standing rule).
# One constant, both surfaces — the whole reason this file exists. 7 days
# (#50 D10): long enough that an active back-and-forth is never nudged,
# short enough that a forgotten ruling surfaces within the week.
RULING_NUDGE_AFTER=$((7 * 24 * 3600))
# The escalation back-window: a comment this many seconds before the
# `labeled` event still accompanies it.
RULING_BARE_WINDOW=$((15 * 60))
# The ladder's rungs (#50 D13, via #72's doctrine): moments past the current
# episode's `labeled` event. Two timers only — the 24h rung's comment names
# triage's past-24h authority too, so there is no third timer to keep honest.
RULING_RUNG12_AT=$((12 * 3600))
RULING_RUNG24_AT=$((24 * 3600))
# The escalation contract's four field labels (#50 D12). Literal strings —
# the template in BUILDER.md ("the ruling ask") fixes them so this machinery
# can check for them; presence is all that is ever checked (#50 D4).
RULING_SHAPE_FIELDS=(Options: Recommend: Blocked: Default:)
# The idempotency markers, one per comment kind, each scoped to the current
# `labeled` episode by ruling_bare_comment_needed. The nudge deliberately
# has NO marker — see ruling_nudge_decision.
RULING_BARE_MARKER='<!-- ceremony:needs-ruling-bare -->'
RULING_SHAPE_MARKER='<!-- ceremony:needs-ruling-shape -->'
RULING_RUNG12_MARKER='<!-- ceremony:needs-ruling-rung12 -->'
RULING_RUNG24_MARKER='<!-- ceremony:needs-ruling-rung24 -->'
# ---------------------------------------------------------------------------
# Pure decisions. Facts in (args/stdin), verdict out. No gh, no clock.
# ---------------------------------------------------------------------------
ruling_stale_exempt() { # labels on stdin → EXEMPT | SWEEP
# Waiting on a human is legitimately quiet (#50 D10): under a pending
# ruling the staleness clock does not run, the same treatment `blocked`
# gets. The PR-side skip is #51's and lives in labels-reconcile.sh; this
# verdict drives the ISSUE side (the claim-reclaim clock), so the rule has
# one spelling even though the two surfaces consult it in different places.
if grep -qxF needs-ruling; then echo EXEMPT; else echo SWEEP; fi
}
ruling_accompanies() { # $1 comment epoch, $2 labeled epoch → 0 iff in-window
# One spelling of the window rule, shared by the bare verdict and the
# escalation-comment lookup the nudge links — two comparisons drifting
# apart would let the nudge link a comment the bare check rejected.
[ "$1" -ge "$(($2 - RULING_BARE_WINDOW))" ]
}
ruling_bare_decision() { # $1 setter, $2 labeled epoch; "login epoch" lines on stdin
# → ACCOMPANIED | BARE. Only the flag-setter's own comments count: the
# escalation contract (question, options, recommendation — #50 D4) is the
# flag-setter's to post, and somebody else's chatter must not satisfy it.
local setter="$1" labeled="$2" login epoch
while read -r login epoch; do
[ -n "$login" ] || continue
[ "$login" = "$setter" ] || continue
if ruling_accompanies "$epoch" "$labeled"; then
echo ACCOMPANIED
return 0
fi
done
echo BARE
}
ruling_bare_comment_needed() { # $1 labeled epoch, $2 newest marked-comment epoch ("" = none)
# → POST | SKIP. Scoped to the CURRENT labeled event: a marked comment
# older than the event belongs to an earlier flag episode, so a genuine
# re-flag is re-checked while a 15-minute cron never repeats itself.
# Marker-agnostic — the caller tracks a newest epoch PER marker (#73), so
# this one comparison scopes every marked write (bare, shape, both rungs).
local labeled="$1" marked="${2:-}"
if [ -n "$marked" ] && [ "$marked" -gt "$labeled" ]; then
echo SKIP
else
echo POST
fi
}
ruling_shape_field_present() { # $1 field label; escalation body on stdin → 0 iff present
# THE one spelling of the field-presence test — the escalation selector
# scores by it and the shape check grades by it, on purpose in one place:
# a selector scoring by one grep while the check grades by another is how
# the crew#293 misgrade would come back from the other side (#226).
# Line-anchored, allowing leading whitespace and Markdown bold
# (`**Options:**` is how the live escalations write them): the labels
# appearing only mid-sentence is not the template.
local field="$1"
grep -Eq "^[[:space:]]*(\*\*)?$field"
}
ruling_shape_score() { # escalation body on stdin → 04, one point per field present
# The selection rule's metric (#226). An empty body scores 0 through the
# same loop — no special case, and never an error.
local body field score=0
body="$(cat)"
for field in "${RULING_SHAPE_FIELDS[@]}"; do
if ruling_shape_field_present "$field" <<<"$body"; then score=$((score + 1)); fi
done
echo "$score"
}
ruling_shape_decision() { # escalation body on stdin → SHAPED | MALFORMED <missing labels>
# Presence only (#50 D4): that `Recommend:` exists is checkable, that the
# recommendation is any good is not — no counting options, no parsing the
# prose. The per-field test is ruling_shape_field_present, shared with the
# selector (#226). The `🧭 needs-ruling` header line is deliberately
# unchecked — it is prose, and an emoji grep on an LC_ALL=C runner is a
# portability trap for zero enforcement value.
local body field missing=""
body="$(cat)"
for field in "${RULING_SHAPE_FIELDS[@]}"; do
ruling_shape_field_present "$field" <<<"$body" || missing="$missing $field"
done
if [ -z "$missing" ]; then echo SHAPED; else echo "MALFORMED$missing"; fi
}
ruling_deadline_decision() { # $1 now, $2 the current episode's labeled epoch → RUNG0 | RUNG12 | RUNG24
# The ladder anchors to the `labeled` event, never to activity (#50 D14:
# an active back-and-forth still climbs it; only the separate 7-day nudge
# resets). "At 12h" means AT: the boundary starts the rung — the rung is a
# moment whose duty exists the moment it strikes, unlike the strictly-past
# nudge horizon. RUNG24 covers "past 24h" too: the 24h comment names both
# the builder's rung and triage's past-24h authority, so there is no
# fourth timer to keep honest.
local age=$(($1 - $2))
if [ "$age" -ge "$RULING_RUNG24_AT" ]; then
echo RUNG24
elif [ "$age" -ge "$RULING_RUNG12_AT" ]; then
echo RUNG12
else
echo RUNG0
fi
}
ruling_default_decision() { # escalation body on stdin → DEADLINE <ts> | HARDBLOCK | UNPARSEABLE
# Parsed only to *describe* the item in the rung comments, never to gate a
# rung (#50 D14: the rungs apply whatever `Default:` says). Mechanical or
# nothing: an ISO-8601 UTC timestamp anywhere on the `Default:` line is
# the deadline, the literal word `none` is a hard block, anything else is
# reported as unparseable rather than guessed at. Only the `Default:` line
# is read — a timestamp elsewhere in the body is somebody's prose.
local line ts
line="$(grep -E '^[[:space:]]*(\*\*)?Default:' | head -n1)"
ts="$(grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}(:[0-9]{2})?Z' <<<"$line" | head -n1)"
if [ -n "$ts" ]; then
echo "DEADLINE $ts"
elif grep -qE '(^|[^[:alnum:]])none([^[:alnum:]]|$)' <<<"$line"; then
echo HARDBLOCK
else
echo UNPARSEABLE
fi
}
ruling_nudge_decision() { # $1 now, $2 last real-activity epoch → NUDGE | KEEP
# Real activity only, as the caller's surface defines it: the PR sweep
# supplies comments, reviews and commits; the issue sweeps supply comments
# alone — an `assigned` event is the claim clock's fact, and counting it
# let a claim silence a pending ruling (#284). Never label churn, or the
# sweep would reset its own clock. The nudge needs NO marker: the
# nudge comment is itself activity, so posting it resets this window and
# the rule self-rate-limits to at most one nudge per 7 quiet days. That is
# deliberate — a later refactor that "fixes" it by adding a marker breaks
# exactly the property that makes it safe on a 15-minute cron.
if [ "$(($1 - $2))" -gt "$RULING_NUDGE_AFTER" ]; then echo NUDGE; else echo KEEP; fi
}
ruling_newest_flag() { # "login<TAB>iso8601" lines on stdin → the newest line
# A re-flag after a removal is judged on its own escalation, never on the
# last one's — so every fact anchors to the MOST RECENT labeled event.
# ISO-8601 UTC sorts lexically, so no date parsing is needed here.
sort -t $'\t' -k2 | tail -n1
}
ruling_escalation_row() { # $1 setter, $2 labeled epoch; "login epoch url [b64]" lines on stdin
# → "url b64" of the BEST-SHAPED in-window comment by the setter, or
# nothing: highest ruling_shape_score wins, equal scores break to the
# earliest epoch. Earliest-wins outright was the rule until crew#293
# (2026-08-02): a builder answered its round whole and escalated 33
# seconds later — both in one window, the reply earlier — and the sweep
# graded the round reply, told a correct escalation it was malformed, and
# the setter re-posted a shape it had already met. Score resolves both
# orderings; the earliest tiebreak keeps escalation-then-follow-ups
# wherever the scores cannot tell candidates apart, including all-zero.
# An undecodable or absent body scores 0 and stays a legal candidate —
# an unreadable fact never invents a verdict, and never errors the sweep.
# The window and the setter gate candidacy before any score is taken.
local setter="$1" labeled="$2" login epoch url b64 body score
local best_score=-1 best_epoch="" best=""
while read -r login epoch url b64; do
[ -n "$login" ] || continue
[ "$login" = "$setter" ] || continue
ruling_accompanies "$epoch" "$labeled" || continue
if body="$(base64 -d <<<"${b64:-}" 2>/dev/null)"; then
score="$(ruling_shape_score <<<"$body")"
else
score=0
fi
if [ "$score" -gt "$best_score" ] \
|| { [ "$score" -eq "$best_score" ] && [ "$epoch" -lt "$best_epoch" ]; }; then
best_score="$score"
best_epoch="$epoch"
best="$url ${b64:-}"
fi
done
[ -z "$best" ] || printf '%s\n' "$best"
}
ruling_escalation_url() { # same contract, url column only — the nudge's link
local row
row="$(ruling_escalation_row "$@")"
[ -z "$row" ] || printf '%s\n' "${row%% *}"
}
# ---------------------------------------------------------------------------
# The impure orchestrator: fetch the facts, call the decisions, post through
# the caller's run(). Called by both reconcilers for every open item that
# carries the flag. Needs REPO; uses the caller's run() and log().
# ---------------------------------------------------------------------------
reconcile_ruling() { # $1 item number, $2 last real-activity epoch, $3 now
local n="$1" last_activity="$2" now="$3"
: "${REPO:?reconcile_ruling: REPO is required}"
# The newest `labeled` event for the flag: actor + timestamp. A failed read
# skips BOTH checks — the nudge's specified content links the escalation
# comment, which only these facts identify, and half-verdicts on half-read
# facts is the exact shape the reconciler's standing rule forbids.
# forge_timeline projects both forges into the GitHub event shape
# (.event / .actor.login). Capture its status BEFORE jq: a pipeline's
# status is the last command's, so `forge_timeline | jq` would collapse
# an unreadable timeline into an empty one — the two states this function
# exists to tell apart (#188 / #4853).
local flags newest setter labeled_at labeled_epoch timeline
if ! timeline="$(forge_timeline "$n" 2>/dev/null)"; then
log "#$n: ruling timeline unreadable — no verdict invented this pass"
return 0
fi
flags="$(jq -r '
.[] | select(.event == "labeled" and .label.name == "needs-ruling")
| [.actor.login, .created_at] | @tsv
' <<<"$timeline")"
if [ -z "$flags" ]; then
# The label is on the item but no labeled event is visible (a timeline
# hiccup, or an import). Same treatment as unreadable: do nothing.
log "#$n: ruling flag has no visible labeled event — no verdict invented this pass"
return 0
fi
newest="$(ruling_newest_flag <<<"$flags")"
setter="${newest%%$'\t'*}"
labeled_at="${newest##*$'\t'}"
labeled_epoch="$(date -d "$labeled_at" +%s)"
# The body travels as jq's @base64 — bodies carry newlines and tabs, and
# the whole file is line-oriented, so the row format stays TSV and the
# body is decoded at its points of use (#73). Do not switch rows to JSON.
local comments
if ! comments="$(forge_api --paginate "repos/$REPO/issues/$n/comments" \
--jq '.[] | [.user.login, .created_at, .html_url,
((.body // "") | @base64)] | @tsv' 2>/dev/null)"; then
log "#$n: ruling comments unreadable — no verdict invented this pass"
return 0
fi
# One pass over the comments builds every fact the decisions consume: who
# commented when (for the bare verdict), the newest marked comment PER
# MARKER (each write is idempotent per episode — one pass, not one pass
# per marker), and the "login epoch url b64" rows the escalation lookup
# reads. A body that fails to decode counts as unmarked — for idempotency
# that risks a repeat, never an invented verdict.
local login at url b64 body epoch authored="" rows=""
local marked_bare="" marked_shape="" marked_rung12="" marked_rung24=""
while IFS=$'\t' read -r login at url b64; do
[ -n "$login" ] || continue
epoch="$(date -d "$at" +%s)"
authored="$authored$login $epoch"$'\n'
rows="$rows$login $epoch $url ${b64:-}"$'\n'
body="$(base64 -d <<<"${b64:-}" 2>/dev/null)" || body=""
case "$body" in *"$RULING_BARE_MARKER"*)
if [ -z "$marked_bare" ] || [ "$epoch" -gt "$marked_bare" ]; then marked_bare="$epoch"; fi ;;
esac
case "$body" in *"$RULING_SHAPE_MARKER"*)
if [ -z "$marked_shape" ] || [ "$epoch" -gt "$marked_shape" ]; then marked_shape="$epoch"; fi ;;
esac
case "$body" in *"$RULING_RUNG12_MARKER"*)
if [ -z "$marked_rung12" ] || [ "$epoch" -gt "$marked_rung12" ]; then marked_rung12="$epoch"; fi ;;
esac
case "$body" in *"$RULING_RUNG24_MARKER"*)
if [ -z "$marked_rung24" ] || [ "$epoch" -gt "$marked_rung24" ]; then marked_rung24="$epoch"; fi ;;
esac
done <<<"$comments"
# ---- the bare-flag check (#50 D4, mechanical proxy) ----
if [ "$(ruling_bare_decision "$setter" "$labeled_epoch" <<<"$authored")" = BARE ]; then
if [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_bare")" = POST ]; then
run forge_issue_comment "$n" "$RULING_BARE_MARKER
The ruling flag on this item was set by @$setter with no accompanying
escalation comment. Setting it requires the escalation contract — the
**question**, the **options**, and a **recommendation** — posted by the
flag-setter no more than 15 minutes before applying the label, or any time
after ([LABELS.md](https://github.com/heavy-duty/ceremony/blob/main/LABELS.md)
carries the flag-setter's obligations; heavy-duty/ceremony#50 D4). The label stays — this machine never removes an
escalation on the strength of a timestamp heuristic — but the contract is
still owed." >/dev/null
log "#$n: ruling flag is bare — commented (the label is never removed)"
fi
# Bare stops here (#73): there is no shape to check when there is no
# escalation comment, and a rung comment beside the bare comment would be
# two comments about the same omission — noise. The 7-day nudge below is
# deliberately untouched by this exclusion; it predates the ladder and
# already words the bare case itself.
else
# ---- the shape check (#50 D12): the contract's four field labels ----
local esc_row esc_url esc_body shape
esc_row="$(ruling_escalation_row "$setter" "$labeled_epoch" <<<"$rows")"
esc_url="${esc_row%% *}"
if ! esc_body="$(base64 -d <<<"${esc_row#* }" 2>/dev/null)"; then
# An undecodable body must not become "malformed" — an unreadable fact
# never invents a verdict. The rungs read the same body, so they wait
# for a readable pass too.
log "#$n: escalation body unreadable — no verdict invented this pass"
else
shape="$(ruling_shape_decision <<<"$esc_body")"
if [ "$shape" != SHAPED ] \
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_shape")" = POST ]; then
local missing="${shape#MALFORMED }"
run forge_issue_comment "$n" "$RULING_SHAPE_MARKER
@$setter — the [escalation comment]($esc_url) accompanying this ruling flag
is missing required field labels: **$missing**. The contract's shape is
fixed because this machinery checks for it (heavy-duty/ceremony#50 D12):
four line-anchored field labels — \`Options:\`, \`Recommend:\`, \`Blocked:\`,
\`Default:\` — per the canonical template in
[BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask).
Presence is all that is checked; the machine never judges the prose
(heavy-duty/ceremony#50 D4). The label stays — the shape is owed, not
enforced." >/dev/null
log "#$n: escalation malformed (missing:$missing) — commented (the shape is owed, not enforced)"
fi
# ---- the ladder's rungs (#50 D13D14), observed never decided ----
# Each rung's comment fires once per episode, AT its moment: a rung
# whose moment passed unobserved (the sweep was down through 12h24h)
# is not paged after the fact — the later rung's comment carries the
# whole remaining duty.
local rung state described
rung="$(ruling_deadline_decision "$now" "$labeled_epoch")"
state="$(ruling_default_decision <<<"$esc_body")"
case "$state" in
DEADLINE\ *) described="a stated default deadline of \`${state#DEADLINE }\`" ;;
HARDBLOCK) described="\`Default: none\` — a hard block; no default ever fires" ;;
*) described="a missing or unparseable \`Default:\` line — reported as-is, never guessed at (and the contract's own rule is that unsure is a hard block)" ;;
esac
if [ "$rung" = RUNG12 ] \
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung12")" = POST ]; then
run forge_issue_comment "$n" "$RULING_RUNG12_MARKER
@$setter — this ruling is 12 hours past its \`labeled\` event: the ladder's
12h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask),
heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries
$described.
The rung's duty is the flag-setter's: re-read the \`Default:\` against
everything that has landed since the flag went up — does it still hold, and
has reasonable doubt appeared? A stale default does not fire, and new doubt
makes it a hard block. The rungs run on the \`labeled\` clock and do not
reset on activity; this comment fires once per flag episode." >/dev/null
log "#$n: ruling at the 12h rung — commented (the setter re-reads the default)"
fi
if [ "$rung" = RUNG24 ] \
&& [ "$(ruling_bare_comment_needed "$labeled_epoch" "$marked_rung24")" = POST ]; then
run forge_issue_comment "$n" "$RULING_RUNG24_MARKER
@$setter — this ruling is 24 hours past its \`labeled\` event: the ladder's
24h rung ([BUILDER.md — the ruling ask](https://github.com/heavy-duty/ceremony/blob/main/BUILDER.md#the-ruling-ask),
heavy-duty/ceremony#50 D13). Mechanically read, the escalation carries
$described.
At 24h the builder proceeds regardless, **as a PR**: pick an option and
state in the PR body which way you went and what doubt remains. Nothing
merges by this — the human still gates the merge. Past 24h the choice is
triage's to make: triage picks the option, records it as a decision, and
remains accountable; the operator may overturn it at merge. The rungs run on
the \`labeled\` clock and do not reset on activity; this comment fires once
per flag episode and covers everything past 24h — there is no further
timer." >/dev/null
log "#$n: ruling at the 24h rung — commented (the builder proceeds as a PR; past 24h is triage's)"
fi
fi
fi
# ---- the 7-day nudge (#50 D10) ----
if [ "$(ruling_nudge_decision "$now" "$last_activity")" = NUDGE ]; then
# The decider is the repo's human reviewer — the same knob the PR
# reconciler trusts for the merge gate, defaulted the same way. The
# flag-setter is named but deliberately not tagged: address the decider,
# never the whole thread's cast (#50 D10).
local decider="${HUMAN_REVIEWER:-danmt}" days esc_url esc_line
days=$(((now - last_activity) / 86400))
esc_url="$(ruling_escalation_url "$setter" "$labeled_epoch" <<<"$rows")"
if [ -n "$esc_url" ]; then
esc_line="The escalation is here: $esc_url"
else
esc_line="No escalation comment accompanies the flag — the contract (question, options, recommendation) is still owed by the flag-setter."
fi
run forge_issue_comment "$n" "@$decider — a ruling on this item has been pending with no activity for ${days} days. $esc_line
Per heavy-duty/ceremony#50 D6/D7 the flag-setter ($setter) owns closing this out: judge when agreement is reached, record the ruling as a decision in one comment, remove the label, and return the item to its flow in that same comment.
*This nudge is comment-only and carries no idempotency marker on purpose: the comment itself is activity, so posting it resets the 7-day window and the rule self-rate-limits. Do not add a marker.*" >/dev/null
log "#$n: ruling nudge (${days}d quiet — the decider owes an answer)"
fi
}

47
test/attention.test.sh Normal file
View file

@ -0,0 +1,47 @@
#!/usr/bin/env bash
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
source "$ROOT/test/harness.sh"
# shellcheck source=lib/attention.sh
source "$ROOT/lib/attention.sh"
check "attention on an unassigned PR is malformed" 0 "MALFORMED_PR" \
attention_target_decision pr 0
check "attention on an assigned PR is still malformed" 0 "MALFORMED_PR" \
attention_target_decision pr 1
check "attention on an unassigned issue is malformed" 0 "MALFORMED_UNASSIGNED" \
attention_target_decision issue 0
check "attention on an assigned issue is healthy" 0 "KEEP" \
attention_target_decision issue 1
check "an unknown surface is rejected" 2 "" attention_target_decision discussion 0
check "a malformed PR target is commented" 0 "POST" \
attention_comment_decision MALFORMED_PR ""
check "an unassigned issue target is commented without precedence" 0 "POST" \
attention_comment_decision MALFORMED_UNASSIGNED ""
check "claimed-unassigned precedence suppresses the second comment" 0 "SUPPRESS" \
attention_comment_decision MALFORMED_UNASSIGNED claimed-unassigned
check "post-merge-assigned precedence suppresses the second comment" 0 "SUPPRESS" \
attention_comment_decision MALFORMED_UNASSIGNED post-merge-assigned
check "a healthy target stays silent" 0 "KEEP" \
attention_comment_decision KEEP ""
check "the newest labeled event defines the episode" 0 "2026-08-03T12:00:00Z" \
attention_newest_flag <<'EOF'
2026-08-03T10:00:00Z
2026-08-03T12:00:00Z
2026-08-03T11:00:00Z
EOF
check "the marker names the label episode" 0 \
'<!-- ceremony:attention-malformed:2026-08-03T12:00:00Z -->' \
attention_episode_marker 2026-08-03T12:00:00Z
# Diagnosis is the only write this library may own. Pin the absence of every
# label/assignee mutation spelling so a later refactor cannot quietly turn a
# report into a repair (#229 D2).
check "the attention library contains no issue/PR edit mutation" 1 "" \
grep -E 'gh (issue|pr) edit|--(add|remove)-(label|assignee)' "$ROOT/lib/attention.sh"
summary

View file

@ -53,6 +53,25 @@ tree dev-armed 1.2.4-dev <<'EOF'
EOF EOF
check "-dev + Unreleased on top passes" 0 "agrees" in_tree dev-armed check "-dev + Unreleased on top passes" 0 "agrees" in_tree dev-armed
tree dev-seeded 1.2.4-dev <<'EOF'
# Changelog
## Unreleased
### Added
### Changed
### Fixed
## 1.2.3 — 2026-07-20
### Fixed
- The shipped entry.
EOF
check "-dev + seeded empty Unreleased headings passes" 0 "agrees" in_tree dev-seeded
tree dev-stamped 1.2.4-dev <<'EOF' tree dev-stamped 1.2.4-dev <<'EOF'
# Changelog # Changelog
@ -90,6 +109,34 @@ tree bare-stamped 1.2.3 <<'EOF'
EOF EOF
check "bare + own stamped section on top passes" 0 "agrees" in_tree bare-stamped check "bare + own stamped section on top passes" 0 "agrees" in_tree bare-stamped
tree bare-dangling-heading 1.2.3 <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
### Added
EOF
check "bare + dangling heading fails with the heading diagnosis" 1 \
"section '1.2.3' has no entries — a heading is not an entry" \
in_tree bare-dangling-heading
check "bare + dangling heading keeps the half-ceremony remedy" 1 \
"HALF-DONE ceremony" in_tree bare-dangling-heading
tree bare-partly-dangling 1.2.3 <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
### Added
### Fixed
- Fixed entry.
EOF
check "bare + one empty grouped heading names the first empty heading" 1 \
"section '1.2.3' has an empty heading: '### Added'" \
in_tree bare-partly-dangling
tree bare-empty-stamp 1.2.3 <<'EOF' tree bare-empty-stamp 1.2.3 <<'EOF'
# Changelog # Changelog
@ -201,6 +248,317 @@ EOF
check "package-json: bare + armed passes" 0 "agrees" \ check "package-json: bare + armed passes" 0 "agrees" \
in_tree pkg-bare-armed CHANGELOG.md package-json in_tree pkg-bare-armed CHANGELOG.md package-json
# --- fragment mode: changelog.d/ is the arming -------------------------------
fragment_tree() {
local name="$1" version="$2"
shift 2
tree "$name" "$version"
mkdir -p "$TMP/$name/changelog.d"
printf '%s\n' "# Changelog fragments" >"$TMP/$name/changelog.d/README.md"
}
fragment_tree fragments-dev-empty 1.2.4-dev <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
- The shipped entry.
EOF
check "fragment -dev + marker + no fragments passes" 0 "fragment mode" \
in_tree fragments-dev-empty
fragment_tree fragments-dev-flat 1.2.4-dev <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
- The shipped entry.
EOF
printf '%s\n' "- Added fragment mode (#115)." >"$TMP/fragments-dev-flat/changelog.d/115.md"
check "fragment -dev + well-formed flat fragment passes" 0 "fragment mode" \
in_tree fragments-dev-flat
# The entry length bound (#167) reds the PR that writes the fragment, with
# the shared changelog_fragment_problem diagnosis.
fragment_tree fragments-dev-over-bound 1.2.4-dev <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
- The shipped entry.
EOF
printf -- '- %s\n' \
"$(awk 'BEGIN { s = ""; while (length(s) < 301) s = s "a"; print s }')" \
>"$TMP/fragments-dev-over-bound/changelog.d/115.md"
check "fragment mode refuses an over-bound entry, fragment and length named" 1 \
"115.md' has a 301-character entry" \
in_tree fragments-dev-over-bound
check "fragment mode over-bound refusal names the bound and the split fix" 1 \
"the bound is 300: split it into multiple '- ' entries in this same fragment" \
in_tree fragments-dev-over-bound
# The terminal cite (#262) reds the PR that writes the fragment, through the
# same shared predicate — which is the whole point of the rule living there
# rather than in prose a reviewer has to remember.
fragment_tree fragments-dev-uncited 1.2.4-dev <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
- The shipped entry.
EOF
printf '%s\n' "- An entry that never learned to cite its issue." \
>"$TMP/fragments-dev-uncited/changelog.d/115.md"
check "fragment mode refuses an uncited entry, fragment named" 1 \
"115.md' has an entry with no issue citation" \
in_tree fragments-dev-uncited
check "fragment mode uncited refusal names the shape to write" 1 \
"end it with the issue it comes from: '(#N).'" \
in_tree fragments-dev-uncited
fragment_tree fragments-dev-misplaced-cite 1.2.4-dev <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
- The shipped entry.
EOF
printf '%s\n' "- The citation trails the period. (#115)" \
>"$TMP/fragments-dev-misplaced-cite/changelog.d/115.md"
check "fragment mode refuses a non-terminal citation, fragment named" 1 \
"115.md' has an entry whose issue citation is not terminal" \
in_tree fragments-dev-misplaced-cite
fragment_tree fragments-dev-grouped 1.2.4-dev <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
### Fixed
- The shipped entry.
EOF
cat >"$TMP/fragments-dev-grouped/changelog.d/115.md" <<'EOF'
### Changed
- Added fragment mode (#115).
EOF
check "fragment -dev + well-formed grouped fragment passes" 0 "fragment mode" \
in_tree fragments-dev-grouped
fragment_tree fragments-dev-mixed 1.2.4-dev <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
- The shipped entry.
EOF
printf '%s\n' "- Flat fragment (#115)." >"$TMP/fragments-dev-mixed/changelog.d/114.md"
cat >"$TMP/fragments-dev-mixed/changelog.d/115.md" <<'EOF'
### Fixed
- Grouped fragment (#115).
EOF
check "fragment mode refuses mixed shapes with the shared assembler diagnosis" 1 \
"fragment 'changelog.d/115.md' is grouped but fragment 'changelog.d/114.md' is not" \
in_tree fragments-dev-mixed
fragment_tree fragments-dev-all-grouped-over-flat 1.2.4-dev <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
- The shipped entry.
EOF
cat >"$TMP/fragments-dev-all-grouped-over-flat/changelog.d/115.md" <<'EOF'
### Fixed
- Grouped fragment (#115).
EOF
check "fragment mode refuses an all-grouped set over a flat published section" 1 \
"changelog.d/115.md' is grouped but newest published section '1.2.3'" \
in_tree fragments-dev-all-grouped-over-flat
fragment_tree fragments-dev-flat-over-grouped 1.2.4-dev <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
### Fixed
- The shipped entry.
EOF
printf '%s\n' "- Flat fragment (#115)." >"$TMP/fragments-dev-flat-over-grouped/changelog.d/115.md"
check "fragment mode refuses a flat set over a grouped published section" 1 \
"changelog.d/115.md' is flat but newest published section '1.2.3'" \
in_tree fragments-dev-flat-over-grouped
# The declared anchor (#182): the flip tree — a grouped set under a
# 'grouped' sentinel over a flat published section — is green, where the
# same tree minus the sentinel is the all-grouped-over-flat red row above.
fragment_tree fragments-dev-flip 1.2.4-dev <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
- The shipped entry.
EOF
printf '%s\n' "grouped" >"$TMP/fragments-dev-flip/changelog.d/shape"
cat >"$TMP/fragments-dev-flip/changelog.d/115.md" <<'EOF'
### Fixed
- Grouped fragment (#115).
EOF
check "fragment mode: 'grouped' sentinel admits the flip tree over a flat published section" 0 \
"fragment mode" in_tree fragments-dev-flip
# Post-flip drift is refused on its own PR: a flat probe fragment atop the
# flip tree goes red — beside grouped fragments the mix rule names it first.
printf '%s\n' "- Flat probe (#116)." >"$TMP/fragments-dev-flip/changelog.d/116.md"
check "fragment mode: a flat probe atop the flip tree is refused" 1 \
"changelog.d/115.md' is grouped but fragment 'changelog.d/116.md' is not" \
in_tree fragments-dev-flip
rm "$TMP/fragments-dev-flip/changelog.d/116.md"
# And once the grouped fragments are consumed, the sentinel alone still
# holds the shape: an all-flat set under 'grouped' is refused, sentinel
# named — the published-section inference never gets a say.
rm "$TMP/fragments-dev-flip/changelog.d/115.md"
printf '%s\n' "- Flat probe (#116)." >"$TMP/fragments-dev-flip/changelog.d/116.md"
check "fragment mode: a flat set under the 'grouped' sentinel refused, sentinel named" 1 \
"changelog.d/116.md' is flat but 'changelog.d/shape' declares grouped" \
in_tree fragments-dev-flip
rm "$TMP/fragments-dev-flip/changelog.d/116.md"
printf '%s\n' "Grouped" >"$TMP/fragments-dev-flip/changelog.d/shape"
check "fragment mode: a malformed sentinel is refused, file named" 1 \
"'changelog.d/shape' declares neither shape" \
in_tree fragments-dev-flip
printf 'grouped\n\n' >"$TMP/fragments-dev-flip/changelog.d/shape"
check "fragment mode: a sentinel with a trailing blank line is refused, file named" 1 \
"'changelog.d/shape' declares neither shape" \
in_tree fragments-dev-flip
fragment_tree fragments-dev-no-published 1.2.4-dev <<'EOF'
# Changelog
Preamble only.
EOF
cat >"$TMP/fragments-dev-no-published/changelog.d/115.md" <<'EOF'
### Fixed
- Grouped fragment (#115).
EOF
check "fragment mode accepts a consistent set with no published section" 0 \
"fragment mode" in_tree fragments-dev-no-published
fragment_tree fragments-unreleased 1.2.4-dev <<'EOF'
# Changelog
## Unreleased
## 1.2.3 — 2026-07-20
- The shipped entry.
EOF
check "fragment mode refuses even an empty Unreleased section" 1 \
"Unreleased' section survived the adoption" in_tree fragments-unreleased
fragment_tree fragments-no-marker 1.2.4-dev <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
- The shipped entry.
EOF
rm "$TMP/fragments-no-marker/changelog.d/README.md"
check "fragment mode requires the generated marker" 1 "README.md" \
in_tree fragments-no-marker
fragment_tree fragments-bad-name 1.2.4-dev <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
- The shipped entry.
EOF
printf '%s\n' "- An entry." >"$TMP/fragments-bad-name/changelog.d/notes.md"
check "fragment mode quotes malformed-fragment diagnosis and file" 1 \
"fragment 'changelog.d/notes.md' is not named for its issue" \
in_tree fragments-bad-name
fragment_tree fragments-dangling-group 1.2.4-dev <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
- The shipped entry.
EOF
printf '%s\n' "### Changed" >"$TMP/fragments-dangling-group/changelog.d/115.md"
check "fragment mode refuses a dangling fragment heading" 1 \
"fragment 'changelog.d/115.md' has no entries" \
in_tree fragments-dangling-group
fragment_tree fragments-bare-stamped 1.2.3 <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
- The shipped entry.
## 1.2.2 — 2026-07-01
- The older entry.
EOF
check "fragment bare + stamped section + consumed directory passes" 0 \
"fragment mode" in_tree fragments-bare-stamped
cp -R "$TMP/fragments-bare-stamped" "$TMP/fragments-bare-survivor"
printf '%s\n' "- This entry was not consumed (#115)." \
>"$TMP/fragments-bare-survivor/changelog.d/115.md"
check "fragment bare refuses and lists surviving fragments" 1 \
"these fragments were not consumed: changelog.d/115.md" \
in_tree fragments-bare-survivor
fragment_tree fragments-bare-wrong 1.2.3 <<'EOF'
# Changelog
## 9.9.9 — 2026-07-20
- The wrong release.
## 1.2.3 — 2026-07-19
- The right release was not stamped on top.
EOF
check "fragment bare refuses a stamp for another version" 1 \
"stamped the wrong number" in_tree fragments-bare-wrong
fragment_tree fragments-bare-missing 1.2.3 <<'EOF'
# Changelog
## 1.2.2 — 2026-07-01
- The older entry.
EOF
check "fragment bare refuses a missing stamp via section diagnosis" 1 \
"no section for '1.2.3'" in_tree fragments-bare-missing
fragment_tree fragments-cross-mode 1.2.4-dev <<'EOF'
# Changelog
## 1.2.3 — 2026-07-20
- The shipped entry.
EOF
check "same changelog passes in fragment mode" 0 "fragment mode" \
in_tree fragments-cross-mode
rm -rf "$TMP/fragments-cross-mode/changelog.d"
check "same changelog fails in legacy mode" 1 "development tree" \
in_tree fragments-cross-mode
# --- the action's wiring: inputs arrive as env vars -------------------------- # --- the action's wiring: inputs arrive as env vars --------------------------
mkdir -p "$TMP/env-tree" mkdir -p "$TMP/env-tree"
@ -212,4 +570,14 @@ env_tree() {
} }
check "env vars drive the script the way action.yml does" 0 "agrees" env_tree check "env vars drive the script the way action.yml does" 0 "agrees" env_tree
mkdir -p "$TMP/env-fragments/custom.d"
printf '1.2.4-dev\n' >"$TMP/env-fragments/VERSION"
printf '# Changelog\n\n## 1.2.3 — 2026-07-20\n\n- Shipped.\n' \
>"$TMP/env-fragments/CHANGELOG.md"
printf '%s\n' "# Changelog fragments" >"$TMP/env-fragments/custom.d/README.md"
env_fragments() {
(cd "$TMP/env-fragments" && FRAGMENTS_DIR=custom.d bash "$SCRIPT")
}
check "fragments-dir env var selects fragment mode" 0 "fragment mode" env_fragments
summary summary

View file

@ -0,0 +1,469 @@
#!/usr/bin/env bash
# Contract tests for bin/changelog-assemble (issue #114). Constructed
# fixture trees, no git repos — the same discipline as
# test/changelog-armed.test.sh. set -u, not -e: failing commands are
# behavior for the harness to inspect.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
# shellcheck source=lib/changelog.sh
. "$ROOT/lib/changelog.sh"
TOOL="$ROOT/bin/changelog-assemble"
SECTION="$ROOT/bin/changelog-section"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# tree <name> — a fixture tree with changelog.d/ and its README marker;
# the changelog body arrives on stdin.
tree() {
mkdir -p "$TMP/$1/changelog.d"
printf 'Machine-assembled; see heavy-duty/ceremony#112.\n' >"$TMP/$1/changelog.d/README.md"
cat >"$TMP/$1/CHANGELOG.md"
}
# frag <tree> <name> — a fragment; body on stdin.
frag() {
cat >"$TMP/$1/changelog.d/$2"
}
# The tool reads the consumer's tree at its working directory, so every
# case runs from inside a constructed fixture tree.
in_tree() {
local dir="$1"
shift
(cd "$TMP/$dir" && "$TOOL" "$@")
}
assert_file() {
local file="$1" expected="$2" actual
actual="$(cat "$file")"
[ "$actual" = "$expected" ] || {
printf 'wanted:\n%s\ngot:\n%s\n' "$expected" "$actual"
return 1
}
}
BASE_CHANGELOG=$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.'
# --- flat write: exact bytes, exact deletions --------------------------------
tree flat-one <<EOF
$BASE_CHANGELOG
EOF
frag flat-one 12.md <<'EOF'
- Twelve landed (#12).
EOF
check "flat: one fragment assembles and stamps" 0 "consumed 1 fragment" \
in_tree flat-one 0.2.0 2026-07-24
check "flat: preamble and shipped section stay byte-identical around the insert" 0 "" \
assert_file "$TMP/flat-one/CHANGELOG.md" \
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n- Twelve landed (#12).\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.'
check "flat: the consumed fragment is deleted" 1 "" \
test -e "$TMP/flat-one/changelog.d/12.md"
check "flat: README.md survives consumption" 0 "" \
test -e "$TMP/flat-one/changelog.d/README.md"
# --- publication order: numeric, newest first, cross-repo beside local -------
tree flat-many <<EOF
$BASE_CHANGELOG
EOF
frag flat-many 2.md <<'EOF'
- Two (#2).
EOF
frag flat-many 9.md <<'EOF'
- Nine (#9).
EOF
frag flat-many 10.md <<'EOF'
- Ten (#10).
EOF
frag flat-many ceremony-14.md <<'EOF'
- Fourteen crossed over — naïve reflows would mangle this café's
continuation line, so it must survive verbatim (#14).
EOF
assert_check() {
local dir="$1" expected="$2" actual
actual="$(in_tree "$dir" 0.2.0 2026-07-24 --check)"
[ "$actual" = "$expected" ] || {
printf 'wanted:\n%s\ngot:\n%s\n' "$expected" "$actual"
return 1
}
}
check "flat: numeric-descending order (10.md before 9.md), cross-repo name beside local" 0 "" \
assert_check flat-many $'- Fourteen crossed over — naïve reflows would mangle this café'"'"$'s\n continuation line, so it must survive verbatim (#14).\n- Ten (#10).\n- Nine (#9).\n- Two (#2).'
# --- grouped write: canonical order, unnamed group appended ------------------
tree grouped <<'EOF'
# Changelog
Preamble prose belongs to no section.
## 0.1.0 — 2026-07-01
### Fixed
- The shipped entry.
EOF
frag grouped 21.md <<'EOF'
### Fixed
- Fixed twenty-one (#21).
EOF
frag grouped 20.md <<'EOF'
### Added
- Added twenty (#20).
- Added twenty, second bullet (#20).
### Docs
- Docs twenty (#20).
EOF
frag grouped 19.md <<'EOF'
### Security
- Security nineteen (#19).
### Added
- Added nineteen (#19).
EOF
GROUPED_BODY=$'### Added\n\n- Added twenty (#20).\n- Added twenty, second bullet (#20).\n- Added nineteen (#19).\n\n### Fixed\n\n- Fixed twenty-one (#21).\n\n### Security\n\n- Security nineteen (#19).\n\n### Docs\n\n- Docs twenty (#20).'
check "grouped: --check shows canonical order, multi-bullet group, unnamed group last" 0 "" \
assert_check grouped "$GROUPED_BODY"
check "grouped: write mode assembles the same section" 0 "consumed 3 fragment" \
in_tree grouped 0.2.0 2026-07-24
check "grouped: the written file is exact" 0 "" \
assert_file "$TMP/grouped/CHANGELOG.md" \
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n'"$GROUPED_BODY"$'\n\n## 0.1.0 — 2026-07-01\n\n### Fixed\n\n- The shipped entry.'
# --- the declared anchor: the first grouped release over a flat history ------
# The flip ceremony (#182): a 'grouped' sentinel admits grouped fragments
# over a flat newest published section, the sentinel is never a stray file,
# and it survives the consumption (D5) — the next -dev tree still declares
# its shape.
tree flip <<EOF
$BASE_CHANGELOG
EOF
printf 'grouped\n' >"$TMP/flip/changelog.d/shape"
frag flip 40.md <<'EOF'
### Added
- Forty landed (#40).
EOF
check "sentinel: the flip release assembles grouped over a flat published section" 0 \
"consumed 1 fragment" in_tree flip 0.2.0 2026-07-24
check "sentinel: the written flip section is exact" 0 "" \
assert_file "$TMP/flip/CHANGELOG.md" \
$'# Changelog\n\nPreamble prose belongs to no section.\n\n## 0.2.0 — 2026-07-24\n\n### Added\n\n- Forty landed (#40).\n\n## 0.1.0 — 2026-07-01\n\n- The shipped entry.'
check "sentinel: changelog.d/shape survives consumption" 0 "" \
test -e "$TMP/flip/changelog.d/shape"
tree flip-flat-frag <<EOF
$BASE_CHANGELOG
EOF
printf 'grouped\n' >"$TMP/flip-flat-frag/changelog.d/shape"
frag flip-flat-frag 41.md <<'EOF'
- Flat forty-one (#41).
EOF
check "sentinel: a flat fragment under 'grouped' refuses, sentinel named" 1 \
"changelog.d/shape' declares grouped" in_tree flip-flat-frag 0.2.0 2026-07-24
tree flip-malformed <<EOF
$BASE_CHANGELOG
EOF
printf 'Grouped\n' >"$TMP/flip-malformed/changelog.d/shape"
frag flip-malformed 42.md <<'EOF'
### Added
- Forty-two (#42).
EOF
check "sentinel: a malformed sentinel refuses, file named" 1 \
"changelog.d/shape' declares neither shape" in_tree flip-malformed 0.2.0 2026-07-24
# --- a changelog holding only its preamble -----------------------------------
tree preamble-only <<'EOF'
# Changelog
Only preamble so far.
EOF
frag preamble-only 1.md <<'EOF'
- The first entry ever (#1).
EOF
check "a changelog with no section yet gets the section after the preamble" 0 "" \
in_tree preamble-only 0.1.0 2026-07-24
check "preamble-only write is exact" 0 "" \
assert_file "$TMP/preamble-only/CHANGELOG.md" \
$'# Changelog\n\nOnly preamble so far.\n\n## 0.1.0 — 2026-07-24\n\n- The first entry ever (#1).'
# --- the fragment predicate at release time (#262) ---------------------------
# The cite rule joins changelog_fragment_problem, so it binds both callers:
# the arming guard at PR time and this assembler at release time. Asserted
# rather than assumed — a release that publishes an uncited entry is the
# failure the PR-time guard exists to have already caught.
tree uncited-release <<EOF
$BASE_CHANGELOG
EOF
frag uncited-release 60.md <<'EOF'
- An entry that never learned to cite its issue.
EOF
check "release time: an uncited fragment refuses the release, fragment named" 1 \
"changelog.d/60.md' has an entry with no issue citation" \
in_tree uncited-release 0.2.0 2026-07-24
check "release time: the uncited refusal survives --check too" 1 \
"has an entry with no issue citation" \
in_tree uncited-release 0.2.0 2026-07-24 --check
check "release time: the refused release wrote nothing" 0 "" \
test -e "$TMP/uncited-release/changelog.d/60.md"
tree misplaced-release <<EOF
$BASE_CHANGELOG
EOF
frag misplaced-release 61.md <<'EOF'
- The citation trails the period. (#61)
EOF
check "release time: a non-terminal citation refuses the release" 1 \
"changelog.d/61.md' has an entry whose issue citation is not terminal" \
in_tree misplaced-release 0.2.0 2026-07-24
# --- --check is provably read-only -------------------------------------------
tree check-readonly <<EOF
$BASE_CHANGELOG
EOF
frag check-readonly 5.md <<'EOF'
- Five (#5).
EOF
cp -R "$TMP/check-readonly" "$TMP/check-readonly.before"
check "--check prints the assembled body" 0 "Five (#5)." \
in_tree check-readonly 0.2.0 2026-07-24 --check
check "--check is read-only: the tree is byte-identical before and after" 0 "" \
diff -r "$TMP/check-readonly.before" "$TMP/check-readonly"
# --- the date defaults to today (UTC) ----------------------------------------
check "date defaults without an argument" 0 "consumed 1 fragment" \
in_tree check-readonly 0.2.0
check "the defaulted stamp is a UTC date" 0 "" \
grep -qE '^## 0\.2\.0 — [0-9]{4}-[0-9]{2}-[0-9]{2}$' "$TMP/check-readonly/CHANGELOG.md"
# --- --changelog and --dir override the defaults -----------------------------
mkdir -p "$TMP/flagged/frags"
printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\n' >"$TMP/flagged/NOTES.md"
printf -- '- Flagged entry (#2).\n' >"$TMP/flagged/frags/2.md"
check "--changelog and --dir override the defaults" 0 "" \
"$TOOL" 0.2.0 2026-07-24 --changelog "$TMP/flagged/NOTES.md" --dir "$TMP/flagged/frags"
check "the flag-driven write landed in the named changelog" 0 "" \
grep -qF -- "- Flagged entry (#2)." "$TMP/flagged/NOTES.md"
# --- refusals: each names the file responsible -------------------------------
tree empty-frags <<EOF
$BASE_CHANGELOG
EOF
rm "$TMP/empty-frags/changelog.d/README.md"
check "an empty directory refuses — a release publishes prose" 1 "zero fragments" \
in_tree empty-frags 0.2.0
check "the empty-directory refusal names the directory" 1 "changelog.d" \
in_tree empty-frags 0.2.0
tree only-readme <<EOF
$BASE_CHANGELOG
EOF
check "a README-only directory refuses with zero fragments" 1 "zero fragments" \
in_tree only-readme 0.2.0
tree no-bullet <<EOF
$BASE_CHANGELOG
EOF
frag no-bullet 3.md <<'EOF'
Prose without a bullet is a heading in spirit.
EOF
check "a fragment with no bullet refuses, file named" 1 \
"fragment 'changelog.d/3.md' has no entries" \
in_tree no-bullet 0.2.0
tree dangling <<EOF
$BASE_CHANGELOG
EOF
frag dangling 4.md <<'EOF'
### Added
### Fixed
- Fixed entry.
EOF
check "a dangling grouped heading refuses, file and heading named" 1 \
"fragment 'changelog.d/4.md' has an empty heading: '### Added'" \
in_tree dangling 0.2.0
tree smuggled <<EOF
$BASE_CHANGELOG
EOF
frag smuggled 6.md <<'EOF'
## 0.2.0 — 2026-07-24
- An entry under a smuggled heading.
EOF
check "a fragment carrying a '## ' line refuses, file named" 1 \
"fragment 'changelog.d/6.md' carries a '## ' heading" \
in_tree smuggled 0.2.0
tree stray-txt <<EOF
$BASE_CHANGELOG
EOF
frag stray-txt 7.md <<'EOF'
- Seven (#7).
EOF
frag stray-txt notes.txt <<'EOF'
A stray scratchpad.
EOF
check "a stray notes.txt refuses, file named" 1 "notes.txt" \
in_tree stray-txt 0.2.0
tree stray-markdown <<EOF
$BASE_CHANGELOG
EOF
frag stray-markdown 12.markdown <<'EOF'
- Wrong extension.
EOF
check "12.markdown refuses on the name pattern" 1 "12.markdown" \
in_tree stray-markdown 0.2.0
tree stray-case <<EOF
$BASE_CHANGELOG
EOF
frag stray-case Fix-12.md <<'EOF'
- Uppercase prefix.
EOF
check "Fix-12.md refuses on the name pattern" 1 "Fix-12.md" \
in_tree stray-case 0.2.0
tree mixed <<EOF
$BASE_CHANGELOG
EOF
frag mixed 5.md <<'EOF'
- Flat five (#5).
EOF
frag mixed 6.md <<'EOF'
### Added
- Grouped six (#6).
EOF
check "grouped + flat mixed refuses, both files named" 1 "6.md" \
in_tree mixed 0.2.0
check "the mixed refusal names the flat side too" 1 "5.md" \
in_tree mixed 0.2.0
tree grouped-over-flat <<EOF
$BASE_CHANGELOG
EOF
frag grouped-over-flat 6.md <<'EOF'
### Added
- Grouped six (#6).
EOF
check "an all-grouped set over a flat published section refuses before assembly" 1 \
"fragment 'changelog.d/6.md' is grouped but newest published section '0.1.0'" \
in_tree grouped-over-flat 0.2.0
tree already <<'EOF'
# Changelog
## 0.2.0 — 2026-07-20
- Already shipped.
EOF
frag already 4.md <<'EOF'
- A late fragment (#4).
EOF
check "an already-present section refuses — the ceremony was already run" 1 \
"already has a section for '0.2.0'" \
in_tree already 0.2.0
# Whole-version matching, as everywhere in this family: an rc section never
# blocks the bare version.
tree rc-present <<'EOF'
# Changelog
## 0.2.0-rc1 — 2026-07-15
- The candidate's entry.
EOF
frag rc-present 8.md <<'EOF'
- The real release entry (#8).
EOF
check "an rc section does not block assembling the bare version" 0 "" \
in_tree rc-present 0.2.0 2026-07-24
mkdir -p "$TMP/no-changelog/changelog.d"
printf -- '- Entry (#2).\n' >"$TMP/no-changelog/changelog.d/2.md"
check "a missing changelog refuses" 1 "no such file" \
in_tree no-changelog 0.2.0
# --- usage errors exit 2 -----------------------------------------------------
check "no arguments is a usage error" 2 "usage:" in_tree flat-one
check "an unknown flag is a usage error" 2 "usage:" in_tree flat-one 0.2.0 --frobnicate
check "a third positional is a usage error" 2 "usage:" in_tree flat-one 0.2.0 2026-07-24 extra
check "--dir without a value is a usage error" 2 "usage:" in_tree flat-one 0.2.0 --dir
# --- round trip: the publisher and the assembler agree by test ---------------
tree round-trip <<'EOF'
# Changelog
Preamble prose belongs to no section.
## 0.1.0 — 2026-07-01
### Fixed
- The shipped entry.
EOF
frag round-trip 30.md <<'EOF'
### Added
- Thirty — wraps onto a
continuation line with a naïve café (#30).
EOF
frag round-trip 29.md <<'EOF'
### Fixed
- Fixed twenty-nine (#29).
EOF
CHECKED="$(in_tree round-trip 0.2.0 2026-07-24 --check)"
check "round trip: write mode succeeds after --check" 0 "" \
in_tree round-trip 0.2.0 2026-07-24
assert_round_trip() {
local published
published="$( (cd "$TMP/round-trip" && "$SECTION" 0.2.0) )"
[ "$published" = "$CHECKED" ] || {
printf -- '--check said:\n%s\nthe publisher said:\n%s\n' "$CHECKED" "$published"
return 1
}
}
check "round trip: bin/changelog-section returns exactly the body --check printed" 0 "" \
assert_round_trip
check "round trip: the assembled section reports no problem" 0 "" \
changelog_section_problem "$TMP/round-trip/CHANGELOG.md" 0.2.0
# --- idempotence: the ceremony cannot run twice ------------------------------
check "a second write over the consumed directory refuses with zero fragments" 1 \
"zero fragments" \
in_tree round-trip 0.2.0 2026-07-24
summary

View file

@ -0,0 +1,380 @@
#!/usr/bin/env bash
# Contract tests for actions/changelog-assembled (issue #116). Like the
# monotonic guard's suite, every applicable case is a constructed git repo —
# "the section matches the fragments it consumed" is a property of a DIFF:
# the fragments are gone from HEAD's tree by construction, so the fixture is
# a history: a base commit holding the fragments and a -dev version, a HEAD
# commit holding the ceremony's edit, and a 'base' branch standing in for
# origin/main. set -u, not -e: failing commands are behavior for the
# harness to inspect.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
SCRIPT="$ROOT/actions/changelog-assembled/changelog-assembled.sh"
ARMED="$ROOT/actions/changelog-armed/changelog-armed.sh"
MONOTONIC="$ROOT/actions/changelog-monotonic/changelog-monotonic.sh"
ASSEMBLE="$ROOT/bin/changelog-assemble"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
init_repo() {
local dir="$TMP/$1"
mkdir -p "$dir"
git -C "$dir" init -q -b main
git -C "$dir" config user.email ci@example.invalid
git -C "$dir" config user.name ci
}
# commit_base <name> — commit the tree as the merge base and pin 'base' there.
commit_base() {
git -C "$TMP/$1" add -A
git -C "$TMP/$1" commit -qm base
git -C "$TMP/$1" branch base
}
commit_head() {
git -C "$TMP/$1" add -A
git -C "$TMP/$1" commit -qm head
}
# seed_flat <name> — the shared pre-ceremony tree: an armed changelog, two
# flat fragments (numeric order will put 12 before 9), a -dev version.
seed_flat() {
local name="$1" dir="$TMP/$1"
init_repo "$name"
mkdir -p "$dir/changelog.d"
printf 'Machine-assembled; see heavy-duty/ceremony#112.\n' >"$dir/changelog.d/README.md"
cat >"$dir/CHANGELOG.md" <<'EOF'
# Changelog
Preamble prose belongs to no section.
## 0.1.0 — 2026-07-01
- The shipped entry.
EOF
printf '0.1.1-dev\n' >"$dir/VERSION"
printf -- '- Twelve landed (#12).\n' >"$dir/changelog.d/12.md"
printf -- '- Nine landed, and its prose wraps onto a\n continuation line (#9).\n' >"$dir/changelog.d/9.md"
commit_base "$name"
}
# ceremony <name> <ver> <date> — the faithful release edit on the working
# tree, exactly as a human runs it: the real assembler in write mode, then
# the bare version stamp. Deliberately does NOT commit, so a case can break
# the tree before committing HEAD.
ceremony() {
local name="$1" ver="$2" stamp="$3"
(cd "$TMP/$name" && "$ASSEMBLE" "$ver" "$stamp" >/dev/null 2>&1) || return 1
printf '%s\n' "$ver" >"$TMP/$name/VERSION"
}
run() { local name="$1"; shift; (cd "$TMP/$name" && bash "$SCRIPT" "$@"); }
run_strict() { local name="$1"; shift; (cd "$TMP/$name" && CHANGELOG_ASSEMBLED_STRICT=1 bash "$SCRIPT" "$@"); }
# --- the documented flow passes ----------------------------------------------
seed_flat faithful-flat
ceremony faithful-flat 0.2.0 2026-07-24
commit_head faithful-flat
check "faithful flat ceremony: the section is byte-for-byte the assembly" 0 \
"byte-for-byte" run faithful-flat base
seed_flat faithful-grouped
sed -i '/^- The shipped entry/i ### Fixed\\\n' "$TMP/faithful-grouped/CHANGELOG.md"
printf -- '### Fixed\n\n- Fixed twenty-one (#21).\n' >"$TMP/faithful-grouped/changelog.d/21.md"
printf -- '### Added\n\n- Added twenty (#20).\n\n### Docs\n\n- Docs twenty (#20).\n' >"$TMP/faithful-grouped/changelog.d/20.md"
rm "$TMP/faithful-grouped/changelog.d/12.md" "$TMP/faithful-grouped/changelog.d/9.md"
git -C "$TMP/faithful-grouped" add -A
git -C "$TMP/faithful-grouped" commit -qm regroup
git -C "$TMP/faithful-grouped" branch -f base
ceremony faithful-grouped 0.2.0 2026-07-24
commit_head faithful-grouped
check "faithful grouped ceremony passes" 0 "byte-for-byte" run faithful-grouped base
# The date is HEAD's to choose: a stamp nowhere near today must not read as
# a prose difference — the comparison is body against body, headings (and
# so dates) never enter it.
seed_flat old-date
ceremony old-date 0.2.0 2020-01-01
commit_head old-date
check "the stamp's date never enters the comparison" 0 "byte-for-byte" \
run old-date base
# --- inapplicable trees: green NOTICE, never a silent skip -------------------
seed_flat ordinary-add
printf -- '- Thirteen incoming (#13).\n' >"$TMP/ordinary-add/changelog.d/13.md"
commit_head ordinary-add
check "-dev PR adding a fragment: green NOTICE" 0 "NOTICE" run ordinary-add base
seed_flat ordinary-none
printf 'code\n' >"$TMP/ordinary-none/code.txt"
commit_head ordinary-none
check "-dev PR touching no fragment: green NOTICE" 0 "NOTICE" run ordinary-none base
seed_flat ordinary-del
rm "$TMP/ordinary-del/changelog.d/12.md"
commit_head ordinary-del
check "-dev PR even deleting a fragment: green NOTICE" 0 "NOTICE" run ordinary-del base
# Legacy mode: no changelog.d/ at the merge base — always a NOTICE, even on
# a release tree, because the mid-adoption ceremony edits the changelog by
# hand and there is no fragment set for its section to answer to.
init_repo legacy
printf '# Changelog\n\n## Unreleased\n\n- An entry.\n' >"$TMP/legacy/CHANGELOG.md"
printf '0.1.1-dev\n' >"$TMP/legacy/VERSION"
commit_base legacy
printf '# Changelog\n\n## 0.2.0 — 2026-07-24\n\n- An entry.\n' >"$TMP/legacy/CHANGELOG.md"
printf '0.2.0\n' >"$TMP/legacy/VERSION"
commit_head legacy
check "legacy repo (no changelog.d at base): green NOTICE, even on a release tree" 0 \
"NOTICE" run legacy base
# The un-rearmed window: a PR branched right after a release merges sits on
# a bare version whose section was stamped at its MERGE BASE — it is not
# the ceremony and must not be asked to answer for one.
seed_flat post-release
ceremony post-release 0.2.0 2026-07-24
git -C "$TMP/post-release" add -A
git -C "$TMP/post-release" commit -qm release
git -C "$TMP/post-release" branch -f base
printf 'code\n' >"$TMP/post-release/code.txt"
commit_head post-release
check "a PR atop the un-rearmed release: green NOTICE (this branch did not stamp)" 0 \
"NOTICE" run post-release base
# --- the refusals ------------------------------------------------------------
# The issue's headline failure: one fragment kept out of the ceremony — it
# survives at HEAD and its entry is absent from the section. Both refusals
# fire: the diff names the missing entry, the survivor list names the file.
seed_flat dropped
mv "$TMP/dropped/changelog.d/9.md" "$TMP/dropped/9.md.hold"
ceremony dropped 0.2.0 2026-07-24
mv "$TMP/dropped/9.md.hold" "$TMP/dropped/changelog.d/9.md"
commit_head dropped
check "a fragment kept out of the ceremony fails" 1 "" run dropped base
check "the dropped-entry diff names the missing entry" 1 "Nine landed" \
run dropped base
check "the surviving fragment is listed by path" 1 "changelog.d/9.md" \
run dropped base
# The same drop, but the fragment was deleted anyway: its prose vanished
# without ever being published. Only the diff can say so.
seed_flat vanished
mv "$TMP/vanished/changelog.d/9.md" "$TMP/vanished/9.md.hold"
ceremony vanished 0.2.0 2026-07-24
rm "$TMP/vanished/9.md.hold"
commit_head vanished
check "a deleted fragment whose entry never landed fails" 1 "Nine landed" \
run vanished base
seed_flat edited
ceremony edited 0.2.0 2026-07-24
sed -i 's/Twelve landed/Twelve allegedly landed/' "$TMP/edited/CHANGELOG.md"
commit_head edited
check "a hand-edited entry fails with a unified diff" 1 "+++" run edited base
check "the edit is visible in the diff" 1 "allegedly" run edited base
check "the diff failure teaches redo-with-the-tool, never hand-edit" 1 \
"never to hand-edit" run edited base
# Re-ordering away from the canonical order: the section is hand-built with
# the right entries in the wrong order (the assembler puts 12 before 9).
seed_flat reordered
rm "$TMP/reordered/changelog.d/12.md" "$TMP/reordered/changelog.d/9.md"
cat >"$TMP/reordered/CHANGELOG.md" <<'EOF'
# Changelog
Preamble prose belongs to no section.
## 0.2.0 — 2026-07-24
- Nine landed, and its prose wraps onto a
continuation line (#9).
- Twelve landed (#12).
## 0.1.0 — 2026-07-01
- The shipped entry.
EOF
printf '0.2.0\n' >"$TMP/reordered/VERSION"
commit_head reordered
check "re-ordered entries fail" 1 "NOT what the fragments" run reordered base
# A faithful assembly that forgot one deletion: the section is right, the
# directory is not — only the survivor refusal fires.
seed_flat survivor
ceremony survivor 0.2.0 2026-07-24
printf -- '- Nine landed, and its prose wraps onto a\n continuation line (#9).\n' >"$TMP/survivor/changelog.d/9.md"
commit_head survivor
check "a surviving fragment with its entry present fails" 1 "STILL PRESENT" \
run survivor base
check "the survivor refusal names the file" 1 "changelog.d/9.md" \
run survivor base
# A release PR can be faithful to its merge base while the target branch moves
# ahead and gains a fragment during review. That target-only fragment was not
# available to the ceremony, so merging the PR would strand it for the next
# release. The guard must read the target ref as well as their merge base.
seed_flat target-stranded
ceremony target-stranded 0.2.0 2026-07-24
commit_head target-stranded
git -C "$TMP/target-stranded" switch -q base
printf -- '- Landed while the release was under review (#30).\n' \
>"$TMP/target-stranded/changelog.d/30.md"
git -C "$TMP/target-stranded" add -A
git -C "$TMP/target-stranded" commit -qm target-fragment
git -C "$TMP/target-stranded" switch -q main
check "a target-head fragment the release did not consume fails" 1 \
"changelog.d/30.md" run target-stranded base
check "the target-stranding refusal names the rebase remedy" 1 \
"rebase onto the target head" run target-stranded base
check "the target-stranding refusal names the assembler re-run" 1 \
"changelog-assemble '0.2.0'" run target-stranded base
# Removing the target-only fragment makes the same diverged fixture green:
# target drift itself is not the failure, only a stranded fragment is.
git -C "$TMP/target-stranded" switch -q base
rm "$TMP/target-stranded/changelog.d/30.md"
git -C "$TMP/target-stranded" add -A
git -C "$TMP/target-stranded" commit -qm target-fragment-removed
git -C "$TMP/target-stranded" switch -q main
check "the same target fixture is green once no fragment is stranded" 0 \
"byte-for-byte" run target-stranded base
# Spell out the common harmless case independently: the target branch moved,
# but the advancing commit added no fragment.
seed_flat target-unrelated
ceremony target-unrelated 0.2.0 2026-07-24
commit_head target-unrelated
git -C "$TMP/target-unrelated" switch -q base
printf 'unrelated target change\n' >"$TMP/target-unrelated/code.txt"
git -C "$TMP/target-unrelated" add -A
git -C "$TMP/target-unrelated" commit -qm target-unrelated
git -C "$TMP/target-unrelated" switch -q main
check "a target head advanced without a fragment stays green" 0 \
"byte-for-byte" run target-unrelated base
# Fragments consumed, section never stamped: the prose went nowhere.
seed_flat halfdone
rm "$TMP/halfdone/changelog.d/12.md" "$TMP/halfdone/changelog.d/9.md"
printf '0.2.0\n' >"$TMP/halfdone/VERSION"
commit_head halfdone
check "fragments consumed but no section stamped fails" 1 \
"non-empty section for '0.2.0'" run halfdone base
# A release stamped out of a fragment-free directory: the replay refuses the
# way the real assembler would have — an empty release.
seed_flat empty-release
rm "$TMP/empty-release/changelog.d/12.md" "$TMP/empty-release/changelog.d/9.md"
git -C "$TMP/empty-release" add -A
git -C "$TMP/empty-release" commit -qm consume-early
git -C "$TMP/empty-release" branch -f base
cat >"$TMP/empty-release/CHANGELOG.md" <<'EOF'
# Changelog
Preamble prose belongs to no section.
## 0.2.0 — 2026-07-24
- Prose from nowhere.
## 0.1.0 — 2026-07-01
- The shipped entry.
EOF
printf '0.2.0\n' >"$TMP/empty-release/VERSION"
commit_head empty-release
check "a section stamped from zero fragments fails on the replay's refusal" 1 \
"zero fragments" run empty-release base
check "missing changelog fails" 1 "no such file" run faithful-flat base NOPE.md
# --- the trio interaction row (the issue's whole argument) -------------------
# On the faithful tree all three guards are green. On the dropped-entry tree
# monotonic is green (no heading was deleted) and, since #115's fragment
# mode, armed is red too — the surviving fragment is unconsumed on a bare
# tree — but only this guard names the entry the release LOST (#116 was
# written before #115 landed and expected armed green there; #117's flip
# recorded the interaction as it now stands). The hand-edited tree is where
# this guard stands alone: armed sees a publishable section, monotonic sees
# no deleted heading, and only the replay knows the prose is not what the
# authors wrote.
run_armed() { (cd "$TMP/$1" && bash "$ARMED"); }
run_monotonic() { local name="$1"; shift; (cd "$TMP/$name" && bash "$MONOTONIC" "$@"); }
check "trio, faithful tree: changelog-armed green" 0 "agrees" run_armed faithful-flat
check "trio, faithful tree: changelog-monotonic green" 0 "still present" \
run_monotonic faithful-flat base
check "trio, faithful tree: changelog-assembled green" 0 "byte-for-byte" \
run faithful-flat base
check "trio, dropped-entry tree: changelog-armed red too (unconsumed fragment)" 1 \
"not consumed" run_armed dropped
check "trio, dropped-entry tree: changelog-monotonic stays green" 0 "still present" \
run_monotonic dropped base
check "trio, dropped-entry tree: changelog-assembled names the lost entry" 1 \
"Nine landed" run dropped base
check "trio, hand-edited tree: changelog-armed green" 0 "agrees" run_armed edited
check "trio, hand-edited tree: changelog-monotonic stays green" 0 "still present" \
run_monotonic edited base
check "trio, hand-edited tree: changelog-assembled is the only red" 1 "allegedly" \
run edited base
# --- degradation: the loud skip and the STRICT refusal -----------------------
seed_flat no-base
ceremony no-base 0.2.0 2026-07-24
commit_head no-base
check "base ref missing, STRICT=0: loud skip, exit 0" 0 "SKIPPED" \
run no-base does-not-exist
check "base ref missing, STRICT=1: hard failure" 1 "FAILURE" \
run_strict no-base does-not-exist
check "the STRICT failure names the checkout fix, not the script" 1 "fetch-depth: 0" \
run_strict no-base does-not-exist
mkdir -p "$TMP/plain"
printf '# Changelog\n\n## Unreleased\n' >"$TMP/plain/CHANGELOG.md"
check "not a git repo, STRICT=0: loud skip" 0 "not inside a git work tree" \
run plain
check "not a git repo, STRICT=1: hard failure" 1 "FAILURE" run_strict plain
# --- the honest edges --------------------------------------------------------
# Push-to-main shape: the merge base IS HEAD, nothing was consumed between
# them, and the success line must say so instead of claiming a comparison.
seed_flat vacuous
ceremony vacuous 0.2.0 2026-07-24
commit_head vacuous
check "merge base IS HEAD: vacuous, named honestly" 0 "vacuous" run vacuous HEAD
# --- the action's wiring: inputs arrive as env vars --------------------------
# Non-default names for everything action.yml passes, STRICT included, prove
# the env vars are honored the way the composite sets them.
init_repo env-tree
mkdir -p "$TMP/env-tree/frags"
printf '# Changelog\n\n## 0.1.0 — 2026-07-01\n\n- Shipped.\n' >"$TMP/env-tree/NOTES.md"
printf '0.1.1-dev\n' >"$TMP/env-tree/VERSION"
printf -- '- Flagged entry (#2).\n' >"$TMP/env-tree/frags/2.md"
git -C "$TMP/env-tree" add -A
git -C "$TMP/env-tree" commit -qm base
git -C "$TMP/env-tree" branch fixture-base
(cd "$TMP/env-tree" && "$ASSEMBLE" 0.2.0 2026-07-24 --changelog NOTES.md --dir frags >/dev/null 2>&1)
printf '0.2.0\n' >"$TMP/env-tree/VERSION"
git -C "$TMP/env-tree" add -A
git -C "$TMP/env-tree" commit -qm head
env_tree() {
(cd "$TMP/env-tree" && \
CHANGELOG_ASSEMBLED_BASE=fixture-base CHANGELOG=NOTES.md \
CHANGELOG_ASSEMBLED_DIR=frags VERSION_SOURCE=file \
CHANGELOG_ASSEMBLED_STRICT=1 bash "$SCRIPT")
}
check "env vars drive the script the way action.yml does" 0 "byte-for-byte" env_tree
summary

View file

@ -57,11 +57,101 @@ check "date-less version heading parses" 0 "" assert_section 0.4.0 '- A date-les
check "empty stamped section returns empty output" 0 "" assert_section 0.5.0 "" check "empty stamped section returns empty output" 0 "" assert_section 0.5.0 ""
check "missing section returns empty output" 0 "" assert_section 9.9.9 "" check "missing section returns empty output" 0 "" assert_section 9.9.9 ""
PROBLEM_FIXTURE="$TMP/CHANGELOG.problems.md"
assert_problem() {
local version="$1" expected_status="$2" expected="$3"
check "predicate: $version / $expected" "$expected_status" "$expected" \
changelog_section_problem "$PROBLEM_FIXTURE" "$version"
}
cat >"$PROBLEM_FIXTURE" <<'EOF'
# Changelog
## Unreleased
### Added
### Changed
### Fixed
## 1.0.0
- Flat dash entry.
## 1.1.0
* Flat star entry.
## 1.2.0
### Fixed
- Fixed entry.
## 1.3.0
### Added
- Added entry.
### Changed
* Changed entry.
### Fixed
- Fixed entry.
## 1.4.0
## 1.5.0
### Added
## 1.6.0
### Added
### Fixed
- Fixed entry.
EOF
assert_problem Unreleased 0 ""
assert_problem 1.0.0 0 ""
assert_problem 1.1.0 0 ""
assert_problem 1.2.0 0 ""
assert_problem 1.3.0 0 ""
assert_problem 1.4.0 1 "section '1.4.0' has no entries — a heading is not an entry"
assert_problem 1.5.0 1 "section '1.5.0' has no entries — a heading is not an entry"
assert_problem 1.6.0 1 "section '1.6.0' has an empty heading: '### Added'"
assert_problem 9.9.9 1 "no section for '9.9.9'"
MISSING_UNRELEASED_FIXTURE="$TMP/CHANGELOG.missing-unreleased.md"
cat >"$MISSING_UNRELEASED_FIXTURE" <<'EOF'
# Changelog
## 1.0.0
- Released entry.
EOF
check "predicate: absent Unreleased still refuses" 1 "no section for 'Unreleased'" \
changelog_section_problem "$MISSING_UNRELEASED_FIXTURE" Unreleased
WRAPPER="$ROOT/bin/changelog-section" WRAPPER="$ROOT/bin/changelog-section"
check "wrapper publishes the requested body" 0 "The seven-oh entry" "$WRAPPER" 0.7.0 "$FIXTURE" check "wrapper publishes the requested body" 0 "The seven-oh entry" "$WRAPPER" 0.7.0 "$FIXTURE"
check "wrapper refuses an empty section" 1 "no section for '0.5.0'" "$WRAPPER" 0.5.0 "$FIXTURE" check "wrapper refuses an empty section" 1 "no section for '0.5.0'" "$WRAPPER" 0.5.0 "$FIXTURE"
check "wrapper refuses an absent section" 1 "no section for '9.9.9'" "$WRAPPER" 9.9.9 "$FIXTURE" check "wrapper refuses an absent section" 1 "no section for '9.9.9'" "$WRAPPER" 9.9.9 "$FIXTURE"
check "wrapper explains how the release PR fixes refusal" 1 "stamps the Unreleased section" "$WRAPPER" 9.9.9 "$FIXTURE" check "wrapper explains how the release PR fixes refusal" 1 "assembles the section" "$WRAPPER" 9.9.9 "$FIXTURE"
check "wrapper refuses a heading-only version section" 1 \
"section '1.5.0' has no entries — a heading is not an entry" \
"$WRAPPER" 1.5.0 "$PROBLEM_FIXTURE"
check "wrapper names the first dangling heading" 1 \
"section '1.6.0' has an empty heading: '### Added'" \
"$WRAPPER" 1.6.0 "$PROBLEM_FIXTURE"
check "wrapper prints seeded empty Unreleased without refusing" 0 "### Added" \
"$WRAPPER" Unreleased "$PROBLEM_FIXTURE"
check "wrapper requires a version" 2 "usage:" "$WRAPPER" check "wrapper requires a version" 2 "usage:" "$WRAPPER"
check "wrapper refuses a missing file" 1 "no such file" "$WRAPPER" 1.0.0 "$TMP/missing.md" check "wrapper refuses a missing file" 1 "no such file" "$WRAPPER" 1.0.0 "$TMP/missing.md"
@ -78,4 +168,575 @@ check "realistic changelog keeps its production heading shape" 0 "A mint records
check "realistic adjacent release remains independently extractable" 0 "Merging the release PR" \ check "realistic adjacent release remains independently extractable" 0 "Merging the release PR" \
"$WRAPPER" 0.8.0 "$REALISTIC" "$WRAPPER" 0.8.0 "$REALISTIC"
# --- the fragment reader (#114) ----------------------------------------------
FRAG="$TMP/frags"
mkdir -p "$FRAG"
check "fragments: absent directory is empty output, not an error" 0 "" \
changelog_fragments "$TMP/no-such-dir"
check "fragments: fragment-free directory is empty output, not an error" 0 "" \
changelog_fragments "$FRAG"
printf 'marker\n' >"$FRAG/README.md"
check "fragments: README.md is the directory marker, never a fragment" 0 "" \
changelog_fragments "$FRAG"
printf -- '- Two (#2).\n' >"$FRAG/2.md"
printf -- '- Nine (#9).\n' >"$FRAG/9.md"
printf -- '- Ten (#10).\n' >"$FRAG/10.md"
printf -- '- Cross (#14).\n' >"$FRAG/ceremony-14.md"
printf -- '- Local fourteen (#14).\n' >"$FRAG/14.md"
assert_fragments_order() {
local expected="$1" actual
actual="$(changelog_fragments "$FRAG" | awk -F/ '{ print $NF }' | tr '\n' ' ')"
actual="${actual% }"
[ "$actual" = "$expected" ] || {
printf 'wanted: %s\ngot: %s\n' "$expected" "$actual"
return 1
}
}
check "fragments: issue number descending (numeric, 10 before 9), filename tie-break" 0 "" \
assert_fragments_order "14.md ceremony-14.md 10.md 9.md 2.md"
# --- the fragment predicate (#114) -------------------------------------------
PF="$TMP/frag-problems"
mkdir -p "$PF"
printf -- '- Fine (#7).\n' >"$PF/7.md"
check "fragment predicate: a flat fragment passes" 0 "" \
changelog_fragment_problem "$PF/7.md"
cat >"$PF/8.md" <<'EOF'
### Added
- Grouped fine (#8).
EOF
check "fragment predicate: a grouped fragment passes" 0 "" \
changelog_fragment_problem "$PF/8.md"
printf -- '- Cross-repo (#14).\n' >"$PF/ceremony-14.md"
check "fragment predicate: a cross-repo name passes" 0 "" \
changelog_fragment_problem "$PF/ceremony-14.md"
printf -- '- Bad name.\n' >"$PF/Fix-12.md"
check "fragment predicate: an uppercase prefix is refused, file named" 1 "Fix-12.md" \
changelog_fragment_problem "$PF/Fix-12.md"
printf -- '- Bad name.\n' >"$PF/notes.txt"
check "fragment predicate: a non-.md file is refused, file named" 1 "notes.txt" \
changelog_fragment_problem "$PF/notes.txt"
printf -- '- Bad name.\n' >"$PF/12.markdown"
check "fragment predicate: .markdown is refused, file named" 1 "12.markdown" \
changelog_fragment_problem "$PF/12.markdown"
printf -- '- No number.\n' >"$PF/notes.md"
check "fragment predicate: a name with no trailing issue number is refused" 1 "notes.md" \
changelog_fragment_problem "$PF/notes.md"
cat >"$PF/20.md" <<'EOF'
## 1.0.0 — 2026-07-24
- Smuggled heading.
EOF
check "fragment predicate: a '## ' line is refused — the heading is the assembler's" 1 \
"the section heading is the assembler's to write" \
changelog_fragment_problem "$PF/20.md"
printf '### Added\n' >"$PF/21.md"
check "fragment predicate: no bullet anywhere is refused" 1 \
"has no entries — a heading is not an entry" \
changelog_fragment_problem "$PF/21.md"
cat >"$PF/22.md" <<'EOF'
### Added
### Fixed
- Fixed entry.
EOF
check "fragment predicate: a dangling grouped heading is refused, heading named" 1 \
"has an empty heading: '### Added'" \
changelog_fragment_problem "$PF/22.md"
# --- the entry length bound (#167) -------------------------------------------
# mkchars <n> — a run of n 'a's, for entries of exact constructed length.
mkchars() {
awk -v n="$1" 'BEGIN { s = ""; while (length(s) < n) s = s "a"; print s }'
}
printf -- '- %s\n' "$(mkchars 301)" >"$PF/30.md"
check "length bound: a 301-character entry is refused, fragment and length named" 1 \
"30.md' has a 301-character entry" \
changelog_fragment_problem "$PF/30.md"
check "length bound: the refusal names the bound and the split fix" 1 \
"the bound is 300: split it into multiple '- ' entries in this same fragment" \
changelog_fragment_problem "$PF/30.md"
printf -- '- %s (#31).\n' "$(mkchars 293)" >"$PF/31.md"
check "length bound: an entry of exactly 300 passes" 0 "" \
changelog_fragment_problem "$PF/31.md"
{
printf -- '- %s (#32).\n' "$(mkchars 143)"
printf -- '- %s (#32).\n' "$(mkchars 143)"
printf -- '- %s (#32).\n' "$(mkchars 143)"
} >"$PF/32.md"
check "length bound: several within-bound entries pass though the file totals over 300" 0 "" \
changelog_fragment_problem "$PF/32.md"
{
printf -- '- %s\n' "$(mkchars 50)"
printf ' %s\n' "$(mkchars 50)"
printf ' %s\n' "$(mkchars 50)"
printf ' %s\n' "$(mkchars 50)"
printf ' %s (#33).\n' "$(mkchars 50)"
} >"$PF/33.md"
check "length bound: a ~250-character entry wrapped over four continuation lines passes" 0 "" \
changelog_fragment_problem "$PF/33.md"
{
printf '### Added\n\n'
printf -- '- %s (#34).\n' "$(mkchars 293)"
} >"$PF/34.md"
check "length bound: a '### ' heading counts toward no entry — 300 under it still passes" 0 "" \
changelog_fragment_problem "$PF/34.md"
{
printf '### Added\n\n'
printf -- '- %s\n' "$(mkchars 301)"
} >"$PF/35.md"
check "length bound: a grouped bullet is bounded the same as a flat one" 1 \
"35.md' has a 301-character entry" \
changelog_fragment_problem "$PF/35.md"
{
printf -- '- %s\n' "$(mkchars 301)"
printf -- '- Short.\n'
} >"$PF/36.md"
assert_single_diagnosis() {
local count
count="$(changelog_fragment_problem "$PF/36.md" | grep -c "301-character")"
[ "$count" = 1 ] || {
printf 'wanted one diagnosis, got %s\n' "$count"
return 1
}
}
check "length bound: an over-bound entry mid-file is diagnosed exactly once" 0 "" \
assert_single_diagnosis
check "length bound: published sections stay unvalidated — 0.3.0's over-bound entries red nothing" 0 "" \
changelog_section_problem "$ROOT/CHANGELOG.md" 0.3.0
check "length bound: published sections stay unvalidated — 0.2.0 reds nothing either" 0 "" \
changelog_section_problem "$ROOT/CHANGELOG.md" 0.2.0
# --- the terminal issue cite (#262) ------------------------------------------
# cite_case <number> <entry-line...> — a fragment holding exactly the given
# lines, so a case reads as the entry it is about.
cite_case() {
local num="$1"
shift
printf '%s\n' "$@" >"$PF/$num.md"
}
cite_case 40 '- Local (#262).'
check "cite: the canonical '(#N).' passes" 0 "" \
changelog_fragment_problem "$PF/40.md"
cite_case 41 '- Sibling repo (crew#309).'
check "cite: a sibling-repo reference passes" 0 "" \
changelog_fragment_problem "$PF/41.md"
cite_case 42 '- Fully qualified (heavy-duty/crew#309).'
check "cite: an owner/repo reference passes" 0 "" \
changelog_fragment_problem "$PF/42.md"
cite_case 43 '- Two issues, one entry (#236, #250).'
check "cite: one group carrying two references passes" 0 "" \
changelog_fragment_problem "$PF/43.md"
# The cite is measured on the normalized entry, so a citation that lands on
# a continuation line still closes the entry — the #167 lesson, repeated:
# wrapping alone must never red a compliant entry.
cite_case 44 '- An entry whose prose wraps onto a' ' continuation line, cite and all (#262).'
check "cite: a citation on a continuation line passes — the entry is normalized first" 0 "" \
changelog_fragment_problem "$PF/44.md"
cite_case 45 '### Added' '' '- Added one (#101).' '- Added two (#102).' '' \
'### Changed' '' '- Changed one (#103).' '' '### Fixed' '' '- Fixed one (#104).'
check "cite: a grouped fragment, three headings, every entry compliant, passes" 0 "" \
changelog_fragment_problem "$PF/45.md"
# The two diagnoses are distinct by construction (D5): a builder who reads
# one must not be told the other's fix.
cite_case 50 '- No cite here.'
check "cite: an entry with no reference at all is refused" 1 \
"50.md' has an entry with no issue citation" \
changelog_fragment_problem "$PF/50.md"
check "cite: the uncited refusal names the shape to write" 1 \
"end it with the issue it comes from: '(#N).'" \
changelog_fragment_problem "$PF/50.md"
cite_case 51 '- Cite before the period. (#262)'
check "cite: a citation trailing the period is refused — the 248.md shape" 1 \
"51.md' has an entry whose issue citation is not terminal" \
changelog_fragment_problem "$PF/51.md"
check "cite: the misplaced refusal names the shape to write" 1 \
"exactly one '(#N)' group ends the entry, the final '.' after it" \
changelog_fragment_problem "$PF/51.md"
cite_case 52 '- Trailing prose (#262) and then more.'
check "cite: a citation with prose after it is refused" 1 \
"has an entry whose issue citation is not terminal" \
changelog_fragment_problem "$PF/52.md"
cite_case 53 '- Two groups (#236) and (#250).'
check "cite: two citation groups are refused — one terminal group, or none (D2)" 1 \
"has an entry whose issue citation is not terminal" \
changelog_fragment_problem "$PF/53.md"
cite_case 54 '- Bad token (#abc).'
check "cite: a reference with no digits is no reference" 1 \
"has an entry with no issue citation" \
changelog_fragment_problem "$PF/54.md"
cite_case 55 '- Bad token (#).'
check "cite: an empty reference is no reference" 1 \
"has an entry with no issue citation" \
changelog_fragment_problem "$PF/55.md"
# The citation need not name the file's own issue (D3): the filename already
# carries the authorizing one, so an entry may cite the incident beside it.
cite_case 56 '- Cites another issue entirely (#101).'
check "cite: the reference need not match the filename" 0 "" \
changelog_fragment_problem "$PF/56.md"
# Ordering: the bound outranks the cite across the whole fragment, so a
# fragment that reds today draws the diagnosis it drew before this rule
# existed. The uncited entry comes FIRST here on purpose — the other order
# would pass whatever the precedence is.
{
printf -- '- Uncited, and it comes first.\n'
printf -- '- %s\n' "$(mkchars 301)"
} >"$PF/57.md"
check "cite: an over-bound entry outranks an earlier uncited one" 1 \
"57.md' has a 301-character entry" \
changelog_fragment_problem "$PF/57.md"
assert_one_diagnosis() {
local count
count="$(changelog_fragment_problem "$PF/$1.md" | wc -l)"
[ "$count" = 1 ] || {
printf 'wanted one diagnosis, got %s\n' "$count"
return 1
}
}
check "cite: the outranked citation problem is not reported beside it" 0 "" \
assert_one_diagnosis 57
# The axis 57.md cannot test: its over-bound entry is LAST, so the only
# flush that can print is END's, which exits immediately. A flush from a
# main rule exits too — but awk runs END on the way out, so the citation
# row a mid-file length row outranks would print after it unless END is
# guarded. Both ways out of the walk, a bullet and a heading.
{
printf -- '- Uncited, and it comes first.\n'
printf -- '- %s\n' "$(mkchars 301)"
printf -- '- A later entry the walk never reaches (#57).\n'
} >"$PF/58.md"
check "cite: an over-bound entry that is not the last one still reports the bound" 1 \
"58.md' has a 301-character entry" \
changelog_fragment_problem "$PF/58.md"
check "cite: and it is still one diagnosis, not the protocol row spliced into it" 0 "" \
assert_one_diagnosis 58
{
printf '### Fixed\n'
printf -- '- Misplaced, and it comes first. (#59)\n'
printf -- '- %s\n' "$(mkchars 301)"
printf '### Changed\n'
printf -- '- The heading is the other way out of the walk (#59).\n'
} >"$PF/59.md"
check "cite: a heading after the over-bound entry is the same one diagnosis" 1 \
"59.md' has a 301-character entry" \
changelog_fragment_problem "$PF/59.md"
check "cite: the misplaced row does not ride along with it either" 0 "" \
assert_one_diagnosis 59
# Published sections keep their pre-rule prose (D4): reddening history is a
# wall, not a guard. Every shipped section predates the cite.
check "cite: a published section with uncited entries still reds nothing" 0 "" \
changelog_section_problem "$ROOT/CHANGELOG.md" 0.3.0
# The fragments this repo carries right now are the rule's own first
# constituency — the guard is worth nothing if the tree it ships in fails it.
assert_tree_fragments() {
local f
while IFS= read -r f; do
[ -n "$f" ] || continue
changelog_fragment_problem "$f" || return 1
done <<<"$(changelog_fragments "$ROOT/changelog.d")"
}
check "cite: every fragment in this tree passes the rule it ships" 0 "" \
assert_tree_fragments
# --- the assembler (#114) ----------------------------------------------------
assert_assemble() {
local dir="$1" expected="$2" actual
actual="$(changelog_assemble "$dir")"
[ "$actual" = "$expected" ] || {
printf 'wanted:\n%s\ngot:\n%s\n' "$expected" "$actual"
return 1
}
}
AF="$TMP/assemble-flat"
mkdir -p "$AF"
printf 'marker\n' >"$AF/README.md"
cat >"$AF/3.md" <<'EOF'
- Three — an em dash, and prose that
wraps onto a continuation line (#3).
EOF
printf -- '- Ten (#10).\n- Ten again (#10).\n' >"$AF/10.md"
check "assemble: flat fragments, newest issue first, prose verbatim" 0 "" \
assert_assemble "$AF" $'- Ten (#10).\n- Ten again (#10).\n- Three — an em dash, and prose that\n wraps onto a continuation line (#3).'
check "assemble: an empty directory is empty output — refusing is the caller's stance" 0 "" \
changelog_assemble "$TMP/no-such-dir"
AG="$TMP/assemble-grouped"
mkdir -p "$AG"
cat >"$AG/21.md" <<'EOF'
### Fixed
- Fixed twenty-one (#21).
EOF
cat >"$AG/20.md" <<'EOF'
### Added
- Added twenty (#20).
### Docs
- Docs twenty (#20).
EOF
cat >"$AG/19.md" <<'EOF'
### Security
- Security nineteen (#19).
### Added
- Added nineteen (#19).
EOF
check "assemble: canonical group order, unnamed group appended, fragment order inside a group" 0 "" \
assert_assemble "$AG" $'### Added\n\n- Added twenty (#20).\n- Added nineteen (#19).\n\n### Fixed\n\n- Fixed twenty-one (#21).\n\n### Security\n\n- Security nineteen (#19).\n\n### Docs\n\n- Docs twenty (#20).'
AM="$TMP/assemble-mixed"
mkdir -p "$AM"
printf -- '- Flat five (#5).\n' >"$AM/5.md"
cat >"$AM/6.md" <<'EOF'
### Added
- Grouped six (#6).
EOF
check "assemble: mixed shapes refused, grouped side named" 1 "6.md" \
changelog_assemble "$AM"
check "assemble: mixed shapes refused, flat side named too" 1 "5.md" \
changelog_assemble "$AM"
AX="$TMP/assemble-selfmixed"
mkdir -p "$AX"
cat >"$AX/7.md" <<'EOF'
- Ungrouped lead (#7).
### Added
- Grouped follow (#7).
EOF
check "assemble: one fragment mixing both shapes is refused, file named" 1 \
"'$AX/7.md' mixes grouped headings and ungrouped bullets" \
changelog_assemble "$AX"
# --- the fragment-set shape predicate (#159) ---------------------------------
SHAPE_CHANGELOG="$TMP/CHANGELOG.shape.md"
SHAPE_DIR="$TMP/shape-fragments"
mkdir -p "$SHAPE_DIR"
cat >"$SHAPE_CHANGELOG" <<'EOF'
# Changelog
## 2.0.0 — 2026-07-24
- Newest section is flat.
## 1.0.0 — 2026-07-01
### Fixed
- Older section is grouped.
EOF
printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md"
check "shape: flat set matches newest flat published section" 0 "" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed
- Grouped fragment (#1).
EOF
check "shape: grouped set names its conflict with newest flat published section" 1 \
"fragment '$SHAPE_DIR/1.md' is grouped but newest published section '2.0.0' in '$SHAPE_CHANGELOG' is flat" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
cat >"$SHAPE_CHANGELOG" <<'EOF'
# Changelog
## 2.0.0 — 2026-07-24
### Fixed
- Newest section is grouped.
EOF
printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md"
check "shape: flat set names its conflict with newest grouped published section" 1 \
"fragment '$SHAPE_DIR/1.md' is flat but newest published section '2.0.0' in '$SHAPE_CHANGELOG' is grouped" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed
- Grouped fragment (#1).
EOF
check "shape: grouped set matches newest grouped published section" 0 "" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
check "shape: consistent set with no published section passes" 0 "" \
changelog_shape_problem "$TMP/no-such-changelog" "$SHAPE_DIR"
rm "$SHAPE_DIR/1.md"
check "shape: empty fragment set makes the anchor rule vacuous" 0 "" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
# --- the declarable anchor: <dir>/shape (#182) -------------------------------
# The sentinel pins the set's shape and outranks the newest-published-section
# inference — the door a deliberate flip walks through, while the undeclared
# drift rows above stay red, verbatim.
cat >"$SHAPE_CHANGELOG" <<'EOF'
# Changelog
## 2.0.0 — 2026-07-24
- Newest section is flat.
EOF
cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed
- Grouped fragment (#1).
EOF
printf 'grouped\n' >"$SHAPE_DIR/shape"
check "shape: 'grouped' sentinel admits a grouped set over a flat published section" 0 "" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
check "shape: the sentinel binds with no changelog at all — the assembler's call" 0 "" \
changelog_shape_problem "" "$SHAPE_DIR"
printf -- '- Flat fragment (#1).\n' >"$SHAPE_DIR/1.md"
check "shape: flat fragment under a 'grouped' sentinel refused, fragment and sentinel named" 1 \
"fragment '$SHAPE_DIR/1.md' is flat but '$SHAPE_DIR/shape' declares grouped" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
cat >"$SHAPE_CHANGELOG" <<'EOF'
# Changelog
## 2.0.0 — 2026-07-24
### Fixed
- Newest section is grouped.
EOF
printf 'flat\n' >"$SHAPE_DIR/shape"
check "shape: 'flat' sentinel admits a flat set over a grouped published section" 0 "" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed
- Grouped fragment (#1).
EOF
check "shape: grouped fragment under a 'flat' sentinel refused, fragment and sentinel named" 1 \
"fragment '$SHAPE_DIR/1.md' is grouped but '$SHAPE_DIR/shape' declares flat" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
printf 'grouped\n' >"$SHAPE_DIR/shape"
printf -- '- Flat two (#2).\n' >"$SHAPE_DIR/2.md"
check "shape: a mixed set is refused regardless of the sentinel" 1 \
"fragment '$SHAPE_DIR/1.md' is grouped but fragment '$SHAPE_DIR/2.md' is not" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
rm "$SHAPE_DIR/1.md" "$SHAPE_DIR/2.md"
check "shape: empty fragment set with a valid sentinel passes" 0 "" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
printf 'Grouped\n' >"$SHAPE_DIR/shape"
check "shape: a capitalized sentinel is refused, file named" 1 \
"'$SHAPE_DIR/shape' declares neither shape" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
: >"$SHAPE_DIR/shape"
check "shape: an empty sentinel is refused — never a silent fallback" 1 \
"'$SHAPE_DIR/shape' declares neither shape" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
printf 'grouped\nflat\n' >"$SHAPE_DIR/shape"
check "shape: a two-line sentinel is refused" 1 \
"'$SHAPE_DIR/shape' declares neither shape" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
# Trailing blank lines are the case command substitution launders away: the
# captured word is a clean 'grouped', only the file's line count still knows.
printf 'grouped\n\n' >"$SHAPE_DIR/shape"
check "shape: 'grouped' with a trailing blank line is refused, file named" 1 \
"'$SHAPE_DIR/shape' declares neither shape" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
printf 'flat\n\n' >"$SHAPE_DIR/shape"
check "shape: 'flat' with a trailing blank line is refused, file named" 1 \
"'$SHAPE_DIR/shape' declares neither shape" \
changelog_shape_problem "$SHAPE_CHANGELOG" "$SHAPE_DIR"
# The sentinel is not a fragment (#182 D3): the *.md glob is the mechanism,
# but the assertion is on the list itself, so a glob change cannot silently
# start publishing the sentinel.
printf 'grouped\n' >"$SHAPE_DIR/shape"
cat >"$SHAPE_DIR/1.md" <<'EOF'
### Fixed
- Grouped fragment (#1).
EOF
assert_fragments_exclude_sentinel() {
local out
out="$(changelog_fragments "$SHAPE_DIR")"
[ -n "$out" ] || { echo "wanted a non-empty fragment list"; return 1; }
if printf '%s\n' "$out" | grep -q '/shape$'; then
printf 'the sentinel leaked into the fragment list:\n%s\n' "$out"
return 1
fi
}
check "fragments: the shape sentinel never enters the fragment list" 0 "" \
assert_fragments_exclude_sentinel
rm "$SHAPE_DIR/1.md" "$SHAPE_DIR/shape"
AS="$TMP/assemble-sentinel"
mkdir -p "$AS"
printf 'grouped\n' >"$AS/shape"
cat >"$AS/30.md" <<'EOF'
### Fixed
- Fixed thirty (#30).
EOF
cat >"$AS/31.md" <<'EOF'
### Added
- Added thirty-one (#31).
EOF
check "assemble: the sentinel never assembles, and canonical order holds under it" 0 "" \
assert_assemble "$AS" $'### Added\n\n- Added thirty-one (#31).\n\n### Fixed\n\n- Fixed thirty (#30).'
rm "$AS/30.md" "$AS/31.md"
printf -- '- Flat probe (#29).\n' >"$AS/29.md"
check "assemble: a flat set under a 'grouped' sentinel refuses to assemble" 1 \
"declares grouped" \
changelog_assemble "$AS"
summary summary

View file

@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Contract tests for lib/closes_references.sh (issue #188, term 3).
# set -u, not -e: failing commands are behavior for the harness to inspect.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
# issue_references (the LOCAL/CROSS classifier) lives here; closes_references
# calls it, exactly as refs_references does.
# shellcheck source=actions/issueflow-reconcile/issueflow-reconcile.sh
. "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
# shellcheck source=lib/closes_references.sh
. "$ROOT/lib/closes_references.sh"
# closes <want-newline-separated> <body> — the parse of <body> is exactly
# <want>. Exact, not substring: "12" is contained in "123".
closes() {
local want="$1" body="$2" got
got="$(printf '%s' "$body" | closes_references)"
[ "$got" = "$want" ]
}
# --- the three verbs, the three tenses ----------------------------------
# GitHub's documented keyword set. All of them, because a body that says
# "Fixed #4" and goes unclosed is a silent failure of the post-merge
# transition, not a loud one.
check "closes" 0 "" closes 1 'Closes #1'
check "close" 0 "" closes 1 'Close #1'
check "closed" 0 "" closes 1 'Closed #1'
check "fixes" 0 "" closes 2 'Fixes #2'
check "fix" 0 "" closes 2 'Fix #2'
check "fixed" 0 "" closes 2 'Fixed #2'
check "resolves" 0 "" closes 3 'Resolves #3'
check "resolve" 0 "" closes 3 'Resolve #3'
check "resolved" 0 "" closes 3 'Resolved #3'
check "case-insensitive" 0 "" closes 4 'CLOSES #4'
check "lowercase" 0 "" closes 4 'closes #4'
check "colon form" 0 "" closes 5 'Closes: #5'
# --- Refs is NOT a closing link -----------------------------------------
# The relation this file must not swallow. refs_references owns Refs, and
# conflating them makes every referenced issue look closeable — the
# post-merge transition #151 was reopened by hand over exactly that
# distinction.
check "Refs is not a close" 0 "" closes '' 'Refs #7'
check "Refs and Closes in one body keeps only the close" 0 "" \
closes 8 $'Refs #7\nCloses #8'
# --- cross-repo references stay out (#61) -------------------------------
# rig#112 must never be read as local #112. The classifier is shared with
# refs_references precisely so this rule has one implementation.
check "qualified reference is not local" 0 "" closes '' 'Closes rig#112'
check "owner-qualified reference is not local" 0 "" \
closes '' 'Closes heavy-duty/rig#112'
check "a local and a cross reference keep only the local" 0 "" \
closes 9 $'Closes rig#112\nCloses #9'
# --- every occurrence contributes ---------------------------------------
# Binding to the first occurrence is the defect #184 fixed in
# blocked_reference_records; this parser must not reintroduce it.
check "two closes on one line" 0 "" closes $'1\n2' 'Closes #1. Closes #2.'
check "two closes on two lines" 0 "" closes $'1\n2' $'Closes #1\nCloses #2'
check "sorted and deduplicated" 0 "" closes $'2\n10' $'Closes #10\nCloses #2\nCloses #10'
# --- prose must not be swallowed ----------------------------------------
check "trailing prose is not part of the reference" 0 "" \
closes 12 'Closes #12, and adds the guard'
check "a sentence terminator ends the reference" 0 "" closes 13 'Closes #13.'
check "no reference means no output" 0 "" closes '' 'Closes the door behind it'
check "a bare issue mention is not a close" 0 "" closes '' 'See #14 for context'
# "unclosed" contains "close" — a naive word match would fire on it.
check "a word merely containing a verb does not fire" 0 "" \
closes '' 'This left #15 unclosed'
# --- the shapes a real PR body carries ----------------------------------
check "the template's leading declaration" 0 "" \
closes 188 $'Closes #188\n\n## Acceptance criteria\n\n- [ ] a thing'
check "an empty body yields nothing" 0 "" closes '' ''
summary

View file

@ -1,8 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Contract tests for actions/docs-sync (issue #19). Constructed SOURCE trees # Contract tests for actions/docs-sync (issue #19). Constructed SOURCE trees
# (a fake ceremony: manifest + docs) and CONSUMER trees (a release.yml # (a fake ceremony: manifest + docs) and CONSUMER trees (a release.yml
# caller with the pin line), driven offline via --source — the fetch path # caller with the pin line), driven offline via --source. The fetch path is
# needs the network and is exercised by consumers, not here. The fake # driven too, against a PATH-stubbed curl rather than the network (#201) —
# which forge a pin resolves against is a decision, not plumbing. The fake
# source's doc set is deliberately NOT the real five: a script that # source's doc set is deliberately NOT the real five: a script that
# hardcodes the vendored list instead of reading the manifest fails these # hardcodes the vendored list instead of reading the manifest fails these
# rows. set -u, not -e: failing commands are behavior for the harness to # rows. set -u, not -e: failing commands are behavior for the harness to
@ -18,6 +19,14 @@ SCRIPT="$ROOT/actions/docs-sync/docs-sync.sh"
TMP="$(mktemp -d)" TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT trap 'rm -rf "$TMP"' EXIT
# The real manifest is asserted by test/vendored.test.sh, not here (#251 D4).
# A `grep -Fx RELEASES.md` row lived at this spot from #248's review round,
# binding the promise to the one file that had nearly been missed. It was the
# hardcoded list the manifest exists to abolish, one layer down: two spellings
# of "the manifest is right" is exactly the drift it prevents. Its intent —
# every root doctrine file is declared, RELEASES.md included — is now a
# closed-world guard case, which the next file inherits for free.
# --- fixture builders -------------------------------------------------------- # --- fixture builders --------------------------------------------------------
# The main fake ceremony tree: three manifest entries, one in a subdirectory # The main fake ceremony tree: three manifest entries, one in a subdirectory
@ -291,4 +300,80 @@ check "unknown flag refused" 1 "unknown argument" \
check "--source without a directory refused" 1 "no such directory" \ check "--source without a directory refused" 1 "no such directory" \
in_consumer env-wired --check --source "$TMP/does-not-exist" in_consumer env-wired --check --source "$TMP/does-not-exist"
# --- the fetch path: which forge, and never a guessed one (#201) ---------------
# The fetch path had no coverage at all before this: every row above passes
# --source, which overrides the fetch entirely, so the URL the tool actually
# builds was asserted nowhere. It is asserted here with a PATH-stubbed curl
# that records the URL and serves a tarball of the fake source tree — no
# network, and the real tar pipeline still runs, so --strip-components stays
# honest. CURL_FAIL makes the stub fail the way a missing ref does.
FETCHBIN="$TMP/fetchbin"
mkdir -p "$FETCHBIN"
cat >"$FETCHBIN/curl" <<'STUB'
#!/usr/bin/env bash
printf '%s\n' "${!#}" >>"$CURL_URL_LOG"
[ -z "${CURL_FAIL:-}" ] || exit 22
exec tar -cz -C "$(dirname "$CURL_SRC")" "$(basename "$CURL_SRC")"
STUB
chmod +x "$FETCHBIN/curl"
export CURL_URL_LOG="$TMP/curl-urls" CURL_SRC="$SRC"
consumer fetched 0.4.1
# fetch_sync <server-url> <args...> — the fetch path, no --source. Truncates
# the URL log first so requested_url always answers about this run.
fetch_sync() {
local server="$1"
shift
: >"$CURL_URL_LOG"
(cd "$TMP/fetched" && PATH="$FETCHBIN:$PATH" GITHUB_SERVER_URL="$server" \
bash "$SCRIPT" "$@")
}
requested_url() { cat "$CURL_URL_LOG"; }
check "the fetch mirrors the pin fetched from the forge in the environment" 0 \
"added .ceremony/RULES.md" fetch_sync https://forgejo.example.test --fix
check "...and the URL asked for names that forge, not a built-in one" 0 \
"https://forgejo.example.test/heavy-duty/ceremony/archive/0.4.1.tar.gz" \
requested_url
# One pin ref, two forges, two trees — the whole reason #201 exists. The same
# consumer must fetch from whichever forge it is running on.
fetch_sync https://github.com --fix >/dev/null 2>&1
check "the same pin on another forge fetches from that forge instead" 0 \
"https://github.com/heavy-duty/ceremony/archive/0.4.1.tar.gz" requested_url
fetch_sync https://forgejo.example.test/ --fix >/dev/null 2>&1
check "a trailing slash on the server URL does not double the separator" 0 \
"https://forgejo.example.test/heavy-duty/ceremony/archive/0.4.1.tar.gz" \
requested_url
# Unset is not github.com. A tool that never guesses a ref must not guess a
# forge either — and it must refuse BEFORE reaching for the network.
no_server_sync() {
: >"$CURL_URL_LOG"
(cd "$TMP/fetched" && PATH="$FETCHBIN:$PATH" \
env -u GITHUB_SERVER_URL bash "$SCRIPT" --check)
}
check "no GITHUB_SERVER_URL and no --source → refuse, naming the variable" 1 \
"GITHUB_SERVER_URL is unset" no_server_sync
check "...and the refusal says it never guesses a forge" 1 \
"never guesses a forge" no_server_sync
nothing_fetched() { [ ! -s "$CURL_URL_LOG" ]; }
check "...and nothing was fetched before refusing" 0 "" nothing_fetched
# A ref that does not resolve on the forge in play: the message must name the
# URL actually attempted, so "does the pinned ref exist" is answerable.
fetch_fail() {
: >"$CURL_URL_LOG"
(cd "$TMP/fetched" && PATH="$FETCHBIN:$PATH" CURL_FAIL=1 \
GITHUB_SERVER_URL=https://forgejo.example.test bash "$SCRIPT" --check)
}
check "a failed fetch names the URL it tried" 1 \
"https://forgejo.example.test/heavy-duty/ceremony/archive/0.4.1.tar.gz" fetch_fail
check "...and asks about the ref on that forge, not in the abstract" 1 \
"exist on that forge" fetch_fail
summary summary

View file

@ -10,6 +10,12 @@ set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh # shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh" . "$ROOT/test/harness.sh"
# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those
# stubs already intercept (#188). Without this the verbs are simply undefined.
# shellcheck source=lib/forge.sh
. "$ROOT/lib/forge.sh"
forge_select github
FACTS="$ROOT/lib/facts.sh" FACTS="$ROOT/lib/facts.sh"
@ -23,20 +29,39 @@ ZEROS="0000000000000000000000000000000000000000"
mkdir -p "$TMP/stub" mkdir -p "$TMP/stub"
cat >"$TMP/stub/gh" <<'EOF' cat >"$TMP/stub/gh" <<'EOF'
#!/usr/bin/env bash #!/usr/bin/env bash
# Every mode below answers the call shape the shim now makes (#191):
# labeled -> gh api repos/{r}/commits/{sha}/pulls (a JSON ARRAY)
# released -> gh api repos/{r}/releases/tags/{tag}
# The *-unreadable modes are the ones that matter: they fail the way a real
# client fails when it cannot reach the forge, and must NOT be reported as a
# definite answer.
case "${GH_STUB:-none}" in case "${GH_STUB:-none}" in
labeled-yes | labeled-no) labeled-yes)
if [ "$1" != api ]; then echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"release"}]}]'
echo "gh stub: expected an api call, got: gh $*" >&2
exit 97
fi
[ "${GH_STUB}" = labeled-yes ] && echo true || echo false
;; ;;
released-yes | released-no) labeled-no)
if [ "$1" != release ]; then echo '[{"merged_at":"2026-01-01T00:00:00Z","labels":[{"name":"enhancement"}]}]'
echo "gh stub: expected a release call, got: gh $*" >&2 ;;
exit 97 labeled-none)
fi # A completed read that found no PR at all — still an answer.
[ "${GH_STUB}" = released-yes ] && exit 0 || exit 1 echo '[]'
;;
labeled-unmerged)
# A PR carrying the label but never merged: the label alone is not a
# ceremony (the `merged_at != null` half of the contract).
echo '[{"merged_at":null,"labels":[{"name":"release"}]}]'
;;
labeled-unreadable | released-unreadable)
echo "gh: Connection refused (HTTP 000)" >&2
exit 1
;;
released-yes)
echo "$2" | grep -q 'releases/tags/' || { echo "gh stub: expected a releases/tags read, got: gh $*" >&2; exit 97; }
echo "0.0.0"
;;
released-no)
echo "gh: Not Found (HTTP 404)" >&2
exit 1
;; ;;
*) *)
echo "gh stub: gh must not be called in this state (gh $*)" >&2 echo "gh stub: gh must not be called in this state (gh $*)" >&2
@ -68,6 +93,7 @@ facts_in() {
shift shift
(cd "$TMP/$dir" \ (cd "$TMP/$dir" \
&& env PATH="$TMP/stub:$PATH" GITHUB_REPOSITORY=fixture/fixture GH_TOKEN=stub \ && env PATH="$TMP/stub:$PATH" GITHUB_REPOSITORY=fixture/fixture GH_TOKEN=stub \
CEREMONY_FORGE=github \
"$@" bash "$FACTS") "$@" bash "$FACTS")
} }
@ -146,6 +172,42 @@ adoptb_head="$(commit adoption-bare VERSION 0.1.0)"
check "absent-at-base with a bare head still asks for the label" 0 "labeled=yes" \ check "absent-at-base with a bare head still asks for the label" 0 "labeled=yes" \
facts_in adoption-bare VERSION_SOURCE=file MERGE_SHA="$adoptb_head" EVENT_BEFORE="$adoptb_base" GH_STUB=labeled-yes facts_in adoption-bare VERSION_SOURCE=file MERGE_SHA="$adoptb_head" EVENT_BEFORE="$adoptb_base" GH_STUB=labeled-yes
# --- a root commit: no base tree at all (the repository's first push) --------
# The first push to main IS a branch-create push (event.before all-zeros)
# whose head has no first parent — there is no base, and the honest fact is
# "(none)", not an exit-128 death at rev-parse. Both 0.2.0 drills hit the
# death independently (#134). The -dev row consults no API (stub default),
# which also asserts the no-base path runs no base fetch/show at all.
repo greenfield
green_head="$(commit greenfield VERSION 0.1.0-dev)"
check "root commit, all-zeros event.before: base_ver=(none)" 0 "base_ver=(none)" \
facts_in greenfield VERSION_SOURCE=file MERGE_SHA="$green_head" EVENT_BEFORE="$ZEROS"
check "root commit, empty event.before: base_ver=(none)" 0 "base_ver=(none)" \
facts_in greenfield VERSION_SOURCE=file MERGE_SHA="$green_head" EVENT_BEFORE=
repo greenfield-bare
greenb_head="$(commit greenfield-bare VERSION 0.1.0)"
check "bare root commit still establishes labeled, so decide can refuse" 0 "labeled=no" \
facts_in greenfield-bare VERSION_SOURCE=file MERGE_SHA="$greenb_head" EVENT_BEFORE="$ZEROS" GH_STUB=labeled-no
# The D2 pin: "no first parent" is detected, never inferred from a failed
# command — an unresolvable MERGE_SHA is a loud death, not "(none)". A
# `|| true` around the fallback would pass every case above and fail here.
BAD_SHA="1111111111111111111111111111111111111111"
check "an unresolvable MERGE_SHA still dies loudly" 128 "bad object" \
facts_in greenfield VERSION_SOURCE=file MERGE_SHA="$BAD_SHA" EVENT_BEFORE="$ZEROS"
bad_out="$(facts_in greenfield VERSION_SOURCE=file MERGE_SHA="$BAD_SHA" EVENT_BEFORE="$ZEROS" 2>&1)"
if printf '%s' "$bad_out" | grep -qF "base_ver=(none)"; then
echo "FAIL: an unresolvable MERGE_SHA must not be reported as base_ver=(none)"
printf '%s\n' "$bad_out" | sed 's/^/ /'
FAIL=$((FAIL + 1))
else
echo "ok: an unresolvable MERGE_SHA is not reported as base_ver=(none)"
PASS=$((PASS + 1))
fi
# --- the package-json backend ------------------------------------------------ # --- the package-json backend ------------------------------------------------
repo pkg repo pkg
@ -172,4 +234,37 @@ nv_head="$(commit no-version README.md "with no version at the head either")"
check "no version at the head fails loudly" 1 "no such file" \ check "no version at the head fails loudly" 1 "no such file" \
facts_in no-version VERSION_SOURCE=file MERGE_SHA="$nv_head" EVENT_BEFORE="$nv_base" facts_in no-version VERSION_SOURCE=file MERGE_SHA="$nv_head" EVENT_BEFORE="$nv_base"
# --- #191: a read that did not complete is not an answer ------------------
# The bug this suite missed before: lib/facts.sh turned ANY failure of the
# label read into `labeled=no`, and decide's row 5 then refused a correctly
# labeled, correctly merged ceremony PR as "a bare push". On a Forgejo
# runner — no `gh` on the image — that was every release. Measured twice in
# the 0.4.1 drill (drills/0.4.1.md) before it was fixed.
#
# The contract now: a COMPLETED read that finds nothing is still `no` and
# still fail-closed; a read that could not complete refuses, loudly, and
# emits no fact at all.
check "a completed read with no PR behind the commit is labeled=no" 0 "labeled=no" \
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-none
check "a labeled but UNMERGED PR is labeled=no" 0 "labeled=no" \
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-unmerged
check "an unreadable label read refuses instead of saying no" 1 "refusing rather than reporting 'no label'" \
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-unreadable
# ...and emits no fact: a refusal that still printed labeled=no would be the
# same bug wearing a diagnostic.
check "the refusal emits no labeled fact at all" 1 "" \
facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-unreadable
if facts_in ceremony VERSION_SOURCE=file MERGE_SHA="$head_sha" EVENT_BEFORE="$base_sha" GH_STUB=labeled-unreadable 2>/dev/null | grep -q '^labeled='; then
echo "FAIL: the refusal printed a labeled= line" >&2
FAIL=$((FAIL + 1))
else
echo "ok: no labeled= line survives the refusal"
PASS=$((PASS + 1))
fi
check "an unreadable release read refuses instead of saying no" 1 "refusing rather than reporting 'no'" \
facts_in window VERSION_SOURCE=file MERGE_SHA="$win_head" EVENT_BEFORE="$win_base" GH_STUB=released-unreadable
summary summary

1302
test/forge-backends.test.sh Normal file

File diff suppressed because it is too large Load diff

175
test/forge.test.sh Normal file
View file

@ -0,0 +1,175 @@
#!/usr/bin/env bash
# Contract tests for lib/forge.sh (issue #188). set -u, not -e: failing
# commands are behavior for the harness to inspect.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# A PATH with the shell and the text tools lib/forge.sh itself uses, but with
# NO forge clients on it — that is the condition under test. It cannot be a
# genuinely empty directory: `env -i PATH=…` would then fail to find `bash`,
# and the heredoc refusals use `cat`.
mkdir -p "$TMP/empty"
for _t in bash cat sed awk tr printf; do
_p="$(command -v "$_t" 2>/dev/null)" && ln -sf "$_p" "$TMP/empty/$_t"
done
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
# shellcheck source=lib/forge.sh
. "$ROOT/lib/forge.sh"
# eq <want> <cmd...> — succeeds AND prints exactly <want>. check()'s
# substring match cannot prove "forgejo" was not printed as "forgejox".
eq() {
local want="$1" got
shift
got="$("$@")" || return 1
[ "$got" = "$want" ]
}
# detect_in <env-assignments…> — run forge_detect in a clean environment
# carrying only the named vars, so a leaked GITHUB_* from the CI running
# THIS suite cannot decide the answer. Every case below is hermetic.
detect_in() {
env -i PATH="$PATH" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_detect'
}
# A PATH carrying stub binaries for every client the preflight can require.
# Without this the "passes" cases depend on whatever the HOST happens to have,
# which is not hermetic and is wrong in the only place it matters: the Forgejo
# runner image (ghcr.io/catthehacker/ubuntu:act-22.04) has **no gh**, so
# "github + gh passes" failed there while passing on a developer box. Measured
# 2026-08-02 (#188) — the same class of hosted-image assumption this issue
# exists to find.
STUBBIN="$TMP/bin"
mkdir -p "$STUBBIN"
for _b in gh curl jq; do printf '#!/bin/sh\nexit 0\n' >"$STUBBIN/$_b"; chmod +x "$STUBBIN/$_b"; done
preflight_in() {
env -i PATH="$STUBBIN:$PATH" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_preflight'
}
# ...and one with NO clients at all, for the missing-binary refusal.
preflight_bare() {
env -i PATH="$TMP/empty" "$@" bash -c '. '"$ROOT"'/lib/forge.sh; forge_preflight'
}
# --- forge_detect: the explicit override --------------------------------
# CEREMONY_FORGE outranks every probe. It is the escape hatch for a forge
# whose env this file has not met yet, and the handle the tests below use
# to drive the backends without a live instance.
check "override: github" 0 "" eq github detect_in CEREMONY_FORGE=github
check "override: forgejo" 0 "" eq forgejo detect_in CEREMONY_FORGE=forgejo
check "override refuses an unknown forge" 1 "unknown forge" \
detect_in CEREMONY_FORGE=gitlab
# A typo must not silently fall through to a probe that guesses right by
# accident: the operator said something, and it was wrong.
check "override outranks the env" 1 "unknown forge" \
detect_in CEREMONY_FORGE=gitlab GITHUB_API_URL=https://api.github.com
# --- forge_detect: GITHUB_API_URL, the load-bearing signal ---------------
# Measured on forgejo.heavyduty.builders 2026-08-02 with a real
# forgejo-runner v6.3.1 job (probe run, task 278). The Forgejo runner
# populates the GITHUB_* namespace — GITHUB_ACTIONS=true and all — so
# "GITHUB_ACTIONS is set" proves nothing at all. What differs is where
# those URLs point:
#
# GitHub GITHUB_API_URL=https://api.github.com
# Forgejo GITHUB_API_URL=https://forgejo.heavyduty.builders/api/v1
#
# That is the whole bug this issue exists for, in one variable: gh speaks
# /api/v3 against api.github.com, and neither half is true here.
check "api url: api.github.com is github" 0 "" \
eq github detect_in GITHUB_API_URL=https://api.github.com
check "api url: /api/v1 is forgejo" 0 "" \
eq forgejo detect_in GITHUB_API_URL=https://forgejo.heavyduty.builders/api/v1
# GitHub Enterprise Server: a self-hosted GitHub still speaks /api/v3, and
# it is a github backend on a non-github.com host. Getting this wrong would
# route a GHES consumer to the forgejo backend and break term 5.
check "api url: GHES /api/v3 is github" 0 "" \
eq github detect_in GITHUB_API_URL=https://ghe.example.com/api/v3
# --- forge_detect: GITEA_ACTIONS, the positive marker --------------------
# The Forgejo runner also exports GITEA_ACTIONS=true (measured, task 278),
# which GitHub never sets. It is checked BEFORE the URL shape because it is
# unambiguous where a hand-set GITHUB_API_URL might not be.
check "gitea marker alone is enough" 0 "" eq forgejo detect_in GITEA_ACTIONS=true
check "gitea marker outranks a github-shaped api url" 0 "" \
eq forgejo detect_in GITEA_ACTIONS=true GITHUB_API_URL=https://api.github.com
# --- forge_detect: refusing to guess ------------------------------------
# Nothing to read is NOT "probably github". A wrong guess here is exactly
# the silent blind sweep #188 measured; the whole point of this file is
# that an unknown forge is loud.
check "bare environment refuses" 1 "cannot determine which forge" detect_in
check "refusal names what it looked at" 1 "GITHUB_API_URL" detect_in
check "refusal names the escape hatch" 1 "CEREMONY_FORGE" detect_in
# --- forge_preflight: the must-fail case --------------------------------
# The Test plan's named must-fail: "point it at a Forgejo instance with a
# GitHub-shaped client and assert it refuses loudly rather than sweeping
# blind."
#
# Measured before this guard existed, against this instance:
# labels-scope exit 0 "no .github/labeler.yml — nothing to derive" (it exists)
# labels-reconcile exit 0 "reconciled." (zero PRs read)
# issueflow-reconcile exit 1 "unexpected end of JSON input"
# Two of three swept blind and reported success. gh present made it WORSE:
# it silenced the one loud failure. Hence: refuse before the sweep, not
# after — and say which forge and which client, so the log answers "why"
# without a second run (#101 D5's report-do-not-diagnose, one layer up).
check "forgejo + gh-only client refuses" 1 "cannot speak" \
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
check "the refusal names the forge" 1 "forgejo" \
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
# The interpolated client, not the bare string "gh" — which also appears in
# the explanatory prose ("gh speaks GitHub's /api/v3…"), so the old assertion
# stayed green even if the client name never reached the message. Same class
# as the "names both totals" weakness the panel caught in the backend suite
# (#4727 / #4734); found by auditing this file for the same shape.
check "the refusal names the client" 1 "the 'gh' client cannot speak it" \
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
# The refusal must be actionable, not merely loud: #188's whole cost was a
# red check that told nobody what to do.
check "the refusal names the issue" 1 "#188" \
preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=gh
# --- forge_preflight: the passing pairs ---------------------------------
check "github + gh passes" 0 "" preflight_in CEREMONY_FORGE=github CEREMONY_FORGE_CLIENT=gh
check "forgejo + rest passes" 0 "" preflight_in CEREMONY_FORGE=forgejo CEREMONY_FORGE_CLIENT=rest
# The mirror of the must-fail: a Forgejo client against GitHub is just as
# wrong, and symmetric refusal is cheaper than explaining why only one
# direction is checked.
check "github + rest refuses" 1 "cannot speak" \
preflight_in CEREMONY_FORGE=github CEREMONY_FORGE_CLIENT=rest
# --- forge_preflight: it refuses when the forge itself is unknown --------
# Detection failure must not be swallowed into a pass — that would restore
# the blind sweep through the back door.
check "unknown forge fails the preflight" 1 "cannot determine which forge" preflight_in
# --- forge_client: what each backend actually needs ----------------------
# Measured in the runner image the Forgejo instance actually uses
# (ghcr.io/catthehacker/ubuntu:act-22.04, task 278): gh ABSENT, stoke
# ABSENT, curl and jq present. So the forgejo backend is REST-over-curl by
# necessity, not preference — this is the measurement that retired option
# A (port to stoke) as well: the CLI is not on the runner either.
check "github backend wants gh" 0 "" eq gh forge_client github
check "forgejo backend wants rest" 0 "" eq rest forge_client forgejo
check "forge_client refuses an unknown backend" 1 "unknown forge" forge_client gitlab
# The missing-binary arm, hermetically: an empty PATH has no client at all.
check "a forge whose client is not installed refuses" 1 "is not installed" \
preflight_bare CEREMONY_FORGE=github
check "...and names the missing binary" 1 "gh" preflight_bare CEREMONY_FORGE=github
check "...the forgejo arm names its own tools" 1 "curl" preflight_bare CEREMONY_FORGE=forgejo
summary

View file

@ -34,3 +34,13 @@ summary() {
[ "$FAIL" -eq 0 ] [ "$FAIL" -eq 0 ]
} }
# forge_stub_path <endpoint> — strip the paging parameters the forge shim
# injects (#188) so a fixture keyed on the logical endpoint still matches.
# The page size moved OUT of the call sites and into the backend, which means
# every stub now sees "?per_page=100" appended to a paginated read; without
# this, a fixture lookup misses and the stub answers "unreadable", which the
# production code correctly reports as a degraded read.
forge_stub_path() {
printf '%s' "$1" | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g'
}

File diff suppressed because it is too large Load diff

93
test/labels-bootstrap.test.sh Executable file
View file

@ -0,0 +1,93 @@
#!/usr/bin/env bash
# The bootstrap bridge across the workflow_call boundary (#215).
#
# The defect: a called workflow cannot read the caller's dispatch inputs on
# this forge — `github.event.inputs.*` is empty inside `workflow_call` even
# though the top-level caller receives the value in both contexts (probe runs
# 6/7). The old gate read exactly that, so every dispatch-woken sweep
# bootstrapped (runs 459/523). The fix moves the value through a DECLARED
# `workflow_call` input, passed by the caller, with empty mapped to "no" at
# the caller so a cron can never bootstrap.
#
# These cases pin the wiring at every hop and drive the four value paths
# through the semantics of the exact expressions shipped — extracted from the
# YAML, never retyped, so an edited expression is an edited test input.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
REUSABLE="$ROOT/.github/workflows/labels-sweep.yml"
CALLER="$ROOT/.github/workflows/self-labels-sweep.yml"
CONSUMERS="$ROOT/docs/CONSUMERS.md"
# --- the declared boundary ---------------------------------------------------
decl() { yq -r ".on.workflow_call.inputs.bootstrap.$1 // \"\"" "$REUSABLE"; }
declares_boundary() { [ -n "$(yq -r '.on.workflow_call.inputs.bootstrap // ""' "$REUSABLE")" ]; }
check "labels-sweep.yml declares bootstrap as a workflow_call input" 0 "" declares_boundary
check "...typed string" 0 "string" decl type
check "...defaulting to no — an absent pass-through must never bootstrap" 0 "no" \
decl default
# --- the gate reads the declared input, and nothing else ---------------------
gate_exprs() { yq -r '.jobs[].steps[] | select(.with.bootstrap != null) | .with.bootstrap' "$REUSABLE"; }
gates_are_identity() {
[ "$(gate_exprs | sort -u)" = "\${{ inputs.bootstrap }}" ] \
&& [ "$(gate_exprs | wc -l)" -eq 2 ]
}
check "both gate sites feed the DECLARED input, unchanged" 0 "" gates_are_identity
# The forbidden context is only live inside an expression: the file NAMES it
# in comments and in the declared input's description to explain the defect,
# and both are prose. Matching raw text asserted on the explanation — the
# adjacent-assertion trap this suite keeps re-learning — so the predicate is
# scoped to `${{ … }}` bodies.
reads_event_inputs() { grep -qE '\$\{\{[^}]*github\.event\.inputs' "$REUSABLE"; }
check "no expression in the reusable reads github.event.inputs — the context this forge empties" 1 "" \
reads_event_inputs
# --- the caller passes it through, empty mapped to no ------------------------
CALLER_EXPR="$(yq -r '.jobs.sweep.with.bootstrap // ""' "$CALLER")"
check "self-labels-sweep.yml passes with.bootstrap through the boundary" 0 "" \
test -n "$CALLER_EXPR"
check "...with the exact empty-guard expression" 0 "" \
test "$CALLER_EXPR" = "\${{ inputs.bootstrap || 'no' }}"
# The published stub must carry the same bridge, or every consumer inherits
# the defect ceremony just fixed for itself.
check "the CONSUMERS.md sweep stub passes bootstrap through the boundary" 0 \
"bootstrap: \${{ inputs.bootstrap || 'no' }}" \
grep -F "bootstrap: \${{ inputs.bootstrap || 'no' }}" "$CONSUMERS"
# --- the four value paths, through the shipped expressions -------------------
# Evaluate the caller expression's semantics for a given top-level value. The
# expression is asserted byte-exact above, so modelling `x || 'no'` here is
# modelling the string the tree actually ships, not a hope about it.
caller_pass() { [ -n "$1" ] && printf '%s' "$1" || printf 'no'; }
# The reusable's gate is asserted to be the identity; the value then meets
# actions/labels-reconcile's REAL validate step, extracted and executed.
VALIDATE="$(mktemp)"
trap 'rm -f "$VALIDATE"' EXIT
{
printf '%s\n' '#!/usr/bin/env bash'
yq -r '.runs.steps[] | select(.name == "validate bootstrap input") | .run' \
"$ROOT/actions/labels-reconcile/action.yml"
} >"$VALIDATE"
chmod +x "$VALIDATE"
path() { BOOTSTRAP="$(caller_pass "$1")" bash "$VALIDATE"; }
check "schedule (empty top-level context) validates as a non-bootstrap sweep" 0 "" path ""
check "a REST event wake passing no validates as a non-bootstrap sweep" 0 "" path no
check "a manual dispatch passing yes validates as a bootstrap" 0 "" path yes
invalid_path() { BOOTSTRAP="maybe" bash "$VALIDATE"; }
check "an invalid value reaches the validator UNSANITIZED and refuses" 2 \
"bootstrap must be 'yes' or 'no'" invalid_path
# ...and the non-bootstrap/bootstrap split is what the validator's callers
# act on: prove the two accepted values are distinguished, not merely both
# accepted, by pinning what each resolves to after the caller pass.
check "empty and no resolve identically — the cron can never bootstrap" 0 "" \
test "$(caller_pass "")" = "$(caller_pass no)"
check "...and yes stays yes through the pass" 0 "" test "$(caller_pass yes)" = yes
summary

188
test/labels-dispatch.test.sh Executable file
View file

@ -0,0 +1,188 @@
#!/usr/bin/env bash
# The sweep dispatch in .github/workflows/labels.yml (#205).
#
# This EXTRACTS the shipped step's `run:` script and EXECUTES it against a
# recording stub, rather than grepping the YAML for strings. A grep here would
# pass on a step that assembles a perfect request and never sends it — the
# shape of defect this repo keeps finding in its own tests. So every case
# asserts on what the step actually sent, or on what it actually did when the
# forge refused.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
WORKFLOW="$ROOT/.github/workflows/labels.yml"
TMP="$(mktemp -d)"
trap 'rm -rf "${TMP:?}"' EXIT
# --- the step under test, taken from the shipped workflow --------------------
STEP="$TMP/step.sh"
{
printf '%s\n' '#!/usr/bin/env bash' 'set -e'
yq -r '.jobs.trigger.steps[] | select(.name == "dispatch the sweep") | .run' "$WORKFLOW"
} >"$STEP"
chmod +x "$STEP"
step_extracted() { [ "$(wc -l <"$STEP")" -ge 10 ]; }
check "the step's script was extracted from the shipped workflow" 0 "" step_extracted
# --- stubs -------------------------------------------------------------------
# `curl` records every invocation and answers with the code the case wants. It
# parses only what the step actually passes, so a step that stopped sending
# `-d`, or changed the method, fails here rather than recording nothing.
make_curl() { # make_curl <http-code> <body>
cat >"$TMP/bin/curl" <<STUB
#!/usr/bin/env bash
method=GET; data=; url=; out=; write=
while [ \$# -gt 0 ]; do
case "\$1" in
-X) method="\$2"; shift 2 ;;
-d) data="\$2"; shift 2 ;;
-o) out="\$2"; shift 2 ;;
-w) write="\$2"; shift 2 ;;
-H) printf '%s\n' "HEADER \$2" >>"$TMP/calls"; shift 2 ;;
-fsS|-sS|-s) shift ;;
*) url="\$1"; shift ;;
esac
done
printf '%s\n' "METHOD \$method" "URL \$url" "DATA \$data" >>"$TMP/calls"
# the default-branch read is a plain GET whose stdout the step pipes to jq
if [ "\$method" = GET ]; then printf '{"default_branch":"trunk"}'; exit 0; fi
[ -n "\$out" ] && printf '%s' '$2' >"\$out"
[ -n "\$write" ] && printf '%s' '$1'
exit 0
STUB
chmod +x "$TMP/bin/curl"
}
run_step() { # run_step <http-code> <body> [env assignments...]
local code="$1" body="$2"
shift 2
rm -rf "${TMP:?}/bin"
mkdir -p "$TMP/bin"
: >"$TMP/calls"
make_curl "$code" "$body"
env PATH="$TMP/bin:$PATH" \
GITHUB_TOKEN=tok \
GITHUB_API_URL=https://forge.example/api/v1 \
GITHUB_REPOSITORY=owner/repo \
SWEEP_WORKFLOW=self-labels-sweep.yml \
"$@" \
"$STEP"
}
sent() { grep -h "^$1 " "$TMP/calls" | tail -1 | cut -d' ' -f2-; }
sent_field() { jq -r "$1" <<<"$(sent DATA)"; }
# --- the success path --------------------------------------------------------
check "a 204 dispatch succeeds, naming what it woke and where" 0 \
"sweep dispatched — self-labels-sweep.yml on main" \
run_step 204 "" DEFAULT_BRANCH=main
posted() { [ "$(sent METHOD)" = POST ]; }
check "...by POST, not GET" 0 "" posted
right_endpoint() {
[ "$(sent URL)" = \
"https://forge.example/api/v1/repos/owner/repo/actions/workflows/self-labels-sweep.yml/dispatches" ]
}
check "...to the dispatches endpoint of the workflow it was told to wake" 0 "" right_endpoint
ref_is_main() { [ "$(sent_field .ref)" = main ]; }
check "...carrying a ref, because REST has no default and refuses without one" 0 "" ref_is_main
bootstrap_is_string_no() { [ "$(sent_field '.inputs.bootstrap')" = no ]; }
check "...and bootstrap=no as a STRING input, not a bare flag" 0 "" bootstrap_is_string_no
bearer_sent() { grep -qF 'HEADER Authorization: Bearer tok' "$TMP/calls"; }
check "...under the bearer header both forges accept" 0 "" bearer_sent
# --- the ref it must NOT inherit ---------------------------------------------
# On pull_request_target GITHUB_REF_NAME is `<n>/merge`. A step that reaches
# for it dispatches at something that is not a branch — and the forge answers
# that with the opaque 500, so it would look like an outage.
check "a pull_request_target run still dispatches at the branch" 0 "" \
run_step 204 "" DEFAULT_BRANCH=main GITHUB_REF_NAME=203/merge
ref_is_not_a_merge_ref() { case "$(sent_field .ref)" in *merge*) return 1 ;; *) return 0 ;; esac; }
check "...never at its merge ref" 0 "" ref_is_not_a_merge_ref
# --- the fallback ------------------------------------------------------------
check "an absent default branch is read from the forge, not guessed" 0 "" \
run_step 204 "" DEFAULT_BRANCH=
ref_is_trunk() { [ "$(sent_field .ref)" = trunk ]; }
check "...and the dispatch uses what the read returned" 0 "" ref_is_trunk
# --- failure is loud, and the diagnostic is owned ----------------------------
# `gh workflow run` failing WAS the misconfiguration alarm. The port keeps that
# contract: a consumer missing the caller, its input, or `actions: write` must
# fail here rather than sweep silently never again.
check "an empty 500 fails the step — the alarm still rings" 1 \
"/self-labels-sweep.yml/dispatches (ref=main)" \
run_step 500 "" DEFAULT_BRANCH=main
check "...explaining Forgejo's EMPTY 500 rather than passing it through" 1 \
"empty 500 body from Forgejo means the workflow name or the ref did not resolve" \
run_step 500 "" DEFAULT_BRANCH=main
check "...and naming the consumer causes the alarm exists for" 1 "actions: write" \
run_step 500 "" DEFAULT_BRANCH=main
check "any non-204 fails, not only the statuses the API documents" 1 "forbidden" \
run_step 403 '{"message":"forbidden"}' DEFAULT_BRANCH=main
# --- the API root is not guessed ---------------------------------------------
# Defaulting an unset GITHUB_API_URL to api.github.com sent this forge's
# dispatch to GitHub and reported success (@codex-reviewer-andresmgsl). The
# teeth are the call count: refusing AFTER a request is not refusing.
run_step_no_api() {
rm -rf "${TMP:?}/bin"; mkdir -p "$TMP/bin"; : >"$TMP/calls"
make_curl 204 ""
# -u, because plain `env` PRESERVES the parent environment: on the runner
# every step arrives with GITHUB_API_URL set — the premise of the fix under
# test — so without the unset this case inherits it, the refusal path never
# executes, and the case passes only in a dev shell that lacks the variable
# (@kimi-reviewer-andresmgsl, run 468).
env -u GITHUB_API_URL \
PATH="$TMP/bin:$PATH" GITHUB_TOKEN=tok GITHUB_REPOSITORY=owner/repo \
SWEEP_WORKFLOW=self-labels-sweep.yml DEFAULT_BRANCH=main "$STEP"
}
check "an unset GITHUB_API_URL refuses rather than guessing GitHub" 1 \
"GITHUB_API_URL is unset" run_step_no_api
no_calls_made() { [ ! -s "$TMP/calls" ]; }
check "...having made zero requests: refusing after a POST is not refusing" 0 "" no_calls_made
# --- a transport failure is not silence --------------------------------------
# The old `|| true` invariant was asserted by grepping the gh line this port
# removed, so it passed on any REST implementation including one that swallows
# a failed POST (@codex-reviewer-andresmgsl). Driven instead: curl itself exits
# non-zero, which `-w` cannot report because nothing is written.
make_failing_curl() {
printf '%s\n' '#!/usr/bin/env bash' 'echo "curl: (7) failed to connect" >&2' 'exit 7' \
>"$TMP/bin/curl"
chmod +x "$TMP/bin/curl"
}
run_step_curl_dies() {
rm -rf "${TMP:?}/bin"; mkdir -p "$TMP/bin"; : >"$TMP/calls"
make_failing_curl
env PATH="$TMP/bin:$PATH" GITHUB_TOKEN=tok \
GITHUB_API_URL=https://forge.example/api/v1 GITHUB_REPOSITORY=owner/repo \
SWEEP_WORKFLOW=self-labels-sweep.yml DEFAULT_BRANCH=main "$STEP"
}
check "a POST that never reaches the forge fails the step, and says so" 1 \
"never completed" run_step_curl_dies
# A code-aware guard alongside the behavioural one: no swallowing operator on
# the dispatch itself.
never_silenced() { sed 's/#.*//' "$STEP" | grep -qE '\|\|[[:space:]]*true'; }
check "...and the step carries no || true" 1 "" never_silenced
# --- what the port removed ---------------------------------------------------
# Strip comments first: the step's prose NAMES `gh workflow run` and
# CEREMONY_FORGE_CLIENT to explain what it replaced, so a raw grep asserts on
# the explanation instead of the code.
step_code() { sed 's/#.*//' "$STEP"; }
invokes_gh() { step_code | grep -qE '(^|[^[:alnum:]_])gh[[:space:]]'; }
decides_forge() { step_code | grep -qE 'GITHUB_SERVER_URL|CEREMONY_FORGE_CLIENT'; }
check "the step no longer INVOKES gh, its comments about it aside" 1 "" invokes_gh
check "...and no longer decides a forge, because REST needs no branch" 1 "" decides_forge
summary

File diff suppressed because it is too large Load diff

265
test/labels-scope.test.sh Normal file
View file

@ -0,0 +1,265 @@
#!/usr/bin/env bash
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
source "$ROOT/test/harness.sh"
# shellcheck source=actions/labels-scope/labels-scope.sh
source "$ROOT/actions/labels-scope/labels-scope.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
TAB="$(printf '\t')"
# --- glob_to_regex: the minimatch subset the family actually uses ------------
check "glob: ** crosses slashes" 0 '^lib/.*$' glob_to_regex 'lib/**'
check "glob: * stays inside a segment" 0 '^[^/]*\.md$' glob_to_regex '*.md'
check "glob: ? is one non-slash char" 0 '^doc[^/]/x$' glob_to_regex 'doc?/x'
check "glob: literal path is anchored whole" 0 '^README$' glob_to_regex 'README'
check "glob: dots are escaped, not wildcards" 0 \
'^commands/users-[^/]*\.sh$' glob_to_regex 'commands/users-*.sh'
check "glob: regex specials are literal" 0 '^a\+b\{c\}\(d\)$' glob_to_regex 'a+b{c}(d)'
# --- derive_labels: pure matching over parsed rows ---------------------------
cfg="scope:release-flow${TAB}lib/**
scope:release-flow${TAB}VERSION
scope:docs${TAB}docs/**
scope:docs${TAB}README"
check "derive: ** matches nested paths" 0 "scope:release-flow" \
derive_labels "$cfg" 'lib/deep/facts.sh'
# shellcheck disable=SC2016 # expansion belongs to the nested bash
check "derive: ** does not match the bare directory" 1 "" \
bash -c 'source "$1"; [ -n "$(derive_labels "$2" lib)" ]' _ \
"$ROOT/actions/labels-scope/labels-scope.sh" "$cfg"
# shellcheck disable=SC2016 # expansion belongs to the nested bash
check "derive: literal glob does not match a nested twin" 1 "" \
bash -c 'source "$1"; [ -n "$(derive_labels "$2" docs2/README)" ]' _ \
"$ROOT/actions/labels-scope/labels-scope.sh" "$cfg"
# shellcheck disable=SC2016 # expansion belongs to the nested bash
check "derive: one label per line, config order, deduped" 0 "" \
bash -c 'source "$1"; got="$(derive_labels "$2" "$(printf "%s\n" README VERSION lib/x docs/a.md)")"
[ "$got" = "$(printf "%s\n" scope:release-flow scope:docs)" ] || { printf "%s\n" "$got"; exit 1; }' _ \
"$ROOT/actions/labels-scope/labels-scope.sh" "$cfg"
check "derive: no files derives nothing" 0 "" derive_labels "$cfg" ""
check "derive: unmatched files derive nothing" 0 "" derive_labels "$cfg" 'src/other.c'
# --- parse_labeler_config: every spelling the governed repos use -------------
# Needs yq (preinstalled on ubuntu-latest). Locally, skip with a notice so
# the suite stays runnable in minimal environments; in CI the skip is a
# failure — ci.yml sets CEREMONY_REQUIRE_YQ so these cases can never
# quietly stop running there.
if command -v yq >/dev/null 2>&1; then
parses() { parse_labeler_config <"$1"; }
# block sequences + block glob list (ceremony's own spelling)
cat >"$TMP/block.yml" <<'EOF'
# a comment, as ceremony's own file carries
scope:labels:
- changed-files:
- any-glob-to-any-file:
- .github/workflows/labels.yml
- actions/labels-reconcile/**
EOF
check "parse: block style" 0 \
"scope:labels${TAB}actions/labels-reconcile/**" parses "$TMP/block.yml"
# quoted keys + flow glob list (box/rig's spelling)
cat >"$TMP/flow.yml" <<'EOF'
"scope:cli":
- changed-files:
- any-glob-to-any-file: ["bin/**", "test/cli.sh"]
EOF
check "parse: quoted key, flow list" 0 \
"scope:cli${TAB}bin/**" parses "$TMP/flow.yml"
# flow map inside changed-files (incubator's spelling)
cat >"$TMP/flowmap.yml" <<'EOF'
"scope:core":
- changed-files: [{any-glob-to-any-file: ["apps/core/**"]}]
EOF
check "parse: flow map entry" 0 \
"scope:core${TAB}apps/core/**" parses "$TMP/flowmap.yml"
# a single glob as a bare string
cat >"$TMP/single.yml" <<'EOF'
scope:docs:
- changed-files:
- any-glob-to-any-file: docs/**
EOF
check "parse: bare-string glob" 0 \
"scope:docs${TAB}docs/**" parses "$TMP/single.yml"
# the repo's real mapping parses, and rows keep config order
check "parse: ceremony's own labeler.yml" 0 \
"scope:labels${TAB}.github/labeler.yml" parses "$ROOT/.github/labeler.yml"
# the real mapping covers this implementation's own surface (#133 round):
# a PR touching only labels-scope must still derive scope:labels, like
# the neighboring labels-reconcile rows already did
real_rows="$(parses "$ROOT/.github/labeler.yml")"
check "derive: the real mapping labels a labels-scope-only change" 0 \
"scope:labels" derive_labels "$real_rows" 'actions/labels-scope/labels-scope.sh'
check "derive: the real mapping labels this test file" 0 \
"scope:labels" derive_labels "$real_rows" 'test/labels-scope.test.sh'
# --- the real mapping locates: one file set in, the whole label set out ---
# #267 measured the old map at 100% recall / 15% precision — 20 of the last
# 20 PRs wore scope:release-flow and 3 touched a release surface — so these
# cases assert the DERIVED SET WHOLE, brackets and all. A substring check
# cannot tell scope:labels from scope:labels plus a wrong second label, and
# a wrong second label is the whole defect.
derives() { # <newline-separated paths> → "[label,label]" for the real map
printf '[%s]\n' "$(derive_labels "$real_rows" "$1" | paste -sd, -)"
}
files() { printf '%s\n' "$@"; }
# D1: a fragment is written by every behavior change (BUILDER.md), so it
# carries no locating information. Asserted as an empty set on its own, not
# as an absence inside a longer list: this is the case that fails first if
# the glob is ever restored.
check "derive: a fragment-only path derives nothing at all" 0 \
"[]" derives 'changelog.d/999.md'
# D2: the issue-flow sweep is a reconciler of the label taxonomy
check "derive: the issueflow reconciler is scope:labels" 0 \
"[scope:labels]" derives 'actions/issueflow-reconcile/issueflow-reconcile.sh'
check "derive: the issueflow reconciler's test is scope:labels" 0 \
"[scope:labels]" derives 'test/issueflow-reconcile.test.sh'
# the reported bug, replayed: #261's exact file set wore scope:release-flow,
# inherited from its fragment, pointing at the one surface it does not touch
check "derive: #261's file set is scope:labels alone" 0 "[scope:labels]" \
derives "$(files actions/issueflow-reconcile/issueflow-reconcile.sh \
changelog.d/252.md test/issueflow-reconcile.test.sh)"
# D1's cost, checked rather than assumed: dropping the fragment glob must
# not cost the release surface its label
check "derive: a release PR is still scope:release-flow" 0 \
"[scope:release-flow]" \
derives "$(files VERSION CHANGELOG.md drills/0.6.0.md changelog.d/236.md)"
# D3: the docs block matched a literal README this tree does not have
check "derive: README.md is scope:docs" 0 "[scope:docs]" derives 'README.md'
check "derive: RELEASES.md is scope:docs" 0 "[scope:docs]" derives 'RELEASES.md'
check "derive: TRIAGE.md is scope:docs" 0 "[scope:docs]" derives 'TRIAGE.md'
# D3: three guard actions and their tests were in no block at all. Each of
# the six paths is asserted ALONE, never bundled with its sibling: a set
# holding both the action and its test derives scope:guards when either row
# matches, so one row could be deleted with the case still green — the six
# rows have to be six assertions to be six protections (#300 round).
for guard in changelog-assembled docs-sync runner-isolated; do
check "derive: actions/$guard is scope:guards" 0 "[scope:guards]" \
derives "actions/$guard/$guard.sh"
check "derive: $guard's test is scope:guards" 0 "[scope:guards]" \
derives "test/$guard.test.sh"
done
# D4: lib/ is genuinely mixed, so the shared files wear both labels rather
# than lib/** being re-carved into a row per file
check "derive: lib/ruling.sh is release-flow AND labels" 0 \
"[scope:release-flow,scope:labels]" derives 'lib/ruling.sh'
check "derive: lib/read.sh is release-flow AND labels" 0 \
"[scope:release-flow,scope:labels]" derives 'lib/read.sh'
check "derive: lib/version.sh is release-flow only" 0 \
"[scope:release-flow]" derives 'lib/version.sh'
# D6: the map stays advisory. An unmapped path derives an empty set and
# exits 0 — a guard that redded here would fail every PR touching FLEET.md
# or ci.yml, neither of which this map claims.
check "derive: an unmapped path is silence, not an error" 0 "[]" \
derives "$(files FLEET.md .github/workflows/ci.yml)"
# --- #302: one wrong answer and the surfaces the map never learned ------
# Every path asserted ALONE, per #300 round 1: a set holding a script and
# its test derives the scope when either row matches, so bundling would
# let a row be deleted with the case still green.
# D1, the reported bug replayed: both reconcilers source lib/attention.sh,
# nothing release-side does — [scope:release-flow] alone was a wrong
# answer, and the honest set is both, same as its two shelf-mates
check "derive: lib/attention.sh is release-flow AND labels" 0 \
"[scope:release-flow,scope:labels]" derives 'lib/attention.sh'
# D2: the sweep half of the automation, detached from the trigger half in
# #209 — cadence, permissions and job wiring must locate
check "derive: the labels sweep workflow is scope:labels" 0 \
"[scope:labels]" derives '.github/workflows/labels-sweep.yml'
check "derive: the self sweep workflow is scope:labels" 0 \
"[scope:labels]" derives '.github/workflows/self-labels-sweep.yml'
# D3, the deliberate asymmetry with D1: a test file inherits no lib/**
# glob, so its row is the one scope its subject actually locates
check "derive: attention's test is scope:labels alone" 0 \
"[scope:labels]" derives 'test/attention.test.sh'
check "derive: ruling's test is scope:labels alone" 0 \
"[scope:labels]" derives 'test/ruling.test.sh'
# D4: the same read's remaining gaps, one row each
check "derive: the trigger-surface pins are scope:labels" 0 \
"[scope:labels]" derives 'test/labels-triggers.test.sh'
check "derive: the assemble test is scope:release-flow" 0 \
"[scope:release-flow]" derives 'test/changelog-assemble.test.sh'
check "derive: the release-path manifest is scope:release-flow" 0 \
"[scope:release-flow]" derives '.github/scripts/release-path.sh'
check "derive: the release-path test is scope:release-flow" 0 \
"[scope:release-flow]" derives 'test/release-path.test.sh'
check "derive: the marker-check guard is scope:guards" 0 \
"[scope:guards]" derives '.github/scripts/marker-check.sh'
check "derive: the marker-check test is scope:guards" 0 \
"[scope:guards]" derives 'test/marker-check.test.sh'
check "derive: the vendored-check guard is scope:guards" 0 \
"[scope:guards]" derives '.github/scripts/vendored-check.sh'
check "derive: the vendored test is scope:guards" 0 \
"[scope:guards]" derives 'test/vendored.test.sh'
# D7: no test/** or .github/scripts/** catch-all — both directories span
# all four scopes, so this pair reds under any catch-all row: each file
# would gain the other's scope beside its own
check "derive: test/version.test.sh is release-flow alone" 0 \
"[scope:release-flow]" derives 'test/version.test.sh'
check "derive: this test file is scope:labels alone" 0 \
"[scope:labels]" derives 'test/labels-scope.test.sh'
# refusals: unsupported shapes fail loudly, naming the label
cat >"$TMP/allglobs.yml" <<'EOF'
scope:x:
- changed-files:
- all-globs-to-all-files: ["a/**"]
EOF
check "parse: all-globs-to-all-files is refused" 5 \
"scope:x: unsupported matcher(s) all-globs-to-all-files" parses "$TMP/allglobs.yml"
cat >"$TMP/branch.yml" <<'EOF'
scope:x:
- head-branch: ["^feature/"]
EOF
check "parse: branch matchers are refused" 5 \
"scope:x: unsupported key(s) head-branch" parses "$TMP/branch.yml"
cat >"$TMP/toplist.yml" <<'EOF'
- scope:x
EOF
check "parse: non-map top level is refused" 5 \
"top level must be a map" parses "$TMP/toplist.yml"
cat >"$TMP/backslash.yml" <<'EOF'
scope:x:
- changed-files:
- any-glob-to-any-file: ["a\\b/**"]
EOF
check "parse: backslash escapes are refused" 5 \
"backslash in glob" parses "$TMP/backslash.yml"
elif [ -n "${CEREMONY_REQUIRE_YQ:-}" ]; then
echo "FAIL: CEREMONY_REQUIRE_YQ is set but yq is missing — the config parse cases did not run"
FAIL=$((FAIL + 1))
else
echo "SKIP: yq not found — parse_labeler_config cases not exercised"
fi
summary

View file

@ -0,0 +1,182 @@
#!/usr/bin/env bash
set -u
# The labels TRIGGER SURFACE is a cost lever (#199): a full-board sweep is
# billed a 1-minute minimum every time a trigger fires, so how OFTEN it fires
# is what exhausted the fleet's shared Actions allotment. These assertions
# pin the reductions #199 made and the guard it must not trade away — none of
# them touch the reconciler's LOGIC, which its own fixtures cover.
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
source "$ROOT/test/harness.sh"
REUSABLE="$ROOT/.github/workflows/labels.yml"
SWEEP="$ROOT/.github/workflows/labels-sweep.yml"
SELF="$ROOT/.github/workflows/self-labels.yml"
SELF_SWEEP="$ROOT/.github/workflows/self-labels-sweep.yml"
STUB="$ROOT/docs/CONSUMERS.md" # the published caller stubs, fenced yaml blocks
# The `cancel-in-progress:` value of a named top-level job, read from the first
# such line inside that job's block. Job keys sit at two-space indent.
job_cancel_in_progress() { # $1 = file, $2 = job name
awk -v job="^ $2:\$" '
$0 ~ job { f = 1; next }
f && /^ [a-z]/ { exit } # next job — stop before leaking into it
f && /cancel-in-progress:/ { sub(/.*cancel-in-progress:[[:space:]]*/, ""); print; exit }
' "$1"
}
# The `types:` list of a trigger key (issues:, pull_request_target:), read from
# the first `types:` line after the bare key. The key is bare (nothing after
# the colon) so it never collides with `issues: write` in the permissions block.
trigger_types() { # $1 = file, $2 = trigger key
awk -v key="^ $2:\$" '
$0 ~ key { f = 1; next }
f && /^ types:/ { sub(/^ types:[[:space:]]*/, ""); print; exit }
f && /^ [a-z]/ { exit }
' "$1"
}
# ---- the guard the cost fix must never trade away (#199 test plan must-fail) --
# cancel-in-progress: true on reconcile kills a sweep mid-board, the exact race
# the shared concurrency group exists to prevent. It WOULD cut run count — by
# trading correctness for minutes — so it stays false, forever. The job lives
# in labels-sweep.yml since #209; the guard moved with it.
check "reconcile serializes, never cancels mid-board" 0 "false" \
job_cancel_in_progress "$SWEEP" reconcile
# shellcheck disable=SC2016 # the awk program runs in the nested bash, not here
check "reconcile is never cancel-in-progress: true" 1 "" \
bash -c 'job_cancel_in_progress() {
awk -v job="^ reconcile:\$" "\$0 ~ job{f=1;next} f&&/^ [a-z]/{exit} f&&/cancel-in-progress:/{sub(/.*cancel-in-progress:[[:space:]]*/,\"\");print;exit}" "$1"
}; [ "$(job_cancel_in_progress "$1")" = true ]' _ "$SWEEP"
# scope MAY cancel — it is per-PR and additive, so a superseded run is waste,
# not a lost sweep. This asserts the must-fail above is scoped to reconcile.
check "scope stays cancel-in-progress: true (per-PR, additive)" 0 "true" \
job_cancel_in_progress "$REUSABLE" scope
# ---- the sweep is detached from PR-triggered runs (#209) ---------------------
# While reconcile rode the PR-event run, every displacement in its shared
# queue recorded a CANCELLED check on some PR — fake red CI. The reusable
# labels.yml must never grow the job back; its trigger job wakes the sweep
# caller by dispatch instead, and that dispatch is the misconfiguration
# alarm: a pin bumped without the sweep caller must go loudly red at the
# trigger, so the dispatch line is never allowed to silence itself.
check "labels.yml carries no reconcile job" 1 "" \
grep -E '^ reconcile:' "$REUSABLE"
check "labels-sweep.yml carries the reconcile job" 0 " reconcile:" \
grep -E '^ reconcile:' "$SWEEP"
check "the sweep keeps the ONE shared concurrency group" 0 "group: labels-reconcile" \
grep -F 'group: labels-reconcile' "$SWEEP"
check "labels.yml carries the trigger job" 0 " trigger:" \
grep -E '^ trigger:' "$REUSABLE"
# #205 ported this dispatch from `gh workflow run` to REST. The assertion is
# the same one it always was — the sweep caller is woken BY NAME and never
# bootstrapped — but it now has to hold against a request rather than a CLI
# line. What the step actually SENDS is driven in test/labels-dispatch.test.sh;
# these two keep the wiring pinned here alongside the rest of the trigger.
# shellcheck disable=SC2016 # $SWEEP_WORKFLOW is the workflow's own env var, asserted literally
check "the trigger dispatches the sweep caller by name" 0 \
'actions/workflows/$SWEEP_WORKFLOW/dispatches' \
grep -F '/dispatches' "$REUSABLE"
check "...never bootstrapping" 0 'bootstrap: "no"' \
grep -F 'bootstrap' "$REUSABLE"
# The never-silenced invariant moved to test/labels-dispatch.test.sh, where it
# is BEHAVIOURAL: a curl that dies at the transport must fail the extracted
# step, plus a code-aware no-`|| true` guard on the step itself. The check that
# lived here grepped the `gh workflow run` line #205 removed, so after the port
# it passed on every implementation including one that swallows a failed POST —
# a green assertion whose name claimed an invariant its implementation could
# not observe (@codex-reviewer-andresmgsl, #213 review).
check "the sweep caller filename input defaults to labels-sweep.yml" 0 \
"default: labels-sweep.yml" grep -F 'default: labels-sweep.yml' "$REUSABLE"
# the dogfood callers wear the split: the event caller names its deviant
# sweep filename, and the sweep caller declares the bootstrap input the
# trigger's -f flag requires (an undeclared input reds every dispatch)
check "self caller passes its dogfood sweep filename" 0 \
"sweep_workflow: self-labels-sweep.yml" \
grep -F 'sweep_workflow: self-labels-sweep.yml' "$SELF"
check "self sweep caller declares the bootstrap dispatch input" 0 \
"bootstrap:" grep -E '^ bootstrap:' "$SELF_SWEEP"
check "stub sweep caller declares the bootstrap dispatch input" 0 \
"bootstrap:" grep -E '^ bootstrap:' "$STUB"
# the labels caller's event runs must not carry the sweep's cron or manual
# dispatch — those relocated to the sweep caller with #209
check "self caller carries no cron" 1 "" grep -F 'cron:' "$SELF"
check "self caller carries no workflow_dispatch" 1 "" \
grep -E '^ workflow_dispatch:' "$SELF"
# ---- the cron is a backstop, relaxed to hourly (#199 candidate 1) -----------
# Scope the */15 assertion to the cron LINE — the prose comments cite */15 by
# name to explain the change, and must not re-red their own documentation.
# The cron rides the sweep caller since #209.
check "self sweep caller cron is hourly" 0 '0 * * * *' grep -F 'cron:' "$SELF_SWEEP"
# shellcheck disable=SC2016 # $1 expands in the nested bash, not here
check "self sweep caller cron line no longer fires */15" 1 "" \
bash -c 'grep -F "cron:" "$1" | grep -qF "*/15"' _ "$SELF_SWEEP"
check "stub cron is hourly" 0 '0 * * * *' grep -F 'cron:' "$STUB"
# shellcheck disable=SC2016 # $1 expands in the nested bash, not here
check "stub cron line no longer fires */15" 1 "" \
bash -c 'grep -F "cron:" "$1" | grep -qF "*/15"' _ "$STUB"
# ---- issues: is narrowed to the queue-state-changing actions (#199) ----------
# Kept because each carries a queue-state change an event uniquely carries, so
# dropping it would trip #199's must-fail (a transition waiting on the schedule
# when an event could have carried it): opened → mint→needs-triage; closed →
# blocker-closes→ready self-heal; edited → a body rewrite of the `Blocked by #N`
# declaration the sweep parses; reopened → a closed issue re-entering the queue.
# (labels.test.sh owns the exact-list and caller<->stub parity assertions.)
for keep in opened closed edited reopened; do
# shellcheck disable=SC2016 # the awk program runs in the nested bash, not here
check "self caller issues surface keeps '$keep'" 0 "" \
bash -c 'trigger_types() {
awk -v key="^ issues:\$" "\$0 ~ key{f=1;next} f&&/^ types:/{sub(/^ types:[[:space:]]*/,\"\");print;exit} f&&/^ [a-z]/{exit}" "$1"
}; trigger_types "$1" | grep -qw "$2"' _ "$SELF" "$keep"
done
# The churn actions must not reappear on the issues surface without a fresh why.
# labeled/unlabeled were the dominant issues-churn source; assigned/unassigned
# only feed validation and the 48h claim clock, caught within one cadence.
for churn in labeled unlabeled assigned unassigned; do
# shellcheck disable=SC2016 # the awk program runs in the nested bash, not here
check "self caller issues surface drops '$churn'" 1 "" \
bash -c 'trigger_types() {
awk -v key="^ issues:\$" "\$0 ~ key{f=1;next} f&&/^ types:/{sub(/^ types:[[:space:]]*/,\"\");print;exit} f&&/^ [a-z]/{exit}" "$1"
}; trigger_types "$1" | grep -qw "$2"' _ "$SELF" "$churn"
done
# ---- the PR handoff wake is NOT collateral of the issues narrowing ----------
# The handoff (state:needs-human, confirmed by the caller's labeled event) rides
# pull_request_target, not issues. A future edit that strips it there re-reds.
check "pull_request_target keeps the labeled handoff wake" 0 "labeled" \
trigger_types "$SELF" pull_request_target
# ---- fork heads carry a read-only token on this Forgejo (#241) --------------
# Same-repo heads keep the existing immediate scope + sweep-dispatch path. A
# fork-headed pull_request_target run must attempt no write: both write-capable
# jobs exclude it, while one successful job explains exactly what the scheduled
# sweep does and does not supply. Require each full normalised expression to
# appear intact, so deleting or inverting one of its clauses fails the guard.
job_if_expression() { # $1 = file, $2 = job
yq -r ".jobs.$2.if // \"\"" "$1" |
tr '\n' ' ' |
awk '{$1=$1; print}'
}
check "scope writes only for a same-repo PR head" 0 \
"github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name == github.repository && github.event.action != 'labeled' && github.event.action != 'unlabeled' && github.event.action != 'review_requested' && github.event.action != 'review_request_removed'" \
job_if_expression "$REUSABLE" scope
check "the sweep trigger preserves non-PR events and excludes fork heads" 0 \
"github.event_name != 'pull_request_target' || github.event.pull_request.head.repo.full_name == github.repository" \
job_if_expression "$REUSABLE" trigger
check "a fork-headed PR selects the successful explanation job" 0 \
"github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository" \
job_if_expression "$REUSABLE" fork_head
fork_head_step() {
yq -r '.jobs.fork_head.steps[] | select(.name == "explain deferred fork labels") | .run' \
"$REUSABLE" | bash
}
check "the fork path distinguishes swept state from unsupported scope writes" 0 \
"read-only token; state, blocker, and handoff reconciliation deferred to the scheduled sweep; path-derived scope labels are not applied to fork heads" \
fork_head_step
summary

View file

@ -12,6 +12,7 @@ trap 'rm -rf "$TMP"' EXIT
printf '%s\n' \ printf '%s\n' \
'panel=one two three' \ 'panel=one two three' \
'triage-actors=triage-one' \
'' \ '' \
'scope:one|C5DEF5|First scope' \ 'scope:one|C5DEF5|First scope' \
'scope:two|C5DEF5|Second scope' >"$TMP/good.conf" 'scope:two|C5DEF5|Second scope' >"$TMP/good.conf"
@ -25,6 +26,30 @@ check "panel is parsed" 0 "one two three" bash -c \
check "core and config rows merge" 0 "scope:two|C5DEF5|Second scope" bash -c \ check "core and config rows merge" 0 "scope:two|C5DEF5|Second scope" bash -c \
'source "$1"; core_label_rows; configured_label_rows "$2"' _ \ 'source "$1"; core_label_rows; configured_label_rows "$2"' _ \
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$TMP/good.conf" "$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$TMP/good.conf"
attention_row='attention|D93F0B|A demand is parked here for the assignee: pick up the thread, ack by removing this label'
# shellcheck disable=SC2016 # expansion belongs to the nested bash
check "attention core row is emitted once, byte-exact" 0 "1" bash -c \
'source "$1"; core_label_rows | grep -cxF "$2"' _ \
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$attention_row"
needs_triage_row='needs-triage|FBCA04|Did not come through triage — owes normalization into work or a reasoned refusal'
# shellcheck disable=SC2016 # expansion belongs to the nested bash
check "needs-triage core row is emitted once, byte-exact" 0 "1" bash -c \
'source "$1"; core_label_rows | grep -cxF "$2"' _ \
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$needs_triage_row"
# shellcheck disable=SC2016 # expansion belongs to the nested bash
check "core label rows carry no stale discussion path" 1 "" bash -c \
'source "$1"; core_label_rows | grep -i discussion' _ \
"$ROOT/actions/labels-reconcile/labels-reconcile.sh"
# shellcheck disable=SC2016 # fields are intentionally split in the nested shell
check "attention description survives label field splitting" 0 \
"A demand is parked here for the assignee: pick up the thread, ack by removing this label" \
bash -c 'source "$1"; while IFS="|" read -r name color desc; do
[ "$name" != attention ] || printf "%s\n" "$desc"
done < <(core_label_rows)' _ "$ROOT/actions/labels-reconcile/labels-reconcile.sh"
# shellcheck disable=SC2016 # expansion belongs to the nested bash
check "triage config is not parsed as a label row" 1 "" bash -c \
'source "$1"; configured_label_rows "$2" | grep -F triage-actors' _ \
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$TMP/good.conf"
check "missing scope config is an empty table" 0 "" configured_label_rows "$TMP/missing.conf" check "missing scope config is an empty table" 0 "" configured_label_rows "$TMP/missing.conf"
printf '%s\n' 'scope:bad|C5DEF5' >"$TMP/bad.conf" printf '%s\n' 'scope:bad|C5DEF5' >"$TMP/bad.conf"
@ -37,4 +62,201 @@ load_config "$TMP/good.conf"
set_required_bots two set_required_bots two
check "PR author is recused from the required panel" 0 "one three" printf '%s\n' "${REQUIRED_BOTS[*]}" check "PR author is recused from the required panel" 0 "one three" printf '%s\n' "${REQUIRED_BOTS[*]}"
# -- per-author panel rows (#224): the config-parse matrix -------------------
# required_for loads a conf fresh in a subshell and prints the required set
# behind a RESULT: anchor, so substring matching cannot confuse "b c" with
# "a b c".
# shellcheck disable=SC2016 # expansion belongs to the nested bash
required_for() { # $1 = conf, $2 = author → RESULT:<required set>
bash -c 'source "$1"; load_config "$2" || exit 1
set_required_bots "$3"; printf "RESULT:%s\n" "${REQUIRED_BOTS[*]}"' _ \
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$1" "$2"
}
printf '%s\n' 'panel=a b c' >"$TMP/plain.conf"
check "no bracketed row: panelist author gets panel minus self" 0 "RESULT:b c" \
required_for "$TMP/plain.conf" a
check "no bracketed row: outside author gets the whole panel" 0 "RESULT:a b c" \
required_for "$TMP/plain.conf" z
printf '%s\n' 'panel=a b c' 'panel[z]=b c' >"$TMP/author.conf"
check "bracketed author gets exactly its row" 0 "RESULT:b c" \
required_for "$TMP/author.conf" z
check "unbracketed author beside a bracketed row is unchanged" 0 "RESULT:b c" \
required_for "$TMP/author.conf" a
printf '%s\n' 'panel[z]=b c' 'panel=a b c' >"$TMP/reversed.conf"
check "row order is irrelevant: bracketed row before panel=" 0 "RESULT:b c" \
required_for "$TMP/reversed.conf" z
check "row order is irrelevant for the base panel too" 0 "RESULT:b c" \
required_for "$TMP/reversed.conf" a
printf '%s\n' 'panel=a b c' 'panel[a]=a b' >"$TMP/self.conf"
check "author inside its own bracketed row is still recused" 0 "RESULT:b" \
required_for "$TMP/self.conf" a
# shellcheck disable=SC2016 # expansion belongs to the nested bash
check "base panel is byte-identical with the bracketed rows deleted" 0 "SAME" \
bash -c 'source "$1"; load_config "$2"; with="${BOTS[*]}"
load_config "$3"; [ "$with" = "${BOTS[*]}" ] && echo SAME' _ \
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" \
"$TMP/author.conf" "$TMP/plain.conf"
printf '%s\n' 'panel=a b c' 'panel[z]=b' 'panel[z]=c' >"$TMP/dup-author.conf"
check "duplicate rows for one login fail naming the line" 1 \
"duplicate panel[z]= row" load_config "$TMP/dup-author.conf"
printf '%s\n' 'panel=a b c' 'panel[z]=' >"$TMP/empty-set.conf"
check "a bracketed row naming zero reviewers fails loudly" 1 \
"panel[z]= must name at least one reviewer" load_config "$TMP/empty-set.conf"
printf '%s\n' 'panel=a b c' 'panel[]=b c' >"$TMP/empty-login.conf"
check "an empty login fails loudly" 1 "empty login in panel row" \
load_config "$TMP/empty-login.conf"
# codex's round-1 probe: the stray ] used to parse, record login z], and
# silently misroute z to the base panel — exactly the D4 refusal owed.
printf '%s\n' 'panel=a b c' 'panel[z]]=b' >"$TMP/stray-bracket.conf"
check "a stray ] inside the bracket is refused as a bracket" 1 \
"malformed panel[<login>]= row" load_config "$TMP/stray-bracket.conf"
printf '%s\n' 'panel=a b c' 'panel[a_b]=c' >"$TMP/bad-login.conf"
check "a non-login character in the bracket is refused" 1 \
"malformed panel[<login>]= row" load_config "$TMP/bad-login.conf"
printf '%s\n' 'panel=a b c' 'panel[z=b c' >"$TMP/broken-bracket.conf"
check "a malformed bracket is refused as a bracket (D4)" 1 \
"malformed panel[<login>]= row" load_config "$TMP/broken-bracket.conf"
# shellcheck disable=SC2016 # expansion belongs to the nested bash
check "...and never as a label row" 1 "" bash -c \
'source "$1"; load_config "$2" 2>&1 | grep -F "malformed label row"' _ \
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$TMP/broken-bracket.conf"
# The D7 tripwire: in a case pattern an unquoted panel[abc]=* is a bracket
# expression matching panela=… — this row going green as a panel setting is
# exactly the silent mis-route the quoted prefix exists to prevent.
printf '%s\n' 'panel=a b c' 'panela=b c' >"$TMP/glob-guard.conf"
check "panela= is still a malformed label row, never a panel setting (D7)" 1 \
"malformed label row" load_config "$TMP/glob-guard.conf"
printf '%s\n' 'panel[z]=b c' >"$TMP/bracket-only.conf"
check "a bracketed row does not satisfy the mandatory panel=" 1 \
"missing panel= line" load_config "$TMP/bracket-only.conf"
printf '%s\n' 'panel=a b c' 'panel[z]=b c' \
'scope:one|C5DEF5|First scope' >"$TMP/mixed.conf"
check "configured_label_rows returns the scope rows alone" 0 \
"scope:one|C5DEF5|First scope" configured_label_rows "$TMP/mixed.conf"
# shellcheck disable=SC2016 # expansion belongs to the nested bash
check "no panel[...] row reaches the bootstrap" 1 "" bash -c \
'source "$1"; configured_label_rows "$2" | grep -F "panel["' _ \
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$TMP/mixed.conf"
# LABELS.md is mirrored byte-identically into every governed repo, so any
# scope enumeration it carries is true at home and false everywhere else —
# 14 of 16 vendored rows were false across the family when this fired (#104).
# The set lives in labels.conf and the repo's CONTRIBUTING; the mirror never
# names it. A concrete label is `scope:` followed by a name character — the
# doctrine spellings (bare `scope:`, wildcard `scope:*`) put a backtick or `*`
# there instead, so any name, current or future, in any shape (table row,
# name|color|description row, prose) re-reds this while doctrine stays green.
# grep -c prints the count and exits 1 when that count is 0.
check "LABELS.md enumerates no repo's scope labels" 1 "0" \
grep -c 'scope:[a-z0-9]' "$ROOT/LABELS.md"
# The caller's trigger type lists and the CONSUMERS.md stub's must be the
# same lists. review_requested/review_request_removed are the wake that
# clears blocker:unrequested — the label sat false for as long as a quiet
# repo stayed quiet because the one event that falsifies it was never
# listed (#137). The issues list narrowed to [opened, closed, edited, reopened]
# (#199): each carries a queue-state change the hourly cron cannot wait one
# cadence for — opened drives mint→needs-triage, closed the blocker-closes→ready
# self-heal, edited a body rewrite of the `Blocked by #N` line the sweep parses,
# reopened a closed issue re-entering the queue — while the churn/validation
# actions (labeled/unlabeled/assigned/unassigned) came off. The stub is prose, so nothing but these rows
# keeps the lists from drifting: a type in one file only is a wake that fires
# at home and nowhere in the fleet, or the reverse — the drift #144 caught.
# The NF guard keeps `issues: write` under permissions: from matching the
# issues: trigger key.
event_types() { # $1 = file, $2 = trigger key → that trigger's types line, unindented
awk -v key="$2:" '$1 == key && NF == 1 {f=1; next} f && /types: /{sub(/^ */,""); print; exit}' "$1"
}
types_in_sync() { # $1 = trigger key, $2 = caller, $3 = stub → 0 when both lists exist and match
local a b
a="$(event_types "$2" "$1")" b="$(event_types "$3" "$1")"
[ -n "$a" ] && [ "$a" = "$b" ]
}
CALLER="$ROOT/.github/workflows/self-labels.yml"
STUB="$ROOT/docs/CONSUMERS.md"
# event_types anchors on the bare trigger key (NF == 1), so it reads the real
# types line even though the #199 comments name pull_request_target: and
# issues: in prose above the keys — an inline /pull_request_target:/ scan would
# latch onto the first mention and read the wrong list.
pr_has_both_review_wakes() {
event_types "$CALLER" pull_request_target | grep -F review_requested | grep -qF review_request_removed
}
check "caller and stub pull_request_target lists are identical" 0 "" \
types_in_sync pull_request_target "$CALLER" "$STUB"
check "the caller lists both review-request wakes" 0 "" pr_has_both_review_wakes
check "caller and stub issues lists are identical" 0 "" \
types_in_sync issues "$CALLER" "$STUB"
check "the caller lists exactly the queue-state-changing issue types" 0 \
"types: [opened, closed, edited, reopened]" event_types "$CALLER" issues
# the failing cases: drop a type from either file, or reorder one list only,
# and the identity rows above go red — exercised here on mutated copies
mut_caller="$TMP/mut-caller.yml" mut_stub="$TMP/mut-stub.md"
sed 's/, review_request_removed//' "$CALLER" >"$mut_caller"
check "a type dropped from the caller goes red" 1 "" \
types_in_sync pull_request_target "$mut_caller" "$STUB"
sed 's/, review_request_removed//' "$STUB" >"$mut_stub"
check "a type dropped from the stub goes red" 1 "" \
types_in_sync pull_request_target "$CALLER" "$mut_stub"
sed 's/review_requested, review_request_removed/review_request_removed, review_requested/' \
"$STUB" >"$mut_stub"
check "a reorder in one list only goes red" 1 "" \
types_in_sync pull_request_target "$CALLER" "$mut_stub"
sed 's/, closed//' "$CALLER" >"$mut_caller"
check "an issue type dropped from the caller goes red" 1 "" \
types_in_sync issues "$mut_caller" "$STUB"
sed 's/, closed//' "$STUB" >"$mut_stub"
check "an issue type dropped from the stub goes red" 1 "" \
types_in_sync issues "$CALLER" "$mut_stub"
sed 's/opened, closed/closed, opened/' "$STUB" >"$mut_stub"
check "an issue-list reorder in one file only goes red" 1 "" \
types_in_sync issues "$CALLER" "$mut_stub"
# --- #195: the conf's roster and CONTRIBUTING's roster table are one set ----
# The rot this catches: labels.conf named five identities, CONTRIBUTING named
# the same five, and none of the five existed on the forge — two files in
# perfect agreement with each other and none with reality. No offline check
# can reach the second half; what it can hold is that a roster edit touching
# one file and not the other goes red, which is the drift that turns a
# deliberate swap into a silent one.
# roster_from_conf <conf> — every identity panel= and triage-actors= name.
roster_from_conf() {
sed -nE 's/^(panel|triage-actors)=//p' "$1" | tr ' ' '\n' | sed '/^$/d' | sort -u
}
# roster_from_doc <contributing> — the identities the "### Roster" table's
# first column names. Anchored to the section rather than to the table's
# shape: another table elsewhere in the file must not be able to join the
# roster by looking like one.
# shellcheck disable=SC2016 # the backticks below are the table's, not a subshell
roster_from_doc() {
awk '/^### Roster$/ { inside = 1; next }
inside && /^#+ / { exit }
inside' "$1" |
sed -nE 's/^\| `([^`]+)`.*/\1/p' | sort -u
}
roster_in_sync() { # <conf> <contributing>
local conf="$1" doc="$2" drift
drift="$(diff <(roster_from_conf "$conf") <(roster_from_doc "$doc"))" && return 0
echo "roster drift ('<' conf only, '>' table only):" >&2
printf '%s\n' "$drift" >&2
return 1
}
CONF="$ROOT/.github/labels.conf"
CONTRIB="$ROOT/CONTRIBUTING.md"
check "the real conf and the real roster table name the same identities" 0 "" \
roster_in_sync "$CONF" "$CONTRIB"
# the failing cases, in both directions — a one-way check would have passed
# all week on the rot that produced #195
mut_conf="$TMP/mut-labels.conf" mut_contrib="$TMP/mut-contributing.md"
sed 's/^panel=/panel=ghost-bot /' "$CONF" >"$mut_conf"
check "an identity in the conf but not the table goes red" 1 "ghost-bot" \
roster_in_sync "$mut_conf" "$CONTRIB"
# shellcheck disable=SC2016 # the backticks are the table's, not a subshell
sed 's/^| `glm-bot-andresmgsl`/| `ghost-bot`/' "$CONTRIB" >"$mut_contrib"
check "an identity in the table but not the conf goes red" 1 "ghost-bot" \
roster_in_sync "$CONF" "$mut_contrib"
summary summary

142
test/marker-check.test.sh Normal file
View file

@ -0,0 +1,142 @@
#!/usr/bin/env bash
# Contract tests for .github/scripts/marker-check.sh (issue #238). The guard
# is driven against tracked fixture trees; set -u, not -e, because failures
# are behavior for the harness to inspect.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
CHECK="$ROOT/.github/scripts/marker-check.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
fixture() {
local name="$1" version="$2"
mkdir -p "$TMP/$name/docs" "$TMP/$name/changelog.d"
git -C "$TMP/$name" init -q
printf '%s\n' "$version" >"$TMP/$name/VERSION"
printf '# Changelog\n\n## 0.5.0 — 2026-08-03\n\n- Shipped (#221).\n' \
>"$TMP/$name/CHANGELOG.md"
}
run_check() {
git -C "$TMP/$1" add .
bash "$CHECK" "$TMP/$1"
}
fixture wrapped 0.6.0-dev
cat >"$TMP/wrapped/docs/CONSUMERS.md" <<'EOF'
The new guard remains **unreleased**
(#238) until the next tag.
EOF
check "a wrapped local citation is accepted" 0 "agree with the tree" \
run_check wrapped
fixture uncited 0.6.0-dev
printf 'The new guard remains **unreleased** for now.\n' \
>"$TMP/uncited/docs/CONSUMERS.md"
check "an uncited marker fails on a dev tree with file and line" 1 \
"docs/CONSUMERS.md:1" run_check uncited
fixture inline-mention 0.6.0-dev
cat >"$TMP/inline-mention/docs/CONSUMERS.md" <<'EOF'
The marker token is `**unreleased**`.
EOF
check "an inline-code token is a mention and needs no citation" 0 \
"agree with the tree" run_check inline-mention
fixture inline-bare 0.6.0-dev
printf 'The marker token is **unreleased**.\n' \
>"$TMP/inline-bare/docs/CONSUMERS.md"
check "removing the backticks exposes the uncited marker" 1 \
"docs/CONSUMERS.md:1" run_check inline-bare
fixture inline-neighbor 0.6.0-dev
cat >"$TMP/inline-neighbor/docs/CONSUMERS.md" <<'EOF'
The `new guard` remains **unreleased** until its tag.
EOF
check "unrelated inline code cannot hide an uncited marker on the same line" 1 \
"docs/CONSUMERS.md:1" run_check inline-neighbor
# This release comparison would have caught all five of #221's stale markers.
fixture shipped 0.6.0
printf 'The new guard remains **unreleased** (#224).\n' \
>"$TMP/shipped/docs/CONSUMERS.md"
cat >"$TMP/shipped/CHANGELOG.md" <<'EOF'
# Changelog
## 0.6.0 — 2026-08-03
- The guard shipped (#224).
## 0.5.0 — 2026-08-02
- Older work (#999).
EOF
check "a release rejects a marker cited by its top section" 1 \
"docs/CONSUMERS.md:1: **unreleased** (#224)" run_check shipped
fixture not-shipped 0.6.0
printf 'Future work remains **unreleased** (#999).\n' \
>"$TMP/not-shipped/docs/CONSUMERS.md"
cp "$TMP/shipped/CHANGELOG.md" "$TMP/not-shipped/CHANGELOG.md"
check "a release keeps a marker absent from its top section" 0 \
"agree with the tree" run_check not-shipped
fixture dev-shipped 0.6.0-dev
printf 'Future work remains **unreleased** (#224).\n' \
>"$TMP/dev-shipped/docs/CONSUMERS.md"
cp "$TMP/shipped/CHANGELOG.md" "$TMP/dev-shipped/CHANGELOG.md"
check "a dev tree does not compare markers with shipped sections" 0 \
"agree with the tree" run_check dev-shipped
fixture cross-repo 0.6.0
printf 'Crew work remains **unreleased** (crew#293).\n' \
>"$TMP/cross-repo/docs/CONSUMERS.md"
cat >"$TMP/cross-repo/CHANGELOG.md" <<'EOF'
# Changelog
## 0.6.0 — 2026-08-03
- Local work shipped (#293).
EOF
check "a cross-repo citation is valid and ignored by release comparison" 0 \
"agree with the tree" run_check cross-repo
fixture self-qualified 0.6.0
printf 'Ceremony work remains **unreleased** (ceremony#248).\n' \
>"$TMP/self-qualified/docs/CONSUMERS.md"
cat >"$TMP/self-qualified/CHANGELOG.md" <<'EOF'
# Changelog
## 0.6.0 — 2026-08-03
- Ceremony work shipped (#248).
EOF
check "a self-qualified citation is ignored; local markers must use bare #N" 0 \
"agree with the tree" run_check self-qualified
# CHANGELOG.md:38 on main is the live bold-token entry prose this exclusion models.
fixture exclusions 0.6.0-dev
cat >"$TMP/exclusions/NOTES.md" <<'EOF'
# Notes
## Unreleased
The marker token is `**unreleased**`.
EOF
printf -- '- A fragment may say **unreleased** without being documentation.\n' \
>"$TMP/exclusions/changelog.d/999.md"
cat >"$TMP/exclusions/CHANGELOG.md" <<'EOF'
# Changelog
## 0.5.0 — 2026-08-03
- Shipped prose may discuss **unreleased** markers without becoming one.
EOF
check "headings, inline mentions, changelog entries, and fragments are excluded" 0 \
"agree with the tree" run_check exclusions
summary

239
test/no-runtime-gh.test.sh Executable file
View file

@ -0,0 +1,239 @@
#!/usr/bin/env bash
# The forge-portability guard (#198, enforcing #197's acceptance bar):
#
# No runtime `gh` invocation survives outside lib/forge-github.sh, except
# in a file that declares CEREMONY_FORGE_CLIENT=gh and therefore refuses
# loudly on a forge that cannot serve it.
#
# WHY THIS FILE EXISTS, rather than the rule living in review. #188 ported
# every `gh` call site onto the shim. The 0.6.0 upstream merge put SEVEN of
# them back — not in the eighteen conflict hunks, where a resolver would have
# been forced to look, but in whole functions upstream added to files this
# tree already owned. `git merge` takes upstream's side wherever only upstream
# moved a region, so it raised no conflict and asked no question. Reviewing
# the hunks could not have caught them; four reviewers reading the same diff
# each found a different subset.
#
# The sweep runs on a Forgejo instance whose runner image carries curl, jq and
# node and has NEITHER gh NOR stoke (lib/forge-forgejo.sh's header, probe task
# 278). So a reintroduced `gh` is not a style problem — it is `gh: command not
# found` mid-sweep, or a write that silently never happens.
#
# And it is invisible to the rest of the suite by construction: the contract
# tests stub `gh` as a shell function or on PATH, so they exercise a
# reintroduced call site happily and go green. This guard reads the SOURCE,
# which is the only place the difference is visible.
#
# It is deliberately a source-level check, and deliberately the ONLY one of
# its kind: every other guard here drives behaviour. This one cannot — the
# behaviour it forbids is unobservable in a harness that provides a `gh`.
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=test/harness.sh
. "$ROOT/test/harness.sh"
# The backend that is ALLOWED to speak gh — it is the whole point of the file.
ALLOWED_FILE='lib/forge-github.sh'
# A file may opt out by declaring the client it speaks, which makes
# forge_preflight refuse by name on a forge that cannot serve it. Today that
# is actions/refs-not-closing, whose only gather is GraphQL and which Forgejo
# therefore cannot run at all (#199 ports it and drops the declaration).
# Both spellings, because both surfaces must be able to declare: `=` for a
# shell script, `:` for a workflow's env block. A filename exemption was the
# first shape here and @codex-reviewer-andresmgsl was right to reject it —
# it exempts the whole FILE, so any later gh call anywhere in that workflow
# would ride in free, and it lets a declaration exist without a refusal.
declares_gh_client() { grep -qE '^[[:space:]]*(export[[:space:]]+)?CEREMONY_FORGE_CLIENT[=:][[:space:]]*gh[[:space:]]*$' "$1"; }
# Declaring is half of it. #197's bar is "declared AND refuses loudly", and the
# refusal has TWO halves that a single check conflates
# (@codex-reviewer-andresmgsl, #198):
#
# * the FORGE — a gh dispatch is wrong on a forge that cannot serve it, and
# asking only "is gh installed?" passes the moment a Forgejo runner image
# happens to ship gh, which is the client/forge mismatch forge_preflight
# exists to prevent;
# * the BINARY — present or not on this runner.
#
# forge_preflight answers both, so a script that calls it satisfies both. A
# workflow has no shell to call it from and must do both inline.
#
# Comments are stripped first, for the same reason gh_calls strips them and
# with the same lesson learned the hard way: the first version of this
# predicate was satisfied by the word `forge_preflight` inside labels.yml's own
# comment EXPLAINING that it has no forge_preflight to call. A guard that reads
# prose as evidence is the blind sweep again, and it passed its own mutation
# test because of it.
strip_comments() { sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$1"; }
refuses_wrong_forge() {
strip_comments "$1" | grep -qE 'forge_preflight|GITHUB_SERVER_URL.*github\.com'
}
refuses_missing_binary() {
strip_comments "$1" | grep -qE 'forge_preflight|command -v gh'
}
refuses_when_unavailable() {
refuses_wrong_forge "$1" && refuses_missing_binary "$1"
}
# A runtime invocation, not the word. `gh` must be at a command position and
# followed by a gh subcommand — and comment lines are stripped first, because
# these surfaces document at length what gh used to do here and a guard that
# went red on its own prose would be deleted within a week
# (@kimi-reviewer-andresmgsl, #198). Nothing here reads a comment as evidence.
gh_calls() { # $1 = file → "line:code" per runtime gh invocation
# Comments are BLANKED rather than dropped, so grep -n still reports the
# file's real line numbers. Trailing comments go too, not just whole-line
# ones: a workflow's `actions: write # ...gh workflow run...` is prose
# about a call site, and YAML puts it after the code rather than before it.
sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' "$1" \
| grep -nE '(^|[^[:alnum:]_./$-])gh[[:space:]]+(api|issue|pr|release|repo|run|search|workflow|label|auth|browse|gist|secret|variable|ruleset)\b'
}
# The surfaces that run on a forge: executables and the workflows that call
# them. test/ is excluded on purpose — a test stubbing `gh` is the harness
# doing its job, and forbidding the string there would forbid the stubs that
# make the github backend testable at all.
scanned_files() {
local f
for f in "$ROOT"/lib/*.sh "$ROOT"/actions/*/*.sh "$ROOT"/bin/* \
"$ROOT"/.github/scripts/*.sh "$ROOT"/.github/workflows/*.yml; do
[ -f "$f" ] || continue
printf '%s\n' "${f#"$ROOT"/}"
done
}
offenders() {
local rel abs
while IFS= read -r rel; do
[ "$rel" = "$ALLOWED_FILE" ] && continue
abs="$ROOT/$rel"
if declares_gh_client "$abs"; then
refuses_when_unavailable "$abs" && continue
printf '%s: declares CEREMONY_FORGE_CLIENT=gh but carries no refusal\n' "$rel"
continue
fi
gh_calls "$abs" | sed "s|^|$rel:|"
done < <(scanned_files)
}
# In-process, not `bash -c`: a subshell cannot see these functions, so the
# sweep would find nothing, report empty, and pass by looking at nothing —
# the blind-sweep shape this guard exists to forbid, inside the guard itself.
no_offenders() {
local found
found="$(offenders)"
[ -z "$found" ] || { printf '%s\n' "$found" | sed 's/^/ /'; return 1; }
}
check "no runtime gh outside the github backend or a declared-client file" 0 "" \
no_offenders
# --- the guard has teeth ------------------------------------------------------
# A guard nobody has watched fail is a guard nobody is testing. These drive the
# predicates directly, because the sweep above is a property of the whole tree
# and cannot be made to fail without editing it.
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
printf '%s\n' '#!/usr/bin/env bash' 'gh api "repos/$REPO/issues/1"' >"$TMP/bad.sh"
check "a reintroduced gh api read is seen" 0 "gh api" gh_calls "$TMP/bad.sh"
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
printf '%s\n' '#!/usr/bin/env bash' 'run gh issue comment "$n" --body x' >"$TMP/bad2.sh"
check "a reintroduced gh issue write is seen, staged or not" 0 "gh issue" \
gh_calls "$TMP/bad2.sh"
# The exact shape the 0.6.0 merge reintroduced, indented inside a function.
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
printf '%s\n' '#!/usr/bin/env bash' 'f() {' \
' guarded_read bodies gh api --paginate "repos/$REPO/issues/$1/comments"' '}' \
>"$TMP/bad3.sh"
check "...including one nested in a function behind guarded_read" 0 "gh api" \
gh_calls "$TMP/bad3.sh"
printf '%s\n' '#!/usr/bin/env bash' '# gh api used to live here (#188)' \
'# run gh issue comment — retired' >"$TMP/prose.sh"
check "prose about gh is not a call site" 1 "" gh_calls "$TMP/prose.sh"
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
printf '%s\n' '#!/usr/bin/env bash' 'forge_api "repos/$REPO/issues/1"' \
'echo "the gh client speaks /api/v3"' >"$TMP/good.sh"
check "the shim verb is not mistaken for a call site" 1 "" gh_calls "$TMP/good.sh"
# Neighbouring identifiers must not read as the binary: `gh_calls`, `$gh`,
# a path ending in /gh, and `regh api` are all not an invocation of gh.
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
printf '%s\n' '#!/usr/bin/env bash' 'gh_calls() { :; }' 'regh api foo' \
'echo "$gh api"' >"$TMP/lookalike.sh"
check "lookalike identifiers are not call sites" 1 "" gh_calls "$TMP/lookalike.sh"
printf '%s\n' '#!/usr/bin/env bash' 'export CEREMONY_FORGE_CLIENT=gh' \
'gh api graphql -f query=x' >"$TMP/declared.sh"
check "a declared-client file opts out" 0 "" declares_gh_client "$TMP/declared.sh"
# A workflow declares in YAML, not shell — both spellings must count, or the
# only surface that cannot call forge_preflight is also the only one that
# cannot declare.
printf '%s\n' 'jobs:' ' t:' ' steps:' ' - env:' \
' CEREMONY_FORGE_CLIENT: gh' ' run: gh workflow run x' \
>"$TMP/declared.yml"
check "...and so does a workflow declaring it in YAML" 0 "" \
declares_gh_client "$TMP/declared.yml"
# Declared is not enough: #197's bar is declared AND refuses loudly.
check "a declaration without a refusal is not enough" 1 "" \
refuses_when_unavailable "$TMP/declared.yml"
printf '%s\n' 'jobs:' ' t:' ' steps:' ' - env:' \
' CEREMONY_FORGE_CLIENT: gh' \
' run: |' \
' command -v gh >/dev/null || { echo "::warning::not woken"; exit 0; }' \
' gh workflow run x' >"$TMP/declared-refusing.yml"
# Binary presence ALONE is not a refusal: a Forgejo runner that ships gh would
# sail past it and dispatch against a forge that cannot serve the call.
check "...and a declaration guarded only by binary presence still is not" 1 "" \
refuses_when_unavailable "$TMP/declared-refusing.yml"
check "...though it does satisfy the binary half on its own" 0 "" \
refuses_missing_binary "$TMP/declared-refusing.yml"
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
printf '%s\n' 'jobs:' ' t:' ' steps:' ' - env:' \
' CEREMONY_FORGE_CLIENT: gh' \
' run: |' \
' [ "$GITHUB_SERVER_URL" = "https://github.com" ] || exit 0' \
' command -v gh >/dev/null || exit 0' \
' gh workflow run x' >"$TMP/declared-both.yml"
check "...and a declaration guarding BOTH forge and binary is" 0 "" \
refuses_when_unavailable "$TMP/declared-both.yml"
# labels.yml WAS the real customer for that pair. #205 ported its dispatch to
# REST, so it no longer speaks gh and must no longer declare a client — the
# exemption is spent, not inherited. Asserting its ABSENCE is what stops the
# declaration coming back as cover for a re-added `gh` call: an opt-out with no
# gh behind it is a standing permission slip.
check "labels.yml no longer declares a client, because it speaks none (#205)" 1 "" \
declares_gh_client "$ROOT/.github/workflows/labels.yml"
# shellcheck disable=SC2016 # `$SWEEP_WORKFLOW` is the literal the YAML must
# carry: the endpoint has to be built from the caller's input, not hardcoded.
labels_yml_dispatches_by_rest() {
grep -qF 'actions/workflows/$SWEEP_WORKFLOW/dispatches' \
"$ROOT/.github/workflows/labels.yml"
}
check "...and dispatches the sweep over REST instead" 0 "" labels_yml_dispatches_by_rest
check "...and an undeclared one does not" 1 "" declares_gh_client "$TMP/bad.sh"
# A mention of the variable in prose is not a declaration.
printf '%s\n' '#!/usr/bin/env bash' '# CEREMONY_FORGE_CLIENT=gh would opt out' \
>"$TMP/mentions.sh"
check "...nor does prose mentioning the variable" 1 "" \
declares_gh_client "$TMP/mentions.sh"
# The scan must actually reach the surfaces it claims to, or it passes by
# looking at nothing — the blind-sweep shape this repo keeps filing issues
# about, in its own guard.
scan_is_wide() { [ "$(scanned_files | wc -l)" -ge 20 ]; }
check "the scan reaches every executable surface" 0 "" scan_is_wide
scan_lists_backend() { scanned_files | grep -F lib/forge-github.sh; }
check "...including the backend it exempts" 0 "lib/forge-github.sh" scan_lists_backend
check "...and the backend really does speak gh, so the exemption is load-bearing" 0 "gh api" \
gh_calls "$ROOT/lib/forge-github.sh"
summary

Some files were not shown because too many files have changed in this diff Show more