Compare commits

...

109 commits
0.6.1 ... 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
48 changed files with 2465 additions and 502 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:

1
.github/labeler.yml vendored
View file

@ -46,6 +46,7 @@ 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

4
.github/labels.conf vendored
View file

@ -1,5 +1,5 @@
panel=codex-reviewer-andresmgsl glm-reviewer-andresmgsl cluade-reviewer-andresmgsl kimi-reviewer-andresmgsl panel=codex-bot-andresmgsl glm-bot-andresmgsl claude-bot-andresmgsl kimi-bot-andresmgsl
triage-actors=cluade-reviewer-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

@ -19,6 +19,7 @@ printf '%s\n' \
bin/ \ bin/ \
lib/version.sh \ lib/version.sh \
lib/decide.sh \ lib/decide.sh \
lib/preflight.sh \
lib/facts.sh \ lib/facts.sh \
lib/changelog.sh \ lib/changelog.sh \
lib/forge.sh lib/forge.sh

View file

@ -3,9 +3,11 @@ name: labels-sweep
# jobs that rode labels.yml until #209. Triggers and permissions live in the # jobs that rode labels.yml until #209. Triggers and permissions live in the
# caller; docs/CONSUMERS.md carries the complete caller stub # caller; docs/CONSUMERS.md carries the complete caller stub
# (workflow_dispatch plus the hourly cron, which relocated here with the # (workflow_dispatch plus the hourly cron, which relocated here with the
# sweep). Board events still yield a sweep within seconds: labels.yml's # sweep). Issue events and same-repository PR events still yield a sweep within
# trigger job dispatches this workflow's caller on every event it used to # seconds: labels.yml's trigger job dispatches this workflow's caller on those
# run reconcile on. # 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 # Detached on purpose (#209): every sweep covers every open PR and all
# sweeps serialize through ONE shared concurrency group, so GitHub's # sweeps serialize through ONE shared concurrency group, so GitHub's
@ -24,7 +26,8 @@ name: labels-sweep
# taxonomy (its `bootstrap` input defaults to "yes"), exactly what # taxonomy (its `bootstrap` input defaults to "yes"), exactly what
# dispatching the labels caller did before the split. The trigger job's # dispatching the labels caller did before the split. The trigger job's
# dispatches carry bootstrap=no — ~20 label upserts per sweep is too chatty # dispatches carry bootstrap=no — ~20 label upserts per sweep is too chatty
# for every board event, the same reason cron runs never bootstrapped. # 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 # This cannot loop: reconciler writes use GITHUB_TOKEN, and GitHub does not
# create workflow runs from GITHUB_TOKEN-raised events (the trigger's # create workflow runs from GITHUB_TOKEN-raised events (the trigger's
@ -63,7 +66,7 @@ on:
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.6.1" CEREMONY_SELF_REF: "0.6.3"
jobs: jobs:
reconcile: reconcile:

View file

@ -2,12 +2,16 @@ 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 — scope reads changed paths and the # token. Those runs therefore attempt no writes. The scheduled sweep later
# path mapping via the API and checks out only the ceremony implementation. # reconciles state, blockers, and handoff, but it does not apply path-derived
# Keep it that way. # 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).
# #
# The reconcile sweep lived here until #209. Riding the PR-triggered run # The reconcile sweep lived here until #209. Riding the PR-triggered run
# meant every displacement in the sweep's shared concurrency queue recorded # meant every displacement in the sweep's shared concurrency queue recorded
@ -19,10 +23,14 @@ name: labels
# one `review_requested` event per panelist per request, so every review # one `review_requested` event per panelist per request, so every review
# round displaces runs and the rate scales with panel size. The # round displaces runs and the rate scales with panel size. The
# sweep now lives in labels-sweep.yml behind its own caller, and the # sweep now lives in labels-sweep.yml behind its own caller, and the
# trigger job below is its wake: it fires on every event this caller # trigger job below is its instant wake: it fires on every issue event and
# subscribes — the exact surface that used to run reconcile directly — so # same-repository PR event this caller subscribes to, preserving that part of
# the wake latency (#137) is unchanged, while a displaced sweep cancels on # the surface that used to run reconcile directly. Same-repository wake latency
# the Actions tab, attached to no PR. PR checks show scope + trigger only. # (#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: the trigger's dispatch and the reconciler's label # This cannot loop: the trigger's dispatch and the reconciler's label
# writes both use GITHUB_TOKEN. GitHub does not create workflow runs from # writes both use GITHUB_TOKEN. GitHub does not create workflow runs from
@ -48,7 +56,7 @@ on:
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.6.1" CEREMONY_SELF_REF: "0.6.3"
jobs: jobs:
scope: scope:
@ -60,6 +68,7 @@ jobs:
# scope run is clobbered. # 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_requested' &&
@ -106,15 +115,12 @@ jobs:
CONFIG_REF: ${{ github.sha }} CONFIG_REF: ${{ github.sha }}
trigger: trigger:
# The sweep's wake (#209). No `if:`: reconcile carried none, so the # The sweep's instant wake (#209) keeps the whole non-PR event surface and
# trigger keeps the whole event surface the caller subscribes — # same-repository PRs. Fork-headed PRs are excluded because this Forgejo
# workflow_dispatch of the labels caller itself included. That cannot # gives their pull_request_target run a read-only token (#241); fork_head
# double-fire bootstrap: this dispatch always carries bootstrap=no, so # records which reconciliation waits for the sweep and that path-derived
# a dispatched labels caller yields one plain sweep, and the taxonomy # scope labels are not applied there. Non-PR events include workflow_dispatch:
# bootstrap fires solely on a manual dispatch of the sweep caller # excluding it would make a dispatched labels caller silently do nothing.
# (whose input defaults to "yes"). Excluding workflow_dispatch here
# would instead make a dispatched labels caller do nothing at all —
# a silent no-op run is worse than a redundant sweep.
# #
# LOUD on failure — never `|| true`: a red trigger is the # LOUD on failure — never `|| true`: a red trigger is the
# misconfiguration alarm. A consumer that bumps the pin without adding # misconfiguration alarm. A consumer that bumps the pin without adding
@ -122,6 +128,9 @@ jobs:
# `bootstrap` input (unexpected input), or without `actions: write` # `bootstrap` input (unexpected input), or without `actions: write`
# on this caller (permission denied) fails HERE, visibly on the PR, # on this caller (permission denied) fails HERE, visibly on the PR,
# instead of silently never sweeping again. # 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 runs-on: ubuntu-latest
steps: steps:
- name: dispatch the sweep - name: dispatch the sweep
@ -199,3 +208,16 @@ jobs:
exit 1 exit 1
fi fi
echo "labels: sweep dispatched — $SWEEP_WORKFLOW on $branch (bootstrap=no)" 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"

View file

@ -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.6.1" CEREMONY_SELF_REF: "0.6.3"
VERSION_SOURCE: ${{ inputs.version-source }} VERSION_SOURCE: ${{ inputs.version-source }}
jobs: jobs:
@ -205,31 +205,40 @@ jobs:
fi fi
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md" 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=$?
case "$tag_read_rc" in
0) tag_shas="$(printf '%s\n' "$tag_refs" | awk 'NF { print $1 }')" ;;
2) tag_shas="" ;;
*)
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 exit 1
fi ;;
esac
# shellcheck source=/dev/null # shellcheck source=/dev/null
. "$CEREMONY_DIR/lib/forge.sh" . "$CEREMONY_DIR/lib/forge.sh"
forge_select "" forge_select ""
if ! exists="$(forge_release_exists "$VER")"; then if ! released="$(forge_release_exists "$VER")"; then
echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2 echo "could not read whether release '$VER' exists — refusing rather than assuming it does not (#191)." >&2
exit 1 exit 1
fi fi
if [ "$exists" = yes ]; then # shellcheck source=/dev/null
echo "release '$VER' already exists — refusing to re-release, creating nothing." >&2 . "$CEREMONY_DIR/lib/preflight.sh"
exit 1 out="$(TAG_SHAS="$tag_shas" RELEASED="$released" release_preflight)"
fi 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 }}
@ -363,6 +372,22 @@ jobs:
fi fi
changelog_section CHANGELOG.md "$VER" > "$RUNNER_TEMP/notes.md" 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.

View file

@ -7,13 +7,16 @@ name: labels-sweep
on: on:
# The consumer owns this cadence (#203). Hourly is the recommended default # 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 # when no other engine drives board state: the cron is then the sweep's ONLY
# wake for four transition classes — a review verdict landing (there is no # wake for a review verdict landing (there is no
# pull_request_review trigger on the labels caller), blocker:ci-red set or # 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 # cleared (no check_suite/check_run/workflow_run), a blocker:conflict when
# ANOTHER PR merges under this one, and the time-based stale / 48h # ANOTHER PR merges under this one, and the time-based stale / 48h
# claim-reclaim. The labels caller's events carry the rest in seconds, one # claim-reclaim, plus every state, blocker, and handoff transition for a
# trigger-job dispatch away. Hourly trades ≤1h of latency on those four # fork-headed PR on this Forgejo because its pull_request_target token is
# while cutting nominal scheduled sweeps from four an hour to one at # 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 # 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 # 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 # the classes with no other writer bound the cadence; relax it only as that
@ -22,8 +25,9 @@ on:
# A manual full-board sweep. A bare dispatch (input default "yes") also # A manual full-board sweep. A bare dispatch (input default "yes") also
# bootstraps the taxonomy on a fresh repo — what dispatching the labels # bootstraps the taxonomy on a fresh repo — what dispatching the labels
# caller did before #209. The reusable's trigger job wakes this workflow # caller did before #209. The reusable's trigger job wakes this workflow
# with bootstrap=no on every board event — an event-woken sweep must not # with bootstrap=no on every issue and same-repository PR event — an
# re-upsert ~20 labels each time — so declaring this input is part of the # 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 # caller contract: a dispatch naming an undeclared input is refused, and
# the trigger job goes loudly red. # the trigger job goes loudly red.
workflow_dispatch: workflow_dispatch:

View file

@ -8,8 +8,12 @@ name: labels
# Since #209 this caller carries the PR/issue event surface only. The # Since #209 this caller carries the PR/issue event surface only. The
# reconcile sweep no longer rides these runs — the reusable's trigger job # reconcile sweep no longer rides these runs — the reusable's trigger job
# dispatches the sweep caller (self-labels-sweep.yml here), which owns the # dispatches the sweep caller (self-labels-sweep.yml here), which owns the
# hourly cron and the manual/bootstrap workflow_dispatch. A board event # hourly cron and the manual/bootstrap workflow_dispatch. Issue events and
# below still yields a sweep within seconds, one dispatch hop later. # 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:
# Narrowed (#199) to the actions that carry a queue-state change the hourly # Narrowed (#199) to the actions that carry a queue-state change the hourly
# cron cannot wait one cadence for — dropping only labeled/unlabeled/assigned/ # cron cannot wait one cadence for — dropping only labeled/unlabeled/assigned/
@ -26,14 +30,16 @@ on:
issues: issues:
types: [opened, closed, edited, reopened] types: [opened, closed, edited, reopened]
pull_request_target: pull_request_target:
# Every PR arrives from a fork, so these carry the head/draft/review facts # These carry the head/draft/review facts the sweep derives state:* from.
# the sweep derives state:* from. labeled/unlabeled are the handoff wake — # Same-repository heads wake that sweep in seconds; fork heads cannot write
# the author's optimistic state:needs-human write, confirmed or corrected # with this Forgejo's read-only token, so state, blocker, and handoff
# here in seconds (#11); synchronize re-derives on every push; # reconciliation waits for the scheduled cadence. The sweep does not apply
# review_requested/review_request_removed wake the sweep that clears (or # path-derived scope labels to those heads (#241).
# restores) blocker:unrequested — without them the one event that makes # labeled/unlabeled are the same-repository handoff wake — the author's
# the label false could not clear it, and a quiet repo wore the red flag # optimistic state:needs-human write, confirmed or corrected here in
# until the advisory cron (#137). # 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] types: [opened, reopened, ready_for_review, converted_to_draft, synchronize, labeled, unlabeled, review_requested, review_request_removed]
permissions: permissions:
contents: read contents: read

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

@ -33,7 +33,10 @@ triage bug, and the move is to say so on the issue, not to guess.
re-requests posted, by head and not by verdict (steps 12). A red check 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; 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 3. every remaining acceptance criterion is operator-owned, stated so by
triage on the issue; 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, 4. it is **handed off** — round passed, no `blocker:*` standing,
`state:needs-human` set per Handoff, the merge the human's. Shapes 2 `state:needs-human` set per Handoff, the merge the human's. Shapes 2
and 4 are sequential and never overlap; and 4 are sequential and never overlap;
@ -137,8 +140,8 @@ triage bug, and the move is to say so on the issue, not to guess.
its red-flag profile are in REVIEWER.md §What you review against, item 2 its red-flag profile are in REVIEWER.md §What you review against, item 2
(#216). (#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 go to a discussion, or a comment on the relevant issue; Adjacent problems go to a proposal, or a comment on the relevant issue;
you do not mint issues — nobody but triage does — and you do not fix you do not mint work issues — nobody but triage does — and you do not fix
drive-by findings in the same PR. drive-by findings in the same PR.
## The review round ## The review round
@ -174,7 +177,9 @@ such as the panel roster live in that repo's own CONTRIBUTING.)
blocking (#139, #276). **Then classify that entry by `conclusion`, never blocking (#139, #276). **Then classify that entry by `conclusion`, never
`status`**, which can disagree with it (#259). No conclusion is not `status`**, which can disagree with it (#259). No conclusion is not
green: a configured run in progress is waited on, and waiting is green: a configured run in progress is waited on, and waiting is
compliance, not a stall. Cancelled or stale is not green, *stale* being a 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 superseded head's check, which a head-scoped rollup never shows. Skipped
or neutral is green, those being deliberate "passed / not applicable" or neutral is green, those being deliberate "passed / not applicable"
conclusions. No checks configured is green — the third ruled case, not an conclusions. No checks configured is green — the third ruled case, not an
@ -200,8 +205,19 @@ such as the panel roster live in that repo's own CONTRIBUTING.)
head, and its mechanism is crew's to describe (#94). **The re-request head, and its mechanism is crew's to describe (#94). **The re-request
carries the same green-check-at-head precondition**, argued exception 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 included: a fix push whose check comes up red is your next fix, not the
panel's. Prefer verification over argument — add the test that settles panel's. **Where an engine mediates the request, that precondition binds
the doubt. 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; silence and force-forward are not options, and a panel in the PR; silence and force-forward are not options, and a panel
@ -218,8 +234,9 @@ 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** — 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 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. the head answer, then request, step 1's precondition and not a second one.
Waiting there is compliance, and `blocker:unrequested` does not fire while a Waiting there is compliance — again the request's wait, not the
head's checks are pending or red (#236). declaration's — and `blocker:unrequested` does not fire while a head's
checks are pending or red (#236).
## The ruling ask ## The ruling ask

View file

@ -17,10 +17,65 @@ This tree is `heavy-duty/ceremony` on
numbers (#197 D2). Two trees therefore answer to the same number, differing numbers (#197 D2). Two trees therefore answer to the same number, differing
by the forge-compatibility delta. **This tree carries upstream through 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 `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's separate `0.4.1` this forge's own release, not upstream's, and upstream `0.6.1` through `0.6.3`
section is deliberately not carried — the tag published here is the one this were adopted by port rather than merge. Upstream's separate `0.4.1` section
section is the body of. Each sync updates this line is deliberately not carried — the tag published here is the one this section
(docs/UPSTREAM-SYNC.md, #200). 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 ## 0.6.1 — 2026-08-09

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
@ -48,10 +48,10 @@ the box is the blast-radius boundary; roles are what a session is told, and
| identity | box (rig tenant) | standing work | | identity | box (rig tenant) | standing work |
|---|---|---| |---|---|---|
| `cluade-reviewer-andresmgsl` | `triage` (claude-box) | **triage** — the only door issues come through; this identity mints issues and nothing else writes them (#18's `triage-actors`) — and review. It does not build. | | `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. |
| `codex-reviewer-andresmgsl` | codex-box | build + review | | `codex-bot-andresmgsl` | codex-box | build + review |
| `glm-reviewer-andresmgsl` | glm-box | review | | `glm-bot-andresmgsl` | glm-box | review |
| `kimi-reviewer-andresmgsl` | kimi-box | review | | `kimi-bot-andresmgsl` | kimi-box | review |
**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
@ -62,7 +62,7 @@ panel-minus-author, and three is what it currently comes to, not a second
rule. Builders and triage default to different models so the issue contract rule. Builders and triage default to different models so the issue contract
is honestly exercised — a spec gap should surface as a question on the is honestly exercised — a spec gap should surface as a question on the
issue, not be silently filled by shared priors. Humans (`andres`) decide in issue, not be silently filled by shared priors. Humans (`andres`) decide in
discussions and merge; the roster is config, not doctrine — swapping a proposals and merge; the roster is config, not doctrine — swapping a
vendor is an edit to this table (and to `panel=` in vendor is an edit to this table (and to `panel=` in
`.github/labels.conf` once #10 lands), nothing more. `.github/labels.conf` once #10 lands), nothing more.
@ -97,14 +97,13 @@ table repeats it (#104).
## Doctrine conventions ## Doctrine conventions
The vendored role files — `AGENTS.md`, `TRIAGE.md`, `BUILDER.md`, The vendored role files — the set [`docs/VENDORED.txt`](docs/VENDORED.txt)
`REVIEWER.md`, `LABELS.md`, and `RELEASES.md` — state each normative rule declares — state each normative rule completely, keep at most one sentence of
completely, keep at most one sentence of why, and cite its record only with a why, and cite its record only with a bare parenthetical such as `(#N)`,
bare parenthetical such as `(#N)`, `(#N D3)`, or `(#N, #M)`. Incident `(#N D3)`, or `(#N, #M)`. Incident narrative — timestamps, actors, quoted
narrative — timestamps, actors, quoted comments, measured counts, and links to comments, measured counts, and links to specific comments — belongs in that
specific comments — belongs in that record. If a rule cannot be followed record. If a rule cannot be followed without chasing its cite, the rule is
without chasing its cite, the rule is under-stated: fix the statement, not the under-stated: fix the statement, not the citation. (#280)
citation. (#280)
Normative text in those files does not cite issues from other repositories. Normative text in those files does not cite issues from other repositories.
Consumers read the vendored bytes outside this organization's context, and a Consumers read the vendored bytes outside this organization's context, and a
@ -119,21 +118,12 @@ 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, RELEASES.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:
@ -153,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.

View file

@ -136,7 +136,7 @@ The earlier form of this file folded handoff and rebase into the other
builder wakes; they are duties of their own. builder wakes; they are duties of their own.
- **Triage signals**, per registry repo: `needs-triage` issues, - **Triage signals**, per registry repo: `needs-triage` issues,
queue-unlabeled strays, discussions without triage's voice, unread queue-unlabeled strays, proposals without triage's voice, unread
`@`-mentions (their own session), and `blocked` issues whose named blockers `@`-mentions (their own session), and `blocked` issues whose named blockers
have all landed — a lead the session verifies, never a label the engine have all landed — a lead the session verifies, never a label the engine
flips. Backlog hygiene (stale claims, label invariants) runs hourly, flips. Backlog hygiene (stale claims, label invariants) runs hourly,

View file

@ -27,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)
@ -49,7 +51,7 @@ 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 |
@ -240,4 +242,4 @@ 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 the repository lacks. The same workflow reconciles issue-flow labels on issue
events and during the scheduled sweep. Default GitHub labels (`duplicate`, events and during the scheduled sweep. Default GitHub labels (`duplicate`,
`invalid`, `question`, `wontfix`, `help wanted`, `good first issue`) are `invalid`, `question`, `wontfix`, `help wanted`, `good first issue`) are
deleted at bootstrap — a `question` is a discussion, not an issue. deleted at bootstrap — a `question` belongs in a proposal, not a work issue.

View file

@ -108,8 +108,8 @@ workflow that carries it.
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-L301) 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 ([lib/changelog.sh](lib/changelog.sh) is the one the generated PR list ([lib/changelog.sh](lib/changelog.sh) is the one
canonical extractor, and [bin/changelog-section](bin/changelog-section) is canonical extractor, and [bin/changelog-section](bin/changelog-section) is
@ -127,12 +127,13 @@ 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 sorts them. Two fail before the release exists: the consumer's
[artifact hook](docs/CONSUMERS.md#the-artifact-hook) sits between the tag and [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 the publish, so its non-zero exit aborts, and the publish itself
([`gh release create --verify-tag`](.github/workflows/release.yml#L246-L258)) ([`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 can fail on the API call or the assets. Either leaves the same state — a tag
standing and no release — which the standing and no release — which the merge-door preflight recognizes and a
[nothing-exists assert](#the-merge-door-refused-releaseyml) names and the tag re-run resumes. The tag door remains the fallback when the original run is no
door recovers. The third is the re-arm, which runs after the publish, and its longer reachable or the release must come from a fixed tree. The third is the
refusal is the single failure in this file that leaves a real release behind. 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
@ -141,18 +142,20 @@ refusal is the single failure in this file that leaves a real release behind.
`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 half-ceremony dies loudly
([release.yml](.github/workflows/release.yml#L136-L301)). Use it for every ([release.yml](.github/workflows/release.yml#L136-L310)). Use it for every
normal release. normal release.
- **The tag door — the fallback and the backfill.** A bare `X.Y.Z` tag push - **The tag door — the fallback and the backfill.** A bare `X.Y.Z` tag 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#L303-L371)) — 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 — what is left is two asserts: **the tag names the and no label check — what is left is three asserts: **the tag names the
tree's own version** tree's own version**
([L328L339](.github/workflows/release.yml#L328-L339)) and **the tagged ([L350L361](.github/workflows/release.yml#L350-L361)), **the tagged
tree carries a publishable `## X.Y.Z` section** tree carries a publishable `## X.Y.Z` section**
([L340L352](.github/workflows/release.yml#L340-L352)); either failing ([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 refuses, creating nothing. No `-dev` bump either
— the fallback does not rewrite main (cast's precedent). Use it when the — the fallback does not rewrite main (cast's precedent). Use it when the
merge path is red, for backfills, and for the merge path is red, for backfills, and for the
@ -470,7 +473,7 @@ 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 directly with a backend that is neither `file` nor `package-json`. Fix that
caller. caller.
### The merge door refused ([release.yml](.github/workflows/release.yml#L136-L301)) ### 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
@ -480,22 +483,21 @@ without its stamp (a state the
the PR — red main here means it was overridden). Stamp the section on main, the PR — red main here means it was overridden). Stamp the section on 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.
[L208L223](.github/workflows/release.yml#L208-L223), 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 do the notice resumes this door after its tag succeeded but the artifact hook or
the thing twice. If the tag exists but the release does not (a manual tag publish failed. Re-run the merge-door job first. If that run is no longer
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), or the publish and re-push the tag from the fixed tree, or run `forge_release_create` by hand.
step itself failing after the tag), recover by 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
[L293L301](.github/workflows/release.yml#L293-L301) — 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 impersonates the until it lands, main is sitting bare, where a dev install impersonates the
@ -503,29 +505,35 @@ 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#L303-L371)) ### 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 — run changelog-assemble 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. Assemble the section never stamped. Assemble the section
([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), then ([docs/CONSUMERS.md](docs/CONSUMERS.md#assembling-a-release-section)), then
delete and re-push the tag. delete and re-push the tag.
### The re-arm refused ([release.yml](.github/workflows/release.yml#L267-L301)) > 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 The bump belongs to the merge door alone — the tag door deliberately does not
rewrite main ([L303L307](.github/workflows/release.yml#L303-L307)) — and it 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 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, 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 and main is left *armed to impersonate* it, still reading the version it just
shipped ([L266](.github/workflows/release.yml#L266)). That is the one failure 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. in this catalog whose remedy is a manual bump, not a re-run.
> version_next_dev: refusing '$ver' — expected bare X.Y.Z > version_next_dev: refusing '$ver' — expected bare X.Y.Z
@ -573,7 +581,7 @@ In every case the remedy has the same shape — bump `VERSION` (or the
shipped version was bare, and where it was an rc, whatever you have decided 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 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 protection is expected, and the step opens the bump PR itself rather than
failing ([L293L301](.github/workflows/release.yml#L293-L301)). failing ([L302L310](.github/workflows/release.yml#L302-L310)).
### Red main that is not the release workflow ### Red main that is not the release workflow

View file

@ -40,6 +40,59 @@ decision during release-init. The double gate on
out-of-chain track on [heavy-duty/crew#348](https://github.com/heavy-duty/crew/issues/348) 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. 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 ## Release-init
The predecessor closing and clearing the next epic's declared gate is the The predecessor closing and clearing the next epic's declared gate is the
@ -54,7 +107,9 @@ steps:
written. Each member initially declares `Blocked by <the epic>`. written. Each member initially declares `Blocked by <the epic>`.
2. Graph hard `Blocked by` edges and same-file clusters on 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 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`. 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 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 by applying the flip mechanics below. The operator's blessing is the one
step this chain never automates. step this chain never automates.
@ -74,18 +129,50 @@ interleaving unrelated windows blurs both the release story and the evidence
behind it. Gates open windows; they do not silently admit members, so builders behind it. Gates open windows; they do not silently admit members, so builders
still see one deliberately ordered queue. still see one deliberately ordered queue.
While a window stands — an open release-labeled issue with a non-empty While a window stands — an open release-labeled issue whose membership record
enumerated gate — its members form a DAG whose sink is the release issue. holds at least one open member — its members form a DAG whose sink is the
Every member reaches that sink. Members declare only their immediate release issue. Every member reaches that sink. Members declare only their
predecessors; ordering edges live on members, while the sink records membership immediate predecessors; ordering edges live on members, while the sink records
only; and the `ready` set is exactly the graph's current sources. Every close 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 releases exactly its declared successors, and that whole set is concurrently
claimable: a member may have multiple successors, while the collision rule claimable: a member may have multiple successors, while the collision rule
already orders any that share a deliverable. Insertion re-points downstream already orders any that share a deliverable. Insertion re-points downstream
edges rather than merely appending membership at the sink. It follows that edges rather than merely appending membership at the sink. It follows that
every `ready` issue is a gate member. `epic` and `post-merge` issues are exempt every `ready` issue is a member. `epic` and `post-merge` issues are exempt
because neither is claimable (#292). 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 The operator may declare a parallel track at init when its footprint is
disjoint from the primary window: another repository, another artifact, or disjoint from the primary window: another repository, another artifact, or
provably non-overlapping clusters. The declaration names the boundary and any provably non-overlapping clusters. The declaration names the boundary and any
@ -104,6 +191,13 @@ 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 marker into non-parseable prose, then verify that the parser returns an empty
set for the release gate. 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 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 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. ordinary issue dependencies, but it does not choose a release's contents.

View file

@ -123,9 +123,9 @@ saw Y" outranks one that says "this looks like it might".
## 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.

View file

@ -1,22 +1,25 @@
# TRIAGE.md — the triage role # TRIAGE.md — the triage role
You are the only door issues come through. Humans and agents open **discussions**; You are the only door work issues come through. Humans and agents file
you decide what becomes work and set the quality builders and reviewers receive. **proposals**; you decide what becomes work and set the quality builders and
reviewers receive.
## Why this door exists ## Why this door exists
Discussions may be ambiguous; issues may not: a builder must be able to execute Proposals may be ambiguous; work issues may not: a builder must be able to
one **without asking anything**. One accountable role keeps builders from guessing. execute one **without asking anything**. One accountable role keeps builders
from guessing.
## 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. Route the work substance into a proposal and close it, saying why. Route the work
without shaming the filer. 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 tracked. Link the code, doc, or issue; mark answered. already shipped or tracked. Link the code, doc, or issue; mark answered.
@ -32,9 +35,9 @@ one **without asking anything**. One accountable role keeps builders from guessi
[BUILDER.md's canonical ruling template](BUILDER.md#the-ruling-ask), [BUILDER.md's canonical ruling template](BUILDER.md#the-ruling-ask),
including its options, recommendation, blocked/continues statement, and including its options, recommendation, blocked/continues statement, and
reversible-only default rules (#50 D12D13). reversible-only default rules (#50 D12D13).
The discussion is where humans decide; wait there. When the decision The proposal is where humans decide; wait there. When the decision
blocks something already on the board — an existing issue, or minted work blocks something already on the board — an existing issue, or minted work
a discussion's ruling gates — set `needs-ruling` on it too, so the board 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. 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**, 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 name what it waits on, and set `attention` so the assignee's ack is visible
@ -61,7 +64,7 @@ one **without asking anything**. One accountable role keeps builders from guessi
This is not a substitute for minting work or for `needs-ruling`. 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.
@ -71,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
@ -106,9 +109,11 @@ Every issue you mint carries, in this order:
new issue, dropping any predecessor the new issue now reaches (inserting X 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 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 issue but already reaches it through another member declares nothing
new; and the release issue adds the new issue to its gate, recording new; and the release issue adds a row for the new issue to its membership
membership only. Collision and window edges are independent, so write both record, which records membership only and is the only place the sweep reads
when both apply (#292). 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)).
@ -157,4 +162,4 @@ Repositories that adopt version epics follow [RELEASES.md](RELEASES.md).
- 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.6.1 0.6.4-dev

View file

@ -33,6 +33,12 @@ set -euo pipefail
# no '## ' heading, and changelog_section extracts the body below HEAD's # no '## ' heading, and changelog_section extracts the body below HEAD's
# heading — so the date HEAD stamped into its heading never enters the # heading — so the date HEAD stamped into its heading never enters the
# comparison, and a date difference can never masquerade as a prose one. # 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}}" base_ref="${1:-${CHANGELOG_ASSEMBLED_BASE:-origin/main}}"
changelog="${2:-${CHANGELOG:-CHANGELOG.md}}" changelog="${2:-${CHANGELOG:-CHANGELOG.md}}"
@ -169,6 +175,37 @@ frag_count="$(printf '%s' "$base_frags" | grep -c . || true)"
failures=0 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 # Refusal: a fragment the ceremony consumed is still present on HEAD. The
# ceremony deletes exactly what it assembles (#112) — a fragment that # ceremony deletes exactly what it assembles (#112) — a fragment that
# survives its own release sits in the directory and is assembled AGAIN # survives its own release sits in the directory and is assembled AGAIN

View file

@ -208,14 +208,14 @@ claim_reclaim_marker() { # $1 = last activity epoch
refs_references() { # PR body on stdin -> local issue numbers named by Refs refs_references() { # PR body on stdin -> local issue numbers named by Refs
awk ' awk '
{ {
line = $0 rest = tolower($0)
lower = tolower(line) while (match(rest, /(^|[^[:alnum:]_-])refs[[:space:]:]+(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/)) {
if (match(lower, /(^|[^[:alnum:]_-])refs[[:space:]:]+/)) { token = substr(rest, RSTART, RLENGTH)
line = substr(line, RSTART + RLENGTH) sub(/^.*refs[[:space:]:]+/, "", token)
if (line ~ /^(#|([[:alnum:]_.-]+\/)?[[:alnum:]_.-]+#)[0-9]+/) { print token
sub(/[.(;].*/, "", line) # Retain the token final byte so ^ cannot turn a concatenated
print line # alphanumeric suffix into a fresh keyword boundary (#234).
} rest = substr(rest, RSTART + RLENGTH - 1)
} }
} }
' | issue_references \ ' | issue_references \
@ -517,6 +517,14 @@ window_in_scope() { # $1 = comma-joined labels -> 0 subject to the window rule
unblocked_claimable "$1" unblocked_claimable "$1"
} }
board_flags_in_scope() { # $1 = queue state concluded by this issue's pass
# The board snapshot decides which issues might owe a flag, but this pass
# speaks only about the queue state it leaves behind (#327 D2). A derived
# claimed -> post-merge transition therefore cannot post the snapshot's
# now-false claim that the issue is still unblocked and claimable.
unblocked_claimable "$1"
}
collision_key_index() { # board records on stdin -> "key<TAB>number" in scope collision_key_index() { # board records on stdin -> "key<TAB>number" in scope
local n labels title key local n labels title key
while IFS=$'\t' read -r n labels title; do while IFS=$'\t' read -r n labels title; do
@ -551,20 +559,114 @@ collision_flags() { # key index on stdin -> "number<TAB>key=carrier[,key=carrier
' '
} }
window_flags() { # $1 gate members, $2 window carriers; records on stdin -> numbers window_flags() { # $1 window members, $2 window carriers; records on stdin -> numbers
local n labels title gate="$1" carriers="$2" local n labels title members="$1" carriers="$2"
[ -n "$carriers" ] || return 0 [ -n "$carriers" ] || return 0
while IFS=$'\t' read -r n labels title; do while IFS=$'\t' read -r n labels title; do
[ -n "$n" ] || continue [ -n "$n" ] || continue
window_in_scope "$labels" || continue window_in_scope "$labels" || continue
grep -qxF "$n" <<<"$gate" && continue grep -qxF "$n" <<<"$members" && continue
# The release issue is the graph's SINK, never one of its own members # The carrier is the graph's SINK, so the flag excludes it explicitly;
# (#292 D2), so it can never be its own non-member. # membership parsing is a separate decision and cannot prove this guard.
grep -qxF "$n" <<<"$carriers" && continue grep -qxF "$n" <<<"$carriers" && continue
printf '%s\n' "$n" printf '%s\n' "$n"
done done
} }
membership_references() { # release body on stdin -> its enumerated members
# The membership record (#343 D2), read by HEADING and never by a marker
# phrase. `blocked_reference_records` unions every occurrence of its marker
# and runs each clause to its own sentence terminator — deliberate, and the
# right error direction for a `blocked` issue, but the wrong one here: a
# release body is mostly narration ABOUT its members, so a phrase parser
# takes references out of the prose. That is the mechanism that put
# heavy-duty/crew's `0.2.0` epic inside its own gate. The heading match is
# anchored for the same reason: crew#346 carries a literal
# `## The members, in claim order` heading, which a substring match reads as
# the record and an anchored one does not.
#
# One member per row, and it is the row's FIRST token after the list marker
# and an optional checkbox. `epic_references` prints the whole row and takes
# every local reference in it, which is right for a progress view and wrong
# here — measured on crew#346, whose member rows carry merged PR numbers,
# another repository's issues, and one issue annotated in its own row as
# explicitly NOT a member of the window. A first token that is not a bare
# local `#<number>` contributes nothing: silence, not a guess. A qualified
# reference is never a member either, because a window is one repository's
# DAG decided against one board read.
#
# A row is any Markdown list row, so the marker class is the whole CommonMark
# set and exactly it — `-`, `*`, `+`, and 1 to 9 digits then `.` or `)`
# (CommonMark 5.2). Recognising only some of them would drop a row a human
# wrote, and reads, as a member: silence is the correct answer to a row whose
# first token is not a bare local reference, and the wrong one to a member
# enumerated under a marker this parse did not know. Recognising MORE than
# them is the same error mirrored: `1234567890. #412` is not a list row to
# any renderer, so reading it as one takes a member out of narration, and one
# phantom open member keeps a window standing and suppresses its non-member
# flag. The bound is written twice, in the row match and in the strip, and
# both are pinned. `epic_references` matches a narrower class; it is a
# progress view with its own fixtures and is byte-unchanged here (#343 D7).
#
# Indentation is bounded the same way and for the same reason: at most three
# spaces open a row (CommonMark 4.4), and a leading tab is four columns of it
# wherever indentation decides block structure. Past that bound the line is
# not a top-level row, and which non-row it is depends on context this parse
# does not carry — GitHub renders ` - #412` after `## Members` as
# `<pre><code>` and the same bytes under a `- #N` row as a nested `<li>`. The
# record is FLAT, so both are silence: an indented code block is not a row at
# all, a sub-bullet annotating a member row is not a second member, and
# enrolling either is the tenth digit's phantom-member direction one axis
# over. Below the bound the answer goes the other way for the same reason: one
# to three spaces is byte-identical to a top-level row a human indented, so it
# enrols, and the sub-row that shape can also be is the price (#348).
awk '
tolower($0) ~ /^##[[:space:]]+members[[:space:]]*$/ { in_record = 1; next }
in_record && /^#/ { exit }
in_record && /^ {0,3}([-*+]|[0-9]{1,9}[.)])[[:space:]]+/ {
row = $0
sub(/^ {0,3}([-*+]|[0-9]{1,9}[.)])[[:space:]]+/, "", row)
sub(/^\[[ xX]\][[:space:]]+/, "", row)
split(row, token, "[[:space:]]+")
if (token[1] ~ /^#[0-9]+$/) print substr(token[1], 2)
}
' | sort -nu
}
release_window_records() { # $1 carrier, $2 open numbers; refs on stdin -> carrier<TAB>member
# A carrier is never a member of its own window (#327 D1, which #343 D5
# inherits rather than re-decides). Remove it before deciding whether any
# open member makes the window stand, and before returning every non-self
# reference that contributes to WINDOW_MEMBERS. One function, so the two
# readings below can never drift apart on that guard.
local carrier="$1" open_numbers="$2" members member
members="$(awk -v carrier="$carrier" '$0 != carrier')"
[ -n "$members" ] || return 0
grep -qxF -f <(printf '%s\n' "$open_numbers") <<<"$members" || return 0
while IFS= read -r member; do
[ -n "$member" ] && printf '%s\t%s\n' "$carrier" "$member"
done <<<"$members"
}
release_window_gate() { # $1 carrier, $2 open issue numbers; body on stdin -> carrier<TAB>member
# #327 D1's reading of a release issue's `Blocked by` set, kept whole and
# kept driven. The window stopped consuming it at #343 D3 — a release
# epic's declaration answers its predecessor gate and nothing else — so
# what this keeps standing is the self-exclusion guard's other half: the
# gate side and the membership side share release_window_records, and this
# is where a change to it that only the gate could see reds.
blocked_references | release_window_records "$1" "$2"
}
release_window_members() { # $1 carrier, $2 open issue numbers; body on stdin -> carrier<TAB>member
# What the carrier decision reads (#343 D3). No fallback to the gate when
# the record is absent (#343 D4): a release issue enumerating no membership
# is not a carrier, and the board draws no window flag. A fallback would
# reinstate the misreading for precisely the bodies that have not been
# migrated, which is where it does its damage.
membership_references | release_window_records "$1" "$2"
}
window_state() { # $1 = window carriers -> the rendered state, "#249" | "#249, #250" window_state() { # $1 = window carriers -> the rendered state, "#249" | "#249, #250"
awk 'NF { printf "%s#%s", (shown++ ? ", " : ""), $1 } END { printf "\n" }' <<<"$1" awk 'NF { printf "%s#%s", (shown++ ? ", " : ""), $1 } END { printf "\n" }' <<<"$1"
} }
@ -728,7 +830,7 @@ last_issue_comment_activity() { # $1 issue, $2 created_at → epoch; non-zero on
issue_activity_at "$1" "$2" comments-only issue_activity_at "$1" "$2" comments-only
} }
reconcile_board_flags() { # $1 = issue — the collision and window flags (#293) reconcile_board_flags() { # $1 issue, $2 concluded queue state — board flags (#293)
# Dedup is the declaration echo's, per family (#293 D4): the marker is # Dedup is the declaration echo's, per family (#293 D4): the marker is
# keyed to the offending state's VALUE and compared against this family's # keyed to the offending state's VALUE and compared against this family's
# last word on the thread, so a state that changes speaks and a state that # last word on the thread, so a state that changes speaks and a state that
@ -743,6 +845,7 @@ reconcile_board_flags() { # $1 = issue — the collision and window flags (#293)
# board fact that is true right now, and a board where the fact never # board fact that is true right now, and a board where the fact never
# changed has nothing new to say. # changed has nothing new to say.
local n="$1" state marker rendered local n="$1" state marker rendered
board_flags_in_scope "$2" || return 0
state="$(flag_for_issue "$n" "${COLLISION_FLAGS:-}")" state="$(flag_for_issue "$n" "${COLLISION_FLAGS:-}")"
if [ -n "$state" ]; then if [ -n "$state" ]; then
marker="$(state_marker collision "$state")" marker="$(state_marker collision "$state")"
@ -780,20 +883,22 @@ marker carries the collision itself, so an unchanged one never re-posts.*" >/dev
if state_echo_needed "$n" window-nonmember "$marker"; then if state_echo_needed "$n" window-nonmember "$marker"; then
run forge_issue_comment "$n" "<!-- issueflow:$marker --> run forge_issue_comment "$n" "<!-- issueflow:$marker -->
A release window is standing ($state) and this issue is neither one of its A release window is standing ($state) and this issue is neither one of its
gate members nor an \`epic\` or \`post-merge\` issue. members nor an \`epic\` or \`post-merge\` issue.
#292's invariant: during a standing window — an open \`release\`-labeled issue #292's invariant: during a standing window — an open \`release\`-labeled issue
with a non-empty gate — the \`ready\` set is a subset of the gate, \`epic\` and with a non-empty membership record — the \`ready\` set is a subset of that
\`post-merge\` exempt. Every mint during a window is a membership call, binary, record, \`epic\` and \`post-merge\` exempt. Every mint during a window is a
made at mint time: **behind the gate**, this issue's own Dependencies declare membership call, binary, made at mint time: **behind the gate**, this issue's
the release issue as a blocker and the sweep releases it when the release own Dependencies declare the release issue as a blocker and the sweep releases
closes; or **into the graph**, three writes in one tick — this issue declares it when the release closes; or **into the graph**, three writes in one tick —
its immediate predecessors, every member whose immediate predecessor it this issue declares its immediate predecessors, every member whose immediate
becomes re-points to it, and the release issue gains \`Blocked by #N\`, which predecessor it becomes re-points to it, and the release issue gains a row for
records membership and nothing else. Silence is not a state. this issue in its membership record. Silence is not a state.
The gate is read from the release issue's own \`Blocked by\` declarations — the Membership is read from the release issue's own \`## Members\` record: the rows
same parse every \`blocked\` issue is gated on, echoed on that issue. under that heading, one member each, the row's first token a bare \`#N\` and
everything after it prose. A \`Blocked by\` declaration on a release issue
answers its predecessor gate and never its membership (#343).
*Comment only: nothing on this path writes a label or changes a state. The *Comment only: nothing on this path writes a label or changes a state. The
marker carries the window itself, so an unchanged one never re-posts.*" >/dev/null marker carries the window itself, so an unchanged one never re-posts.*" >/dev/null
@ -811,9 +916,14 @@ reconcile_issue() {
local merged_ref_pr="" transition_marker="" transition_handled=false parsed_set="" parse_marker="" local merged_ref_pr="" transition_marker="" transition_handled=false parsed_set="" parse_marker=""
local unchecked="" remove_claimed=claimed local unchecked="" remove_claimed=claimed
local attention_active=true attention_suppression="" local attention_active=true attention_suppression=""
local concluded_queue_state=""
for label in needs-triage epic "${QUEUE_LABELS[@]}"; do
has_issue_label "$label" && concluded_queue_state="$label"
done
decision="$(queue_decision <<<"$ISSUE_LABELS")" decision="$(queue_decision <<<"$ISSUE_LABELS")"
case "$decision" in case "$decision" in
ADD_NEEDS_TRIAGE) ADD_NEEDS_TRIAGE)
concluded_queue_state=needs-triage
run forge_issue_edit "$n" --add-label needs-triage >/dev/null run forge_issue_edit "$n" --add-label needs-triage >/dev/null
log "#$n: needs-triage (no queue state)" ;; log "#$n: needs-triage (no queue state)" ;;
FLAG_CONFLICT) FLAG_CONFLICT)
@ -867,6 +977,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
--remove-label "$remove_claimed" --add-label post-merge >/dev/null --remove-label "$remove_claimed" --add-label post-merge >/dev/null
fi fi
log "#$n: merged Refs PR -> post-merge; claim released" log "#$n: merged Refs PR -> post-merge; claim released"
concluded_queue_state=post-merge
attention_active=false attention_active=false
else else
created="$(jq -r '.created_at' <<<"$ISSUE_JSON")" created="$(jq -r '.created_at' <<<"$ISSUE_JSON")"
@ -897,6 +1008,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
else else
run forge_issue_edit "$n" --remove-label claimed --add-label ready >/dev/null run forge_issue_edit "$n" --remove-label claimed --add-label ready >/dev/null
fi fi
concluded_queue_state=ready
log "#$n: stale claim reclaimed -> ready" ;; log "#$n: stale claim reclaimed -> ready" ;;
esac esac
[ "$decision" != FLAG_UNASSIGNED ] || attention_suppression=claimed-unassigned [ "$decision" != FLAG_UNASSIGNED ] || attention_suppression=claimed-unassigned
@ -1020,6 +1132,7 @@ itself, so a parse unchanged since the last echo never re-posts.*" >/dev/null
ensure_comment "$n" blockers-cleared \ ensure_comment "$n" blockers-cleared \
'Every issue named by `Blocked by` is closed. The sweep is moving this issue to `ready`.' 'Every issue named by `Blocked by` is closed. The sweep is moving this issue to `ready`.'
run forge_issue_edit "$n" --remove-label blocked --add-label ready >/dev/null run forge_issue_edit "$n" --remove-label blocked --add-label ready >/dev/null
concluded_queue_state=ready
log "#$n: blockers closed -> ready" ;; log "#$n: blockers closed -> ready" ;;
esac esac
elif has_issue_label epic; then elif has_issue_label epic; then
@ -1036,7 +1149,7 @@ itself, so a parse unchanged since the last echo never re-posts.*" >/dev/null
1. Mint the window's members. 1. Mint the window's members.
2. Graph hard dependencies and same-file clusters. 2. Graph hard dependencies and same-file clusters.
3. Write ordered waves and the progress task list. 3. Write ordered waves, the \`## Members\` record, and the progress task list.
4. Ask the operator to bless the order, then open the first wave. 4. Ask the operator to bless the order, then open the first wave.
5. Ship the release, close this epic, and trigger the next window. 5. Ship the release, close this epic, and trigger the next window.
@ -1066,7 +1179,7 @@ See \`$release_doctrine_path\`. The operator blessing the order is the one step
# compose with every queue state, and FLAG_CONFLICT's early return still # compose with every queue state, and FLAG_CONFLICT's early return still
# short-circuits them, because a board lying about its queue state is # short-circuits them, because a board lying about its queue state is
# repaired before anything is derived from it. # repaired before anything is derived from it.
reconcile_board_flags "$n" reconcile_board_flags "$n" "$concluded_queue_state"
# ---- the ruling invariants (#52), on any queue state ---- # ---- the ruling invariants (#52), on any queue state ----
# The flag composes with the queue labels (#50 D8), so this runs after the # The flag composes with the queue labels (#50 D8), so this runs after the
@ -1249,7 +1362,7 @@ main() {
done < <(printf '%s' "$b64" | base64 -d | refs_references) done < <(printf '%s' "$b64" | base64 -d | refs_references)
done)" done)"
local n tail_line issue_numbers board_json release_bodies rn rbody gate body local n tail_line issue_numbers board_json release_numbers rn window_records body
local window_rendered="" local window_rendered=""
SKIPPED_COUNT=0 SKIPPED_COUNT=0
SKIPPED_ISSUES="" SKIPPED_ISSUES=""
@ -1274,31 +1387,39 @@ main() {
| @tsv' \ | @tsv' \
<<<"$board_json")" <<<"$board_json")"
issue_numbers="$(cut -f1 <<<"$BOARD_RECORDS")" issue_numbers="$(cut -f1 <<<"$BOARD_RECORDS")"
# A standing window is an open `release`-labeled issue whose gate still # A standing window is an open `release`-labeled issue whose MEMBERSHIP
# holds an OPEN member (#292 D1). The board read IS the open set, so # RECORD still holds an OPEN member (#292 D1 as #343 D3 re-reads it). The
# membership decides openness with no extra call — and an all-closed gate # board read IS the open set, so membership decides openness with no extra
# is exactly the emptied gate the release's own `blocked` -> `ready` # call — and an all-closed record is exactly the emptied window the
# promotion answers, which is why a `ready` release leaves the flag # release's own `blocked` -> `ready` promotion answers, which is why a
# dormant rather than flagging the whole board. # `ready` release leaves the flag dormant rather than flagging the board.
release_bodies="$(jq -r '.[] | select(.pull_request == null) #
# The record is read by heading, so each body must reach the parse with its
# LINE STRUCTURE INTACT. Taking it from the board payload by issue number
# preserves that structure without fetching a second, disagreeing board.
release_numbers="$(jq -r '.[] | select(.pull_request == null)
| select((.labels // []) | map(.name) | index("release")) | select((.labels // []) | map(.name) | index("release"))
| [(.number | tostring), ((.body // "") | gsub("[\t\r\n]"; " "))] | @tsv' \ | .number' \
<<<"$board_json")" <<<"$board_json")"
WINDOW_CARRIERS="" WINDOW_CARRIERS=""
WINDOW_GATE="" WINDOW_MEMBERS=""
if [ -n "$issue_numbers" ]; then if [ -n "$issue_numbers" ]; then
while IFS=$'\t' read -r rn rbody; do window_records="$(
while IFS= read -r rn; do
[ -n "$rn" ] || continue [ -n "$rn" ] || continue
gate="$(blocked_references <<<"$rbody")" jq -r --argjson n "$rn" '.[] | select(.pull_request == null)
[ -n "$gate" ] || continue | select(.number == $n) | .body // ""' <<<"$board_json" \
grep -qxF -f <(printf '%s\n' "$issue_numbers") <<<"$gate" || continue | release_window_members "$rn" "$issue_numbers"
WINDOW_CARRIERS="${WINDOW_CARRIERS}${rn}"$'\n' done <<<"$release_numbers"
WINDOW_GATE="${WINDOW_GATE}${gate}"$'\n' )"
done <<<"$release_bodies" # One record per parsed non-self member keeps the carrier decision and
# its WINDOW_MEMBERS contribution coupled to the extracted function.
WINDOW_CARRIERS="$(cut -f1 <<<"$window_records" | awk 'NF' | sort -nu)"
WINDOW_MEMBERS="$(cut -f2 <<<"$window_records" | awk 'NF' | sort -nu)"
fi fi
[ -z "$WINDOW_CARRIERS" ] || window_rendered="$(window_state "$WINDOW_CARRIERS")" [ -z "$WINDOW_CARRIERS" ] || window_rendered="$(window_state "$WINDOW_CARRIERS")"
COLLISION_FLAGS="$(collision_key_index <<<"$BOARD_RECORDS" | collision_flags)" COLLISION_FLAGS="$(collision_key_index <<<"$BOARD_RECORDS" | collision_flags)"
WINDOW_FLAGS="$(window_flags "$WINDOW_GATE" "$WINDOW_CARRIERS" <<<"$BOARD_RECORDS" \ WINDOW_FLAGS="$(window_flags "$WINDOW_MEMBERS" "$WINDOW_CARRIERS" <<<"$BOARD_RECORDS" \
| awk -v state="$window_rendered" 'NF { print $1 "\t" state }')" | awk -v state="$window_rendered" 'NF { print $1 "\t" state }')"
if [ -z "$issue_numbers" ]; then if [ -z "$issue_numbers" ]; then
log "no open issues." log "no open issues."

View file

@ -270,9 +270,10 @@ set_required_bots() { # the PR author is recused by construction
# The state machine. Pure functions over these 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 (the release-shape guard's ref) # 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 # LABELS newline-separated labels currently on the PR
@ -282,35 +283,6 @@ set_required_bots() { # the PR author is recused by construction
requested() { grep -qxF "$1" <<<"$REQUESTED"; } requested() { grep -qxF "$1" <<<"$REQUESTED"; }
# outstanding_requests <requested-logins> — the portable "who still owes a
# verdict on THIS head" (issue #188, term 4).
#
# GitHub clears requested_reviewers when a verdict lands, so on that forge the
# field already answers this question and the filter below removes nothing.
# **Forgejo does not clear it.** Measured 2026-08-02: rig!140 listed all three
# panelists with all three verdicts in, and rig!146 still lists three while
# MERGED — the field is stale even on a closed PR, so it over-counts forever.
#
# Reading it raw on Forgejo pins a PR at state:bots-reviewing for life and
# stops blocker:unrequested from ever being true: the sweep believes a round
# is permanently live. So the requested set is intersected with "has not
# submitted a verdict for the current head", which is derived from
# /pulls/{n}/reviews — the read that is true on both forges.
#
# Pure over REVIEWS_JSON/HEAD_SHA so the fixtures can drive it; a reviewer
# whose only verdict is STALE still owes one, which is why this asks
# bot_verdict rather than merely "has any review".
outstanding_requests() {
local login
while IFS= read -r login; do
[ -n "$login" ] || continue
case "$(bot_verdict "$login")" in
APPROVE | BLOCK | FEEDBACK) continue ;;
esac
printf '%s\n' "$login"
done <<<"${1-}"
}
checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | UNREADABLE checks_state() { # rollup JSON on stdin → SUCCESS | FAILURE | PENDING | NONE | UNREADABLE
# UNREADABLE is the absence of the key itself, which is what a failed fetch # UNREADABLE is the absence of the key itself, which is what a failed fetch
# leaves behind — distinct from a present-but-empty rollup, which honestly # leaves behind — distinct from a present-but-empty rollup, which honestly
@ -444,18 +416,25 @@ 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
} }
@ -737,7 +716,7 @@ offsite|CFD3D7|Issue deliverable is a PR in another repository — claim clock p
needs-ruling|D4C5F9|A human decision is pending — question, options and a recommendation are in the comment 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 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 post-merge|006B75|Refs-linked PR merged; post-merge criteria remain and triage owns completion
@ -745,7 +724,7 @@ epic|5319E7|Organizes other issues via a dependency-ordered task list — builde
EOF EOF
} }
retired_label_names() { # the GitHub defaults LABELS.md retires — a `question` is a discussion 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 # 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 # 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. # cannot live in a per-repo file. The six names match LABELS.md exactly.
@ -940,7 +919,8 @@ reconcile_pr() { # $1 = PR number; relies on the globals set from its fetch
# Drafts are exempt (the build phase is the builder's); the version # Drafts are exempt (the build phase is the builder's); the version
# reads cost two API calls and only on PRs missing the label. # reads cost two API calls and only on PRs missing the label.
if [ "$DRAFT" != true ] && ! has_label release; then if [ "$DRAFT" != true ] && ! has_label release; then
release_shape_warning "$n" "$(tree_version "$HEAD_SHA")" "$(tree_version "$BASE_SHA")" release_shape_warning "$n" "$(tree_version "$HEAD_SHA")" \
"$(tree_version "${MERGE_BASE_SHA:-$BASE_SHA}")"
fi fi
# ---- merge-next: cleared, never set ---------------------------------- # ---- merge-next: cleared, never set ----------------------------------
@ -1053,14 +1033,21 @@ main() {
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")" 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")"
# PENDING reviews are unsubmitted drafts in someone's browser — not a verdict # This allow-list answers whether a row is a submitted, gradeable review;
# bot_verdict separately answers what that submitted verdict says (#235).
# PENDING drafts and Forgejo REQUEST_REVIEW request rows are not reviews.
REVIEWS_JSON="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \ REVIEWS_JSON="$(forge_api --paginate "repos/$REPO/pulls/$n/reviews" --jq '.[]' \
| jq -s '[.[] | select(.state != "PENDING")]')" | jq -s '[.[] | select(.state == "APPROVED"
# Read AFTER the reviews, because the raw field is not portable: Forgejo or .state == "CHANGES_REQUESTED"
# never clears it, so it is intersected with who still owes a verdict on or .state == "REQUEST_CHANGES"
# this head (#188 term 4). A no-op on GitHub, which clears it itself. or .state == "COMMENTED"
REQUESTED="$(outstanding_requests "$(jq -r '.requested_reviewers[].login' <<<"$PR_JSON")")" 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

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).

View file

@ -337,14 +337,18 @@ The labels automation is two reusable workflows since #209, adopted
together at the same pin: together at the same pin:
- **`labels.yml`** — the event-facing half, called on PR and issue events. - **`labels.yml`** — the event-facing half, called on PR and issue events.
Two jobs: additive path-based `scope:*` labels, and a few-seconds Same-repository PRs keep two write-capable jobs: additive path-based
`trigger` job that wakes the sweep by dispatching the consumer's sweep `scope:*` labels, and a few-seconds `trigger` job that wakes the sweep by
caller (a REST `POST` to the forge's own dispatching the consumer's sweep caller (a REST `POST` to the forge's own
`${GITHUB_API_URL}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches`, `${GITHUB_API_URL}/repos/{owner}/{repo}/actions/workflows/{file}/dispatches`,
plain `GITHUB_TOKEN``workflow_dispatch` is plain `GITHUB_TOKEN``workflow_dispatch` is
one of the two documented exemptions from the token's no-retrigger rule, 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 so no PAT anywhere in the path and no loop: the sweep dispatches
nothing). 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, - **`labels-sweep.yml`** — the reconcile sweep: PR state, blockers,
handoff, stale status, the issue work queue, and the `needs-ruling` handoff, stale status, the issue work queue, and the `needs-ruling`
invariants on both surfaces — the bare-flag check and the 7-day invariants on both surfaces — the bare-flag check and the 7-day
@ -356,8 +360,10 @@ together at the same pin:
as fake red CI that GitHub refuses to rerun (crew#250: `gh run rerun` 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). and its `--failed`/`--job` forms all decline a queue-displaced run).
Behind its own caller, a displaced sweep cancels on the Behind its own caller, a displaced sweep cancels on the
Actions tab, attached to no PR; PR checks show `scope` and the green Actions tab, attached to no PR. Same-repository PR checks show `scope` and
`trigger` only. 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 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`.
@ -384,11 +390,14 @@ The complete event-facing caller is:
name: labels name: labels
on: on:
pull_request_target: pull_request_target:
# Fork PRs; these carry the head/draft/review facts state:* derives from. # These carry the head/draft/review facts state:* derives from. Same-repo
# labeled/unlabeled are the handoff wake (state:needs-human confirmed here); # heads take the instant write + sweep-dispatch path; this Forgejo gives
# synchronize re-derives on every push. review_requested/review_request_removed # fork heads a read-only token, so state, blocker, and handoff reconciliation
# (shipped in 0.3.0, ceremony#137) wake the sweep that clears # waits for the scheduled sweep; path-derived scope labels require a manual
# blocker:unrequested when the panel is asked. # 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] 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 # Available at 0.2.0 and later (the first tag carrying ceremony#32); a
# consumer pinned to 0.1.0 omits this block. # consumer pinned to 0.1.0 omits this block.
@ -425,22 +434,24 @@ name: labels-sweep
on: on:
# The consumer owns this cadence (#203). Hourly is the recommended default # 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 # when no other engine drives board state: the cron is then the sweep's only
# wake for four transition classes — a review verdict landing (no # wake for a review verdict landing (the labels caller has no
# pull_request_review trigger on the labels caller), blocker:ci-red # pull_request_review trigger), blocker:ci-red
# set/cleared, blocker:conflict when another PR merges under this one, and # set/cleared, blocker:conflict when another PR merges under this one, and
# time-based stale / 48h claim-reclaim. The labels caller's events carry the # 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 # rest in seconds, one trigger-job dispatch away. Hourly trades ≤1h of
# latency on those four while cutting nominal scheduled sweeps from four an # latency on the scheduled classes while cutting nominal
# hour to one at GitHub's 1-minute floor. Do not delete the cron: it is their # sweeps from four an hour to one at GitHub's 1-minute floor. Do not delete
# discovery path. If another engine writes some of those transitions, only # the cron: it is their discovery path. If another engine writes some of
# the classes with no other writer bound the cadence; relax it only as that # those transitions, only the classes with no other writer bound the cadence;
# list shrinks. # relax it only as that list shrinks.
schedule: [{cron: "0 * * * *"}] schedule: [{cron: "0 * * * *"}]
# A manual full-board sweep. A bare dispatch (input default "yes") also # 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 # bootstraps the taxonomy on a fresh repo. The labels caller's trigger job
# wakes this workflow with bootstrap=no on every board event, so the # wakes this workflow with bootstrap=no on every issue and same-repo PR
# declared input is part of the contract: a dispatch naming an undeclared # event, so the declared input is part of the contract: a dispatch naming an
# input is refused, and the trigger job goes loudly red. # undeclared input is refused, and the trigger job goes loudly red.
workflow_dispatch: workflow_dispatch:
inputs: inputs:
bootstrap: bootstrap:
@ -477,17 +488,22 @@ 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 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 `state:*` axis on the board rather than a red workflow run. The labels
caller's `actions: write` is different — it is required everywhere, public caller's `actions: write` is different — it is required everywhere, public
repos included: the trigger job's dispatch is a write, and without it every repos included: the trigger job's dispatch is a write. Without it, issue and
event run goes red at the trigger. 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 **The failure mode to know before bumping**: a consumer that bumps its pin
to a #209-carrying tag without adding the sweep caller keeps green-looking to a #209-carrying tag without adding the sweep caller gets a loud red trigger
silence nowhere — the trigger job goes **red on every PR and issue event** on every issue and same-repository PR event (workflow-not-found; likewise on a
(workflow-not-found; likewise on a sweep caller missing its `bootstrap` sweep caller missing its `bootstrap` input, or a labels caller missing
input, or a labels caller missing `actions: write`), and event-woken sweeps `actions: write`). Fork-headed PR runs deliberately skip that trigger and stay
stop until the caller lands. That loudness is deliberate: never read green, so their correctness is proven by the sweep caller's presence and its
silence, or a green `scope` alone, as health. Make the adoption one atomic latest scheduled run, not by the PR check alone. Never read a green
PR — pin bump, sweep caller file, `actions: write` line together. `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 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 first tag carrying ceremony#32. A consumer pinned to `0.1.0` omits it. Adopt
@ -538,20 +554,25 @@ carrying the split:
`actions: read` today (crew does); the trigger job's dispatch is a `actions: read` today (crew does); the trigger job's dispatch is a
write. The sweep caller keeps `actions: read`. write. The sweep caller keeps `actions: read`.
Bump without the sweep caller and the trigger job goes red on every PR Bump without the sweep caller and the trigger job goes red on every issue and
and issue event — the loud failure mode above — so never split these same-repository PR event. Fork-headed PRs stay green, receive state, blocker,
four edits across PRs. 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: fork PRs need the base repository's `pull_request_target` is intentional: same-repository PRs keep the base
token to write labels. The reusable workflows execute no PR code. They check 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. out only the consumer's base branch and the pinned ceremony implementation.
The #52 ruling invariants ride exactly these triggers — but the caller above 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 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 a bare pin bump. `review_requested` and `review_request_removed` on
`pull_request_target:` shipped in `0.3.0` (ceremony#137) — the wake that `pull_request_target:` shipped in `0.3.0` (ceremony#137). It clears
clears `blocker:unrequested` the moment the panel is asked, without which a `blocker:unrequested` the moment the panel is asked on a same-repository head;
quiet repo wears that flag until the backstop cron; a consumer picks them up fork heads wait for the sweep cadence on this Forgejo. A consumer picks the
by pinning `0.3.0` or later, never through mixed refs. events up by pinning `0.3.0` or later, never through mixed refs.
`.github/labels.conf` has one mandatory panel setting, one mandatory `.github/labels.conf` has one mandatory panel setting, one mandatory
`triage-actors` setting, zero or more optional per-author panel rows, and `triage-actors` setting, zero or more optional per-author panel rows, and
@ -584,7 +605,7 @@ goes down. Add the row only at or after the pin bump that carries it, never
before it and never through mixed refs. before it and never through mixed refs.
Both actor lists are whitespace-separated. `triage-actors` names the identities Both actor lists are whitespace-separated. `triage-actors` names the identities
allowed to mint issues without the sweep applying `needs-triage`. Label rows use exactly 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 **Every account in `panel=` must be able to read the repository.** Requesting a
@ -830,15 +851,16 @@ 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
@ -872,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

View file

@ -368,8 +368,25 @@ memory.
caller's `event.inputs` on this instance. Records: caller's `event.inputs` on this instance. Records:
[probe issues #4 and #5](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/4) [probe issues #4 and #5](https://forgejo.heavyduty.builders/heavy-duty/ceremony-runner-probe/issues/4)
(runs 6 and 7). (runs 6 and 7).
- A 0.6.0 consumer exercise once ceremony#198 has merged. (#198 merged - **ceremony#217** — DELIVERED (2026-08-09). The standing venue used the
2026-08-05; this probe remains open.) 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 Two venue lessons from the first drills, kept where the next probe author will
look: look:

View file

@ -17,6 +17,47 @@ resolution is standing and does not get re-decided each sync.
Worked example throughout: the `0.6.0` sync (#197, #198), which merged upstream Worked example throughout: the `0.6.0` sync (#197, #198), which merged upstream
`8c3a4d1` onto `dad99dd` and took four heads to get green. `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 ## The standing resolutions
These recur every sync. They are decided; re-deciding them is the cost this These recur every sync. They are decided; re-deciding them is the cost this

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

@ -84,10 +84,10 @@ drill owed, that verdict wins.
except the `CEREMONY_SELF_REF` pin line in except the `CEREMONY_SELF_REF` pin line in
`.github/workflows/release.yml`. `.github/workflows/release.yml`.
2. The release path is exactly the output of 2. The release path is exactly the output of
`.github/scripts/release-path.sh`: `.github/workflows/release.yml`, `bin/`, `.github/scripts/release-path.sh`. Run the script and paste its output into
`lib/version.sh`, `lib/decide.sh`, `lib/facts.sh`, and the record; measure the diff with
`lib/changelog.sh`. The script is the record author's copy-paste source; `git diff <last-rehearsed-tag>..HEAD -- $(sh .github/scripts/release-path.sh)`.
its contract test keeps this inline list and the workflow's direct and The script's contract test keeps its list and the workflow's direct and
transitive dependencies in agreement. transitive dependencies in agreement.
3. The last rehearsed tag's own record is a full rehearsal, its release is 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. published, and `main` was re-armed to `-dev` after it.

View file

@ -25,12 +25,12 @@ forgejo_api_base() {
# THE TRAP, measured on this instance with REPO unset (#191, caught by # THE TRAP, measured on this instance with REPO unset (#191, caught by
# @kimi on !193 before it shipped): # @kimi on !193 before it shipped):
# #
# forge_release_exists 0.4.1 -> "no", rc 0 (repos//releases/tags/0.4.1 # forge_release_exists 0.4.1 -> "no", rc 0 (repos//releases/tags/0.4.1
# 404s; a repo-less path read # 404s; a repo-less path read
# as "the release does not # as "the published release
# exist" — and the # does not exist" — and the
# nothing-exists assert would # nothing-exists assert would
# then proceed to CREATE) # then proceed to CREATE)
# forge_commit_pulls <sha> -> "[]", rc 0 (a commit that HAS a merged # forge_commit_pulls <sha> -> "[]", rc 0 (a commit that HAS a merged
# PR behind it, read as none) # PR behind it, read as none)
# #
@ -84,7 +84,7 @@ forgejo_page_url() {
esac esac
} }
# forge_api [--paginate] <endpoint> [--jq <expr>] # forge_api [--paginate | --paginate-exhaustive] <endpoint> [--jq <expr>]
# #
# --paginate walks page= until a short page, then PROVES the walk was # --paginate walks page= until a short page, then PROVES the walk was
# complete by comparing what it collected against the server's declared # complete by comparing what it collected against the server's declared
@ -94,11 +94,26 @@ forgejo_page_url() {
# make the completeness check compare null to a number — the guard itself # make the completeness check compare null to a number — the guard itself
# degrading silently, which is the failure class re-entering through the # degrading silently, which is the failure class re-entering through the
# door built to stop it. # 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() { forge_api() {
local paginate=false endpoint="" jqexpr="" have_jq=false local paginate=false paginate_exhaustive=false method=GET endpoint="" jqexpr="" have_jq=false
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--paginate) paginate=true ;; --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 ;; --jq) jqexpr="$2"; have_jq=true; shift ;;
-*) ;; -*) ;;
*) [ -n "$endpoint" ] || endpoint="$1" ;; *) [ -n "$endpoint" ] || endpoint="$1" ;;
@ -106,6 +121,14 @@ forge_api() {
shift shift
done done
[ -n "$endpoint" ] || { echo "forge_api: endpoint required" >&2; return 1; } [ -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 local base token
base="$(forgejo_api_base)" || return 1 base="$(forgejo_api_base)" || return 1
@ -116,7 +139,7 @@ forge_api() {
# shellcheck disable=SC2064 # the paths are fixed at trap time on purpose # shellcheck disable=SC2064 # the paths are fixed at trap time on purpose
trap "rm -f '$hdr' '$body'" RETURN trap "rm -f '$hdr' '$body'" RETURN
if [ "$paginate" = false ]; then if [ "$paginate" = false ] && [ "$paginate_exhaustive" = false ]; then
if ! curl -sS -D "$hdr" -o "$body" \ if ! curl -sS -D "$hdr" -o "$body" \
-H "Authorization: token $token" -H 'Accept: application/json' \ -H "Authorization: token $token" -H 'Accept: application/json' \
"$base/$endpoint"; then "$base/$endpoint"; then
@ -142,6 +165,7 @@ forge_api() {
fi fi
forgejo_http_ok "$hdr" "GET $endpoint" || return 1 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 # 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 # 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 # 9 stopped at 4 believing itself whole. A moving total means the read
@ -158,6 +182,7 @@ forge_api: the declared total for '$endpoint' changed between pages — $total t
EOF EOF
return 1 return 1
fi fi
fi
pagejson="$(cat "$body")" pagejson="$(cat "$body")"
# A 200 whose body is not a collection counted as zero items (#4712), # A 200 whose body is not a collection counted as zero items (#4712),
@ -175,13 +200,17 @@ EOF
[ "$n" -gt 0 ] || break [ "$n" -gt 0 ] || break
all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")" all="$(jq -s '.[0] + .[1]' <<<"$all"$'\n'"$pagejson")"
got=$((got + n)) got=$((got + n))
if [ "$paginate_exhaustive" = true ]; then
[ "$n" -eq 50 ] || break
else
[ "$got" -lt "$total" ] || break [ "$got" -lt "$total" ] || break
fi
page=$((page + 1)) page=$((page + 1))
done done
# The assert. A short read here is the silent-truncation bug arriving by # The assert. A short read here is the silent-truncation bug arriving by
# another route, so it is fatal rather than a warning. # another route, so it is fatal rather than a warning.
if [ "$got" -ne "$total" ]; then if [ "$paginate_exhaustive" = false ] && [ "$got" -ne "$total" ]; then
cat >&2 <<EOF cat >&2 <<EOF
forge_api: incomplete gather for '$endpoint' — collected $got of $total declared (#188). 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 Refusing rather than reconciling a partial board: a sweep over part of the
@ -429,13 +458,20 @@ forge_pr_view() {
status="$(forge_api "repos/$REPO/commits/$sha/status")" || return 1 status="$(forge_api "repos/$REPO/commits/$sha/status")" || return 1
jq -n --argjson pr "$pr" --argjson st "$status" ' jq -n --argjson pr "$pr" --argjson st "$status" '
{ {
mergeable: (if $pr.mergeable == true then "MERGEABLE" # Forgejo folds checking, conflict, check error, and WIP into false.
elif $pr.mergeable == false then "CONFLICTING" # Draft must win because WIP makes the boolean carry no merge result (#236).
else "UNKNOWN" end), 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: [ statusCheckRollup: [
$st.statuses[]? | { $st.statuses[]? | {
__typename: "StatusContext", __typename: "StatusContext",
context: .context, 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), state: (.status | ascii_upcase),
# checks_state groups repeated contexts and takes the NEWEST by # checks_state groups repeated contexts and takes the NEWEST by
# [.startedAt, .createdAt, .completedAt]. Without a timestamp the # [.startedAt, .createdAt, .completedAt]. Without a timestamp the
@ -449,6 +485,15 @@ forge_pr_view() {
}' }'
} }
# 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_list() { forge_api --paginate "repos/$REPO/labels" --jq '.[].name'; }
# forge_label_create — an UPSERT, matching `gh label create --force` (#4743). # forge_label_create — an UPSERT, matching `gh label create --force` (#4743).
@ -549,7 +594,13 @@ forge_request_reviewer() {
# empty timeline — the two states the ruling ladder must tell apart (#4853). # empty timeline — the two states the ruling ladder must tell apart (#4853).
forge_timeline() { forge_timeline() {
local n="${1:?forge_timeline: number required}" raw local n="${1:?forge_timeline: number required}" raw
raw="$(forge_api --paginate "repos/$REPO/issues/$n/timeline")" || return 1 # 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 ' jq '
[.[] [.[]
| select(.type == "label") | select(.type == "label")
@ -621,22 +672,31 @@ forgejo_read_code() {
rm -f "$hdr" rm -f "$hdr"
} }
# forge_release_exists <tag> — prints `yes` or `no`. A non-zero exit means # forge_release_exists <tag> — prints `yes` for a published release and `no`
# the read did not complete and the answer is UNKNOWN; the caller must not # for a draft or a 404. A non-zero exit means the read did not complete and
# treat that as `no` (#191). # the answer is UNKNOWN; the caller must not treat that as `no` (#191, #271).
forge_release_exists() { forge_release_exists() {
local tag="${1:?forge_release_exists: tag required}" body code local tag="${1:?forge_release_exists: tag required}" body code draft
body="$(mktemp)" body="$(mktemp)"
code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$body")" || { rm -f "$body"; return 1; } code="$(forgejo_read_code "repos/$REPO/releases/tags/$tag" "$body")" || { rm -f "$body"; return 1; }
rm -f "$body"
case "$code" in case "$code" in
2*) echo yes ;; 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 ;; 404) echo no ;;
*) *)
rm -f "$body"
echo "forge_release_exists: HTTP $code reading release '$tag' — the answer is unknown, not 'no'" >&2 echo "forge_release_exists: HTTP $code reading release '$tag' — the answer is unknown, not 'no'" >&2
return 1 return 1
;; ;;
esac esac
rm -f "$body"
} }
# forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as # forge_commit_pulls <sha> — the pull requests whose merge produced <sha>, as
@ -714,20 +774,74 @@ forgejo_urlencode() {
jq -rn --arg s "${1-}" '$s|@uri' jq -rn --arg s "${1-}" '$s|@uri'
} }
# forge_release_create <tag> <title> <notes-file> [asset…] — publishes, then # forgejo_release_cleanup_draft <id> <tag> — best-effort rollback after a
# uploads each asset to the created release. The release id comes back from # post-create failure. The caller has already printed the original failure;
# the create, so no second lookup is needed. # 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() { forge_release_create() {
local tag="${1:?forge_release_create: tag required}" title="${2:?forge_release_create: title required}" 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 notes="${3:?forge_release_create: notes file required}" out id base token
local existing code draft existing_id
shift 3 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" \ out="$(forgejo_write POST "repos/$REPO/releases" \
"$(jq -nc --arg t "$tag" --arg n "$title" --rawfile b "$notes" \ "$(jq -nc --arg t "$tag" --arg n "$title" --rawfile b "$notes" \
'{tag_name:$t,name:$n,body:$b,draft:false,prerelease:false}')")" || return 1 '{tag_name:$t,name:$n,body:$b,draft:true,prerelease:false}')")" || return 1
id="$(printf '%s' "$out" | jq -r '.id // empty')" id="$(printf '%s' "$out" | jq -r '.id // empty')"
[ -n "$id" ] || { echo "forge_release_create: the create returned no release id" >&2; return 1; } [ -n "$id" ] || { echo "forge_release_create: the create returned no release id" >&2; return 1; }
[ "$#" -gt 0 ] || return 0 if ! base="$(forgejo_api_base)"; then
base="$(forgejo_api_base)" || return 1 forgejo_release_cleanup_draft "$id" "$tag"
return 1
fi
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}" token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
local f name local f name
for f in "$@"; do for f in "$@"; do
@ -741,8 +855,16 @@ forge_release_create() {
curl -sS -f -X POST -H "Authorization: token $token" \ curl -sS -f -X POST -H "Authorization: token $token" \
-F "attachment=@$f" \ -F "attachment=@$f" \
"$base/repos/$REPO/releases/$id/assets?name=$name" >/dev/null \ "$base/repos/$REPO/releases/$id/assets?name=$name" >/dev/null \
|| { echo "forge_release_create: asset upload failed for '$f'" >&2; return 1; } || {
echo "forge_release_create: asset upload failed for '$f'" >&2
forgejo_release_cleanup_draft "$id" "$tag"
return 1
}
done 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 # forge_pr_create <head> <base> <title> <body> <label…> — POST /pulls takes

View file

@ -101,6 +101,12 @@ forge_pr_view() {
gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup 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 — every label name in the repo.
forge_label_list() { forge_label_list() {
gh label list -R "$REPO" --limit 200 --json name --jq '.[].name' gh label list -R "$REPO" --limit 200 --json name --jq '.[].name'

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
}

View file

@ -217,6 +217,49 @@ check "a surviving fragment with its entry present fails" 1 "STILL PRESENT" \
check "the survivor refusal names the file" 1 "changelog.d/9.md" \ check "the survivor refusal names the file" 1 "changelog.d/9.md" \
run survivor base 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. # Fragments consumed, section never stamped: the prose went nowhere.
seed_flat halfdone seed_flat halfdone
rm "$TMP/halfdone/changelog.d/12.md" "$TMP/halfdone/changelog.d/9.md" rm "$TMP/halfdone/changelog.d/12.md" "$TMP/halfdone/changelog.d/9.md"

View file

@ -581,6 +581,93 @@ check "...never deriving them from reviews, as forgejo must" 1 "" \
unset -f gh unset -f gh
. "$ROOT/lib/forge-forgejo.sh" . "$ROOT/lib/forge-forgejo.sh"
# --- forge_pr_review_requests: the forge's live request set -------------
# A stale verdict is not itself evidence that anybody was re-requested. The
# backend reads the forge's request representation directly, so the state
# machine can distinguish the builder's ball from the panel's (#238).
review_requests_stub() {
fake_forge "$FAKE_REVIEWS_N" "$FAKE_REVIEWS"
}
# crew!96 after the builder pushed: two approvals and one requested change
# belong to the older head, but no live REQUEST_REVIEW row exists.
FAKE_REVIEWS_N=3
FAKE_REVIEWS='[
{"user":{"login":"kimi-bot"},"state":"APPROVED","commit_id":"old","submitted_at":"2026-08-22T23:05:01Z"},
{"user":{"login":"glm-bot"},"state":"APPROVED","commit_id":"old","submitted_at":"2026-08-22T23:14:45Z"},
{"user":{"login":"claude-bot"},"state":"REQUEST_CHANGES","commit_id":"old","submitted_at":"2026-08-22T23:03:39Z"}
]'
review_requests_stub
check "supersedes 'a stale approval still owes a verdict': one never re-requested is not requested" 0 "" \
eq "" forge_pr_review_requests 96
# rig!146 after merge: all three panelists submitted, so the reviews endpoint
# carries no REQUEST_REVIEW row even though requested_reviewers stayed stale.
FAKE_REVIEWS_N=3
FAKE_REVIEWS='[
{"user":{"login":"kimi-bot"},"state":"APPROVED","commit_id":"merged","submitted_at":"2026-08-02T14:05:01Z"},
{"user":{"login":"glm-bot"},"state":"APPROVED","commit_id":"merged","submitted_at":"2026-08-02T14:14:45Z"},
{"user":{"login":"claude-bot"},"state":"APPROVED","commit_id":"merged","submitted_at":"2026-08-02T14:03:39Z"}
]'
review_requests_stub
check "supersedes 'the never-cleared forgejo field collapses to who actually owes': a merged PR yields nobody" 0 "" \
eq "" forge_pr_review_requests 146
# crew!97's opening request rows. Deliberately unordered with one duplicate:
# the public contract is sorted unique logins, independent of API row order.
FAKE_REVIEWS_N=5
FAKE_REVIEWS='[
{"user":{"login":"kimi-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:12Z"},
{"user":{"login":"claude-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:11Z"},
{"user":{"login":"codex-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:11Z"},
{"user":{"login":"glm-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:12Z"},
{"user":{"login":"kimi-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:13Z"}
]'
review_requests_stub
check "forgejo returns the four opening request logins sorted and deduplicated" 0 "" \
eq $'claude-bot\ncodex-bot\nglm-bot\nkimi-bot' forge_pr_review_requests 97
FAKE_REVIEWS_N=2
FAKE_REVIEWS='[
{"user":{"login":"claude-bot"},"state":"REQUEST_CHANGES","commit_id":"old","submitted_at":"2026-08-22T23:03:39Z"},
{"user":{"login":"claude-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T23:16:17Z"}
]'
review_requests_stub
check "supersedes: a reviewer re-requested while holding REQUEST_CHANGES is requested" 0 "" \
eq "claude-bot" forge_pr_review_requests 97
FAKE_REVIEWS_N=1
FAKE_REVIEWS='[{"user":{"login":"kimi-bot"},"state":"REQUEST_REVIEW","commit_id":"","submitted_at":"2026-08-22T22:51:12Z"}]'
review_requests_stub
check "supersedes 'a Forgejo request row is not an answer and leaves the login outstanding': its login is requested" 0 "" \
eq "kimi-bot" forge_pr_review_requests 97
# GitHub already exposes the exact live set on the PR object. Stub only the
# network boundary and assert the same backend-neutral output contract.
. "$ROOT/lib/forge-github.sh"
# shellcheck disable=SC2317 # invoked indirectly, by forge_api
gh() {
[ "$1" = api ] || return 1
shift
local jqexpr="" endpoint=""
while [ $# -gt 0 ]; do
case "$1" in
--jq) jqexpr="$2"; shift ;;
-*) ;;
*) [ -n "$endpoint" ] || endpoint="$1" ;;
esac
shift
done
[ "$endpoint" = repos/o/r/pulls/55 ] || return 1
jq -r "$jqexpr" <<'JSON'
{"requested_reviewers":[{"login":"glm-bot"},{"login":"claude-bot"}]}
JSON
}
check "github returns requested_reviewers from the PR object unchanged" 0 "" \
eq $'claude-bot\nglm-bot' forge_pr_review_requests 55
unset -f gh
. "$ROOT/lib/forge-forgejo.sh"
# --- forge_pr_view: newest verdict per context must win ------------------ # --- forge_pr_view: newest verdict per context must win ------------------
# checks_state groups repeated contexts and selects the newest by # checks_state groups repeated contexts and selects the newest by
# [.startedAt, .createdAt, .completedAt]. Mapping only {context,state} left # [.startedAt, .createdAt, .completedAt]. Mapping only {context,state} left
@ -599,23 +686,66 @@ pr_view_stub() {
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: 1\r\n\r\n' >"$hdr" printf 'HTTP/1.1 200 OK\r\nX-Total-Count: 1\r\n\r\n' >"$hdr"
case "$url" in case "$url" in
*/status) printf '%s' "$FAKE_STATUS" >"$out" ;; */status) printf '%s' "$FAKE_STATUS" >"$out" ;;
*) printf '{"head":{"sha":"abc"},"mergeable":true}' >"$out" ;; *) printf '%s' "$FAKE_PR" >"$out" ;;
esac esac
return 0 return 0
} }
} }
# The FAILURE is older but listed second — array order would pick it. # The FAILURE is older but listed second — array order would pick it.
FAKE_STATUS='{"state":"failure","statuses":[ FAKE_STATUS='{"state":"failure","statuses":[
{"context":"ci / check","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"}, {"context":"labels / labels (pull_request)","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"},
{"context":"ci / check","status":"failure","created_at":"2026-08-02T09:00:00Z","updated_at":"2026-08-02T09:00:00Z"}]}' {"context":"labels / labels (pull_request)","status":"failure","created_at":"2026-08-02T09:00:00Z","updated_at":"2026-08-02T09:00:00Z"},
{"context":"CI / test (pull_request)","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"},
{"context":"Refs guard / refs-not-closing (pull_request)","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"},
{"context":"codecov","status":"success","created_at":"2026-08-02T10:00:00Z","updated_at":"2026-08-02T10:00:00Z"}]}'
FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":false,"mergeable":true}'
pr_view_stub pr_view_stub
view_json="$(REPO=o/r forge_pr_view 5)" view_json="$(REPO=o/r forge_pr_view 5)"
check "pr_view maps createdAt" 0 "" \ check "pr_view maps createdAt" 0 "" \
grep -q '"createdAt": "2026-08-02T10:00:00Z"' <<<"$view_json" grep -q '"createdAt": "2026-08-02T10:00:00Z"' <<<"$view_json"
check "pr_view maps completedAt" 0 "" \ check "pr_view maps completedAt" 0 "" \
grep -q '"completedAt":' <<<"$view_json" grep -q '"completedAt":' <<<"$view_json"
check "pr_view derives workflowName from the first context separator" 0 "" \
jq -e '[.statusCheckRollup[]
| select(.context == "labels / labels (pull_request)"
or .context == "CI / test (pull_request)"
or .context == "Refs guard / refs-not-closing (pull_request)")
| [.workflowName, .context]]
| unique == [
["CI", "CI / test (pull_request)"],
["Refs guard", "Refs guard / refs-not-closing (pull_request)"],
["labels", "labels / labels (pull_request)"]
]' >/dev/null <<<"$view_json"
check "pr_view leaves workflowName empty when the context has no separator" 0 "" \
jq -e 'any(.statusCheckRollup[];
.context == "codecov" and .workflowName == "")' >/dev/null <<<"$view_json"
check "pr_view maps mergeable to the UI string" 0 "" \ check "pr_view maps mergeable to the UI string" 0 "" \
grep -q '"mergeable": "MERGEABLE"' <<<"$view_json" grep -q '"mergeable": "MERGEABLE"' <<<"$view_json"
# Forgejo folds conflict-checking, conflict-check errors, real conflicts, and
# WIP into one false boolean. These fixtures pin the distinctions the API
# object still lets the backend report honestly (#236).
FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":true,"mergeable":false}'
view_json="$(REPO=o/r forge_pr_view 5)"
check "pr_view reports draft mergeability as unknown" 0 "" \
grep -q '"mergeable": "UNKNOWN"' <<<"$view_json"
FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"base","draft":false,"mergeable":false}'
view_json="$(REPO=o/r forge_pr_view 5)"
check "pr_view reports a fast-forward false mergeability as unknown" 0 "" \
grep -q '"mergeable": "UNKNOWN"' <<<"$view_json"
FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":false,"mergeable":false}'
view_json="$(REPO=o/r forge_pr_view 5)"
check "pr_view preserves a distinguishable real conflict" 0 "" \
grep -q '"mergeable": "CONFLICTING"' <<<"$view_json"
# Unreachable on Forgejo: Mergeable() includes !IsWorkInProgress(). This
# ordering fixture exists only to pin draft ahead of mergeable true (#236).
FAKE_PR='{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":true,"mergeable":true}'
view_json="$(REPO=o/r forge_pr_view 5)"
check "pr_view reads draft before the mergeable boolean" 0 "" \
grep -q '"mergeable": "UNKNOWN"' <<<"$view_json"
# The real proof: feed it to the production classifier and confirm the newer # The real proof: feed it to the production classifier and confirm the newer
# SUCCESS wins over the older FAILURE regardless of array order. # SUCCESS wins over the older FAILURE regardless of array order.
# shellcheck source=actions/labels-reconcile/labels-reconcile.sh # shellcheck source=actions/labels-reconcile/labels-reconcile.sh
@ -633,6 +763,7 @@ check "no api base refuses" 1 "cannot reach the forge" \
# .user.login -> .actor.login. Mutation-verified: collapsing add/remove or # .user.login -> .actor.login. Mutation-verified: collapsing add/remove or
# emitting .user instead of .actor each reds its own case (#4853). # emitting .user instead of .actor each reds its own case (#4853).
timeline_stub() { timeline_stub() {
: >"$timeline_calls"
# shellcheck disable=SC2317 # invoked indirectly, by forge_api # shellcheck disable=SC2317 # invoked indirectly, by forge_api
curl() { curl() {
local hdr="" out="" url="" local hdr="" out="" url=""
@ -640,14 +771,44 @@ timeline_stub() {
case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac case "$1" in -D) hdr="$2"; shift ;; -o) out="$2"; shift ;; -H) shift ;; *) url="$1" ;; esac
shift shift
done done
printf 'HTTP/1.1 200 OK\r\nX-Total-Count: %s\r\n\r\n' "${FAKE_TL_N:-2}" >"$hdr" printf '%s\n' "$url" >>"$timeline_calls"
local page=1 page_body="$FAKE_TIMELINE" page_total="${FAKE_TL_N:-2}"
case "$url" in *page=*) page="${url##*page=}"; page="${page%%&*}" ;; esac
if [ "${#FAKE_TL_PAGES[@]}" -gt 0 ]; then
if [ "$page" -le "${#FAKE_TL_PAGES[@]}" ]; then
page_body="${FAKE_TL_PAGES[$((page - 1))]}"
else
page_body='[]'
fi
page_total="$(jq 'length' <<<"$page_body")"
fi
{
printf 'HTTP/1.1 200 OK\r\n'
[ "${FAKE_TL_HEADERS:-yes}" = no ] || printf 'X-Total-Count: %s\r\n' "$page_total"
printf '\r\n'
} >"$hdr"
case "$url" in case "$url" in
*timeline*) printf '%s' "$FAKE_TIMELINE" >"$out" ;; *timeline*) printf '%s' "$page_body" >"$out" ;;
*) printf '[]' >"$out" ;; *) printf '[]' >"$out" ;;
esac esac
return 0 return 0
} }
} }
timeline_page() {
jq -nc --argjson first "$1" --argjson count "$2" '
[range($first; $first + $count)
| {
type: "label",
body: "1",
user: {login: "setter"},
label: {name: "needs-ruling"},
created_at: ("event-" + tostring)
}]
'
}
timeline_calls="$TMP/timeline_calls"
FAKE_TL_PAGES=()
FAKE_TL_HEADERS=yes
FAKE_TIMELINE='[ FAKE_TIMELINE='[
{"type":"label","body":"1","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T14:58:13Z"}, {"type":"label","body":"1","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T14:58:13Z"},
{"type":"label","body":"","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T15:22:22Z"}, {"type":"label","body":"","user":{"login":"setter"},"label":{"name":"needs-ruling"},"created_at":"2026-08-02T15:22:22Z"},
@ -664,6 +825,61 @@ check "forge_timeline drops non-label events" 0 "" \
test "$(jq '[.[] | select(.event == null or .event == "")] | length' <<<"$tl")" = 0 test "$(jq '[.[] | select(.event == null or .event == "")] | length' <<<"$tl")" = 0
check "forge_timeline uses .actor.login, not a bare .user" 0 "" \ check "forge_timeline uses .actor.login, not a bare .user" 0 "" \
jq -e 'all(.[]; has("actor") and (.user|not))' <<<"$tl" >/dev/null jq -e 'all(.[]; has("actor") and (.user|not))' <<<"$tl" >/dev/null
# Forgejo's timeline endpoint lies consistently: x-total-count echoes the
# current page size. With 151 events its pages declare 50, 50, 50 and 1, so
# strict pagination stops successfully after page 1 and drops the newest 101
# events. Exhaustion is safe only here because timelines are append-only.
FAKE_TL_PAGES=(
"$(timeline_page 1 50)"
"$(timeline_page 51 50)"
"$(timeline_page 101 50)"
"$(timeline_page 151 1)"
)
timeline_stub
tl="$(REPO=o/r forge_timeline 188)"
check "forge_timeline exhausts all pages despite per-page total headers" 0 "" \
test "$(jq 'length' <<<"$tl")" = 151
check "forge_timeline retains the newest event beyond page one" 0 "" \
jq -e 'any(.[]; .created_at == "event-151")' <<<"$tl" >/dev/null
check "a short final page terminates without an extra empty-page read" 0 "" \
test "$(wc -l <"$timeline_calls")" = 4
# A collection exactly divisible by the page size needs one final empty read;
# stopping after the second full page cannot prove exhaustion.
FAKE_TL_PAGES=("$(timeline_page 1 50)" "$(timeline_page 51 50)")
timeline_stub
exhaustive_count="$(REPO=o/r forge_api --paginate-exhaustive 'repos/o/r/issues/188/timeline' --jq 'length')"
check "exhaustive pagination terminates after an empty page" 0 "" \
test "$exhaustive_count" = 100
check "an exactly-full exhaustive gather reads the empty third page" 0 "" \
test "$(wc -l <"$timeline_calls")" = 3
# The exhaustive path's completeness proof is the short page itself; it must
# never consult the endpoint's missing or dishonest total header.
FAKE_TL_PAGES=("$(timeline_page 1 50)" "$(timeline_page 51 1)")
FAKE_TL_HEADERS=no
timeline_stub
check "exhaustive pagination needs no x-total-count header" 0 "" \
eq 51 forge_api --paginate-exhaustive 'repos/o/r/issues/188/timeline' --jq 'length'
check "the same missing-header fixture is still refused by strict pagination" 1 \
"did not send x-total-count" \
forge_api --paginate 'repos/o/r/issues/188/timeline' --jq 'length'
FAKE_TL_HEADERS=yes
check "strict and exhaustive pagination are mutually exclusive" 1 "mutually exclusive" \
forge_api --paginate --paginate-exhaustive 'repos/o/r/issues/188/timeline'
check "exhaustive pagination refuses a non-GET method" 1 "GET" \
forge_api --paginate-exhaustive -X POST 'repos/o/r/issues/188/timeline'
check "exhaustive pagination refuses compact -XPOST too" 1 "GET" \
forge_api --paginate-exhaustive -XPOST 'repos/o/r/issues/188/timeline'
check "exhaustive pagination refuses --method=POST too" 1 "GET" \
forge_api --paginate-exhaustive --method=POST 'repos/o/r/issues/188/timeline'
check "the exhaustive flag has exactly one production call site" 0 "" \
test "$(grep -c 'paginate-exhaustive' "$ROOT/lib/forge-forgejo.sh")" = 5
check "only forge_timeline invokes exhaustive pagination" 0 "" \
test "$(grep -c 'forge_api --paginate-exhaustive' "$ROOT/lib/forge-forgejo.sh")" = 1
# Unreadable: curl fails. Status must surface through forge_timeline itself # Unreadable: curl fails. Status must surface through forge_timeline itself
# (not a later jq), or the ruling ladder invents a verdict on a half-read. # (not a later jq), or the ruling ladder invents a verdict on a half-read.
# shellcheck disable=SC2317 # shellcheck disable=SC2317
@ -736,16 +952,25 @@ check "...and never hits the flat /pulls/{n}/comments endpoint" 1 "" \
# tag creation GitHub POST /git/refs # tag creation GitHub POST /git/refs
# Forgejo POST /tags (/git/refs is GET-only) # Forgejo POST /tags (/git/refs is GET-only)
# release_stub <code> <body> — a curl stub answering one canned response and # release_stub <code> <body> [<code> <body>…] — a curl stub answering a
# recording the method+path it was asked for. # sequence of canned responses and recording the method+path it was asked for.
# The cursor lives in a file because forge_release_create captures
# forgejo_write in a command substitution; an in-memory counter would advance
# only in that subshell and every later request would replay response one.
release_stub() { release_stub() {
# Globals, not locals: the curl closure below runs long after this # Globals, not locals: the curl closure below runs long after this
# function returns, exactly as stub_writes does above. # function returns, exactly as stub_writes does above.
STUB_CODE="$1" STUB_BODY="$2" STUB_CODES=() STUB_BODIES=()
while [ "$#" -gt 0 ]; do
STUB_CODES+=("$1") STUB_BODIES+=("$2")
shift 2
done
STUB_READ_CODE="${STUB_CODES[0]}" STUB_READ_BODY="${STUB_BODIES[0]}"
printf '0\n' >"$TMP/release-stub-cursor"
: >"$WRITES" : >"$WRITES"
# shellcheck disable=SC2317 # invoked indirectly, by the forge verbs # shellcheck disable=SC2317 # invoked indirectly, by the forge verbs
curl() { curl() {
local hdr="" out="" method=GET url="" payload="" local hdr="" out="" method=GET url="" payload="" fail_on_http=false
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
-D) hdr="$2"; shift ;; -D) hdr="$2"; shift ;;
@ -753,18 +978,32 @@ release_stub() {
-X) method="$2"; shift ;; -X) method="$2"; shift ;;
-d) payload="$2"; shift ;; -d) payload="$2"; shift ;;
-F) payload="$payload -F $2"; shift ;; -F) payload="$payload -F $2"; shift ;;
-f) fail_on_http=true ;;
-H) shift ;; -H) shift ;;
-*) ;; -*) ;;
*) url="$1" ;; *) url="$1" ;;
esac esac
shift shift
done done
[ -n "$hdr" ] && printf 'HTTP/1.1 %s x\r\n\r\n' "$STUB_CODE" >"$hdr" local index code body
[ -n "$out" ] && printf '%s' "$STUB_BODY" >"$out" if [ "$method" = GET ]; then
code="$STUB_READ_CODE" body="$STUB_READ_BODY"
else
index="$(cat "$TMP/release-stub-cursor")"
code="${STUB_CODES[$index]:-${STUB_CODES[${#STUB_CODES[@]} - 1]}}"
body="${STUB_BODIES[$index]:-${STUB_BODIES[${#STUB_BODIES[@]} - 1]}}"
printf '%s\n' "$((index + 1))" >"$TMP/release-stub-cursor"
fi
[ -n "$hdr" ] && printf 'HTTP/1.1 %s x\r\n\r\n' "$code" >"$hdr"
[ -n "$out" ] && printf '%s' "$body" >"$out"
printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES" printf '%s %s %s\n' "$method" "${url##*/api/v1/}" "$payload" >>"$WRITES"
if [ "$fail_on_http" = true ]; then
case "$code" in 2*) ;; *) return 22 ;; esac
fi
return 0 return 0
} }
} }
release_read() { STUB_READ_CODE="$1" STUB_READ_BODY="$2"; }
GITHUB_API_URL=https://forge.example/api/v1 GITHUB_API_URL=https://forge.example/api/v1
export GITHUB_API_URL export GITHUB_API_URL
@ -807,6 +1046,10 @@ check "forgejo: a 500 refuses rather than saying 'none'" 1 "the answer is unknow
release_stub 200 '{"tag_name":"1.2.3"}' release_stub 200 '{"tag_name":"1.2.3"}'
check "forgejo: a present release is yes" 0 "yes" forge_release_exists 1.2.3 check "forgejo: a present release is yes" 0 "yes" forge_release_exists 1.2.3
release_stub 200 '{"tag_name":"1.2.3","draft":true}'
check "forgejo: a draft is not a present release" 0 "no" forge_release_exists 1.2.3
release_stub 200 '{"tag_name":"1.2.3","draft":false}'
check "forgejo: a published release is present" 0 "yes" forge_release_exists 1.2.3
release_stub 404 '{}' release_stub 404 '{}'
check "forgejo: an absent release is no" 0 "no" forge_release_exists 1.2.3 check "forgejo: an absent release is no" 0 "no" forge_release_exists 1.2.3
release_stub 503 '{}' release_stub 503 '{}'
@ -830,9 +1073,22 @@ check "forgejo: the tag body names the target sha" 0 '"target":"cafebabe"' \
writes_after forge_tag_create 1.2.3 cafebabe writes_after forge_tag_create 1.2.3 cafebabe
printf 'notes body\n' >"$TMP/notes.md" printf 'notes body\n' >"$TMP/notes.md"
release_stub 201 '{"id":42}' release_writes_after() { "$@" >/dev/null 2>&1; grep -v '^GET ' "$WRITES"; }
release_stub 201 '{"id":42}' 200 '{}'
release_read 404 '{}'
check "forgejo: the publish POSTs to /releases with the notes as body" 0 '"body":"notes body' \ check "forgejo: the publish POSTs to /releases with the notes as body" 0 '"body":"notes body' \
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
check "the create stages a draft" 0 '"draft":true' cat "$WRITES"
zero_asset_writes() {
release_stub 201 '{"id":42}' 200 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1
local got
got="$(grep -v '^GET ' "$WRITES" | sed -E 's/ (\{.*|$)//')"
[ "$got" = $'POST repos/o/r/releases\nPATCH repos/o/r/releases/42' ] || { printf '%s\n' "$got"; return 1; }
}
check "the zero-asset path creates then publishes" 0 "" zero_asset_writes
# Assets: the hook contract permits any filename the consumer drops in # Assets: the hook contract permits any filename the consumer drops in
# RELEASE_ASSETS_DIR, and the asset name travels as a QUERY VALUE. Raw # RELEASE_ASSETS_DIR, and the asset name travels as a QUERY VALUE. Raw
@ -846,18 +1102,179 @@ check "the encoder escapes the query delimiters" 0 "a%26b%23c%2Bd%25e.tgz" \
printf 'x\n' >"$TMP/release asset.tgz" printf 'x\n' >"$TMP/release asset.tgz"
printf 'y\n' >"$TMP/a&b.tgz" printf 'y\n' >"$TMP/a&b.tgz"
release_stub 201 '{"id":42}' release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
release_read 404 '{}'
check "an asset with a space uploads under the encoded name" 0 "assets?name=release%20asset.tgz" \ check "an asset with a space uploads under the encoded name" 0 "assets?name=release%20asset.tgz" \
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz" release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz"
release_stub 201 '{"id":42}' release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
release_read 404 '{}'
check "an asset with '&' does not become two parameters" 0 "assets?name=a%26b.tgz" \ check "an asset with '&' does not become two parameters" 0 "assets?name=a%26b.tgz" \
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz" release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
release_stub 201 '{"id":42}' release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
release_read 404 '{}'
check "the upload targets the created release id" 0 "releases/42/assets" \ check "the upload targets the created release id" 0 "releases/42/assets" \
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz" release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
release_stub 201 '{"id":42}' release_stub 201 '{"id":42}' 201 '{}' 200 '{}'
release_read 404 '{}'
check "the asset rides as a multipart attachment" 0 "attachment=@" \ check "the asset rides as a multipart attachment" 0 "attachment=@" \
writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz" release_writes_after forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
happy_asset_writes() {
release_stub 201 '{"id":42}' 201 '{}' 201 '{}' 200 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/release asset.tgz" "$TMP/a&b.tgz" >/dev/null 2>&1
local got want
got="$(grep -v '^GET ' "$WRITES" | sed -E 's/ (\{.*| -F.*|$)//')"
want=$'POST repos/o/r/releases\nPOST repos/o/r/releases/42/assets?name=release%20asset.tgz\nPOST repos/o/r/releases/42/assets?name=a%26b.tgz\nPATCH repos/o/r/releases/42'
[ "$got" = "$want" ] || { printf '%s\n' "$got"; return 1; }
}
check "assets upload in argument order before the release is published" 0 "" happy_asset_writes
upload_failure() {
release_stub 201 '{"id":42}' 500 '{}' 204 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
}
check "an upload failure returns non-zero" 1 "asset upload failed" upload_failure
check "an upload failure deletes the draft" 0 "DELETE repos/o/r/releases/42" cat "$WRITES"
flip_failure() {
release_stub 201 '{"id":42}' 500 '{}' 204 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
}
check "a publish flip failure returns non-zero" 1 "HTTP 500" flip_failure
check "a publish flip failure deletes the draft" 0 "DELETE repos/o/r/releases/42" cat "$WRITES"
create_failure() {
release_stub 500 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
}
check "a create failure returns non-zero" 1 "HTTP 500" create_failure
check "a create failure has nothing to delete" 1 "" grep -q '^DELETE ' "$WRITES"
missing_release_id() {
release_stub 201 '{}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md"
}
check "a create without an id keeps its refusal" 1 "returned no release id" missing_release_id
check "a create without an id deletes nothing unknown" 1 "" grep -q '^DELETE ' "$WRITES"
stranded_cleanup_failure() {
release_stub 201 '{"id":42}' 500 '{"message":"upload refused"}' 500 '{"message":"delete refused"}'
release_read 404 '{}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" "$TMP/a&b.tgz"
}
check "cleanup failure preserves the original failure" 1 "asset upload failed" stranded_cleanup_failure
check "cleanup failure also names the stranded draft" 1 "stranded draft 42 for tag '1.2.3'" stranded_cleanup_failure
release_stub 204 '{}' 201 '{"id":42}' 200 '{}'
release_read 200 '{"id":41,"tag_name":"1.2.3","draft":true}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1
stranded_draft_order() {
local got
got="$(grep -v '^GET ' "$WRITES" | head -2 | sed -E 's/ (\{.*|$)//')"
[ "$got" = $'DELETE repos/o/r/releases/41\nPOST repos/o/r/releases' ] || { printf '%s\n' "$got"; return 1; }
}
check "a stranded same-tag draft is deleted before create" 0 "" stranded_draft_order
release_stub 409 '{}'
release_read 200 '{"id":41,"tag_name":"1.2.3","draft":false}'
forge_release_create 1.2.3 1.2.3 "$TMP/notes.md" >/dev/null 2>&1
check "a published same-tag release is never deleted" 1 "" grep -q '^DELETE ' "$WRITES"
# The merge door's preflight step is extracted and executed. Its network
# edges are stubbed at the boundary, while the real pure decision library
# consumes the gathered facts (#273).
MERGE_PREFLIGHT="$TMP/merge-preflight.sh"
{
printf '%s\n' '#!/usr/bin/env bash' 'set -e'
yq -r '.jobs.release-on-merge.steps[] | select(.id == "preflight") | .run' \
"$ROOT/.github/workflows/release.yml"
} >"$MERGE_PREFLIGHT"
chmod +x "$MERGE_PREFLIGHT"
mkdir -p "$TMP/merge-preflight-lib/lib" "$TMP/merge-preflight-bin"
ln -s "$ROOT/lib/preflight.sh" "$TMP/merge-preflight-lib/lib/preflight.sh"
# shellcheck disable=SC2016 # expanded when the generated helper runs
printf '%s\n' \
'forge_select() { :; }' \
'forge_release_exists() { case "$RELEASE_EXISTS" in error) return 1 ;; *) echo "$RELEASE_EXISTS" ;; esac; }' \
>"$TMP/merge-preflight-lib/lib/forge.sh"
# shellcheck disable=SC2016 # expanded when the PATH stub is executed
printf '%s\n' \
'#!/usr/bin/env bash' \
'case "$GIT_LS_REMOTE" in' \
' absent) exit 2 ;;' \
' error) exit 128 ;;' \
' *) printf "%s\n" "$GIT_LS_REMOTE" ;;' \
'esac' >"$TMP/merge-preflight-bin/git"
chmod +x "$TMP/merge-preflight-bin/git"
merge_preflight_extracted() { [ "$(wc -l <"$MERGE_PREFLIGHT")" -ge 8 ]; }
run_merge_preflight() {
: >"$TMP/merge-preflight-output"
CEREMONY_DIR="$TMP/merge-preflight-lib" \
GITHUB_OUTPUT="$TMP/merge-preflight-output" \
PATH="$TMP/merge-preflight-bin:$PATH" \
VER=1.2.3 MERGE_SHA=1111111111111111111111111111111111111111 \
RELEASE_EXISTS="$1" GIT_LS_REMOTE="$2" "$MERGE_PREFLIGHT"
}
merge_preflight_output_is() {
run_merge_preflight "$1" "$2" >/dev/null && \
[ "$(cat "$TMP/merge-preflight-output")" = "$3" ]
}
check "the merge door's preflight is extracted" 0 "" merge_preflight_extracted
check "the merge door refuses an unreadable release state" 1 \
"refusing rather than assuming" run_merge_preflight error absent
check "the merge door refuses an unreadable tag state" 1 \
"could not read tag '1.2.3'" run_merge_preflight no error
check "ls-remote exit 2 is tag absence and proceeds" 0 "" \
merge_preflight_output_is no absent resume=no
check "a matching peeled ref records a resume" 0 "" \
merge_preflight_output_is no \
$'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\trefs/tags/1.2.3\n1111111111111111111111111111111111111111\trefs/tags/1.2.3^{}' \
resume=yes
tag_step_uses_preflight() {
yq -r '.jobs.release-on-merge.steps[] | select(.name | test("tag the merge commit")) | .if' \
"$ROOT/.github/workflows/release.yml" | grep -q 'steps.preflight.outputs.resume'
}
check "the merge-door tag step is conditioned on the preflight output" 0 "" \
tag_step_uses_preflight
# The tag door's published-release guard is extracted and executed, like the
# other workflow-shell contracts in this repository. The tag itself is this
# door's premise, so only a published release refuses; adding the merge door's
# ls-remote half here would destroy the documented delete-and-re-push recovery.
TAG_GUARD="$TMP/tag-release-guard.sh"
{
printf '%s\n' '#!/usr/bin/env bash' 'set -e'
yq -r '.jobs.release-on-tag.steps[] | select(.name == "no published release may exist") | .run' \
"$ROOT/.github/workflows/release.yml"
} >"$TAG_GUARD"
chmod +x "$TAG_GUARD"
mkdir -p "$TMP/tag-guard-lib/lib"
# shellcheck disable=SC2016 # expanded when the generated helper runs, not while this test writes it
printf '%s\n' \
'forge_select() { :; }' \
'forge_release_exists() { case "$RELEASE_EXISTS" in error) return 1 ;; *) echo "$RELEASE_EXISTS" ;; esac; }' \
>"$TMP/tag-guard-lib/lib/forge.sh"
tag_guard_extracted() { [ "$(wc -l <"$TAG_GUARD")" -ge 8 ]; }
run_tag_guard() {
CEREMONY_DIR="$TMP/tag-guard-lib" VER=1.2.3 RELEASE_EXISTS="$1" "$TAG_GUARD"
}
tag_door_has_no_tag_exists_guard() {
! yq -r '.jobs.release-on-tag.steps[].run // ""' "$ROOT/.github/workflows/release.yml" | grep -q 'ls-remote'
}
check "the tag door's published-release guard is extracted" 0 "" tag_guard_extracted
check "the tag door refuses an existing published release" 1 "release '1.2.3' already exists" run_tag_guard yes
check "the tag door proceeds when no published release exists" 0 "" run_tag_guard no
check "the tag door refuses when release state is unreadable" 1 "refusing rather than assuming" run_tag_guard error
check "the tag door carries no tag-exists guard" 0 "" tag_door_has_no_tag_exists_guard
# --- the github twins address their own paths ---------------------------- # --- the github twins address their own paths ----------------------------
. "$ROOT/lib/forge-github.sh" . "$ROOT/lib/forge-github.sh"

View file

@ -101,6 +101,28 @@ check "empty labels do not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt
refs_body=$'Refs #12\nAlso refs: #8 and heavy-duty/rig#4.\nCloses #99\nNot refs-ish #7\nfix refs parsing from #200\nCloses #40; refs: none\nRefs #175 (split from #150)' refs_body=$'Refs #12\nAlso refs: #8 and heavy-duty/rig#4.\nCloses #99\nNot refs-ish #7\nfix refs parsing from #200\nCloses #40; refs: none\nRefs #175 (split from #150)'
check "Refs parser returns only references owned by a valid Refs marker" 0 "" \ check "Refs parser returns only references owned by a valid Refs marker" 0 "" \
test "$(refs_references <<<"$refs_body")" = $'8\n12\n175' test "$(refs_references <<<"$refs_body")" = $'8\n12\n175'
# A Refs declaration binds one token, never the prose that follows it; every
# later occurrence starts a fresh declaration (#234).
# shellcheck disable=SC2016 # backticks are the fixture's literal Markdown
crew_round_line='- **Claude verified the safe `Refs #52`, TDD provenance, and inherited #65 CI failure.**'
check "Refs parser ignores issue prose after the bound token" 0 "" \
test "$(refs_references <<<"$crew_round_line")" = 52
check "Refs comma-list narrowing is deliberate" 0 "" \
test "$(refs_references <<<'Refs #8, #9')" = 8
check "a cross-repo Refs token stays non-local" 0 "" \
test -z "$(refs_references <<<'Refs heavy-duty/rig#4')"
check "prose after a cross-repo Refs token stays non-local" 0 "" \
test -z "$(refs_references <<<'Refs heavy-duty/rig#4, #12')"
check "parenthesized prose after a local Refs token is ignored" 0 "" \
test "$(refs_references <<<'Refs #175 (split from #150)')" = 175
check "every Refs occurrence on one line contributes its bound token" 0 "" \
test "$(refs_references <<<'Refs #8. Refs #9.')" = $'8\n9'
check "a concatenated Refs spelling is not a second keyword occurrence" 0 "" \
test "$(refs_references <<<'Refs #8Refs #9')" = 8
check "lowercase refs in unrelated prose declares nothing" 0 "" \
test -z "$(refs_references <<<'fix refs parsing from #200')"
check "Refs without a following token declares nothing" 0 "" \
test -z "$(refs_references <<<'Closes #40; refs: none')"
open_records=$'BODY\tRefs #5\nCLOSING\t9\nBODY\tRefs heavy-duty/rig#112\nBODY\tRefs #5\nCLOSING\t5' open_records=$'BODY\tRefs #5\nCLOSING\t9\nBODY\tRefs heavy-duty/rig#112\nBODY\tRefs #5\nCLOSING\t5'
check "open PR linkage unions closing and local Refs body references" 0 $'5\n9' \ check "open PR linkage unions closing and local Refs body references" 0 $'5\n9' \
open_pr_issues <<<"$open_records" open_pr_issues <<<"$open_records"
@ -502,6 +524,11 @@ check "a release epic with every declared blocker closed announces init" 0 "" \
grep -qF '<!-- issueflow:release-init-due -->' "$TMP/posted-53" grep -qF '<!-- issueflow:release-init-due -->' "$TMP/posted-53"
check "the init announce names all five steps" 0 "5" \ check "the init announce names all five steps" 0 "5" \
grep -cE '^[1-5]\. ' "$TMP/posted-53" grep -cE '^[1-5]\. ' "$TMP/posted-53"
# Release-init is where the membership record is first written, so step 3
# names it beside the waves and the progress task list.
# shellcheck disable=SC2016 # backticks are the comment body's own Markdown
check "step 3 names the membership record it first writes" 0 "" \
grep -qF '3. Write ordered waves, the `## Members` record' "$TMP/posted-53"
# shellcheck disable=SC2016 # backticks are the literal portable doctrine citation # shellcheck disable=SC2016 # backticks are the literal portable doctrine citation
check "the init announce cites the portable vendored doctrine path" 0 "" \ check "the init announce cites the portable vendored doctrine path" 0 "" \
grep -qF 'See `.ceremony/RELEASES.md`.' "$TMP/posted-53" grep -qF 'See `.ceremony/RELEASES.md`.' "$TMP/posted-53"
@ -714,9 +741,16 @@ check "the flag-free control is reclaimed (the clock still runs elsewhere)" 0 ""
# -- merged Refs work releases the claim before the reclaim clock ------------ # -- merged Refs work releases the claim before the reclaim clock ------------
printf '[]\n' >"$(cfix 35)" printf '[]\n' >"$(cfix 35)"
COLLISION_FLAGS=$'35\tissueflow-reconcile=34'
WINDOW_FLAGS=$'35\t#50'
transition="$(issue_probe 35 claimed 1 false 350 $'- [x] built\n- [ ] verify dispatch\n * [ ] confirm warning clears')" transition="$(issue_probe 35 claimed 1 false 350 $'- [x] built\n- [ ] verify dispatch\n * [ ] confirm warning clears')"
unset COLLISION_FLAGS WINDOW_FLAGS
check "merged Refs + unchecked criteria transitions in the sweep body" 0 "" \ check "merged Refs + unchecked criteria transitions in the sweep body" 0 "" \
grep -q 'merged Refs PR -> post-merge; claim released' <<<"$transition" grep -q 'merged Refs PR -> post-merge; claim released' <<<"$transition"
check "a pass concluding post-merge draws no precomputed collision flag" 1 "" \
grep -q 'collision flag' <<<"$transition"
check "...and no precomputed window flag" 1 "" \
grep -q 'window flag' <<<"$transition"
# shellcheck disable=SC2016 # positional parameters belong to bash -c # shellcheck disable=SC2016 # positional parameters belong to bash -c
check "...names every remaining criterion verbatim in the comment" 0 "" \ check "...names every remaining criterion verbatim in the comment" 0 "" \
bash -c 'grep -qF -- "- [ ] verify dispatch" "$1" && bash -c 'grep -qF -- "- [ ] verify dispatch" "$1" &&
@ -1821,12 +1855,12 @@ check "...the null-valued row is TRAVERSED, with an observable outcome" 0 "" \
check "...and the object-valued PR row is not reconciled as an issue" 1 "" \ check "...and the object-valued PR row is not reconciled as an issue" 1 "" \
grep -qE '^issueflow: #61' <<<"$fjb_out" grep -qE '^issueflow: #61' <<<"$fjb_out"
# release_bodies is the THIRD producer and has its own has() site. A `release` # release_numbers is the THIRD producer and has its own issue-shape filter. A `release`
# issue on a forgejo-shaped board must reach the window gather, or the #292 # issue on a forgejo-shaped board must reach the window gather, or the #292
# flags are decided over an empty set (@codex-reviewer-andresmgsl, #210). # flags are decided over an empty set (@codex-reviewer-andresmgsl, #210).
printf '%s\n' \ printf '%s\n' \
'[{"number":60,"pull_request":null,"labels":[{"name":"ready"}],"title":"an issue"}, '[{"number":60,"pull_request":null,"labels":[{"name":"ready"}],"title":"an issue"},
{"number":62,"pull_request":null,"labels":[{"name":"release"}],"title":"Release 9.9.9","body":"Blocked by #60."}, {"number":62,"pull_request":null,"labels":[{"name":"release"}],"title":"Release 9.9.9","body":"Blocked by #59.\n\n## Members\n- #60"},
{"number":63,"pull_request":null,"labels":[{"name":"ready"}],"title":"a claimable non-member"}, {"number":63,"pull_request":null,"labels":[{"name":"ready"}],"title":"a claimable non-member"},
{"number":61,"pull_request":{"merged":false},"labels":[],"title":"a pull request"}]' \ {"number":61,"pull_request":{"merged":false},"labels":[],"title":"a pull request"}]' \
>"$FORGEJO_BOARD/repos_owner_repo_issues_state_open.json" >"$FORGEJO_BOARD/repos_owner_repo_issues_state_open.json"
@ -1836,13 +1870,13 @@ jq -n --arg at "$(iso_at "$INOW")" \
>"$FORGEJO_BOARD/repos_owner_repo_issues_63.json" >"$FORGEJO_BOARD/repos_owner_repo_issues_63.json"
printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_issues_63_comments.json" printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_issues_63_comments.json"
jq -n --arg at "$(iso_at "$INOW")" \ jq -n --arg at "$(iso_at "$INOW")" \
'{number:62,user:{login:"triage-one"},created_at:$at,body:"Blocked by #60.",pull_request:null, '{number:62,user:{login:"triage-one"},created_at:$at,body:"Blocked by #59.\n\n## Members\n- #60",pull_request:null,
labels:[{name:"release"}],assignees:[]}' \ labels:[{name:"release"}],assignees:[]}' \
>"$FORGEJO_BOARD/repos_owner_repo_issues_62.json" >"$FORGEJO_BOARD/repos_owner_repo_issues_62.json"
printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_issues_62_comments.json" printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_issues_62_comments.json"
fjb2_out="$(forgejo_board_run)" fjb2_out="$(forgejo_board_run)"
# The observable effect of release_bodies being NON-empty: an open `release` # The observable effect of release_numbers being NON-empty: an open `release`
# issue whose gate still holds an open member makes every claimable non-member # issue whose membership record still holds an open member makes every claimable non-member
# draw a window flag. With that gather empty there are no carriers and no flag, # draw a window flag. With that gather empty there are no carriers and no flag,
# so this row discriminates the site rather than merely reaching it. # so this row discriminates the site rather than merely reaching it.
check "a release issue on a forgejo-shaped board reaches the window gather" 0 "" \ check "a release issue on a forgejo-shaped board reaches the window gather" 0 "" \
@ -2384,6 +2418,215 @@ check "...with the older carrier still asked for nothing" 1 "" \
collision_flags_issue 257 \ collision_flags_issue 257 \
<<<$'257\tready\tissueflow-reconcile.sh + issueflow-reconcile.test.sh — one\n284\tready\tactions/issueflow-reconcile — two' <<<$'257\tready\tissueflow-reconcile.sh + issueflow-reconcile.test.sh — one\n284\tready\tactions/issueflow-reconcile — two'
# -- a release carrier is not a member of its own gate (#327 D1) -----------
self_gate_body='A member narrates Blocked by #163.'
check "a self-only parsed gate does not make its release issue a carrier" 1 "" \
grep -q . < <(release_window_gate 163 $'163\n164' <<<"$self_gate_body")
mixed_gate_body='Blocked by #163, #164, #165.'
check "an open non-self member still makes the release issue a carrier" 0 \
$'163\t164\n163\t165' \
release_window_gate 163 $'163\n164' <<<"$mixed_gate_body"
# shellcheck disable=SC2016 # awk fields belong to awk, not the shell
check "the carrier number never contributes to its own WINDOW_GATE" 1 "" \
awk -F '\t' '$2 == 163 { found = 1 } END { exit !found }' \
< <(release_window_gate 163 $'163\n164' <<<"$mixed_gate_body")
# -- the membership record, read by heading (#343 D2) -----------------------
# The record is a machine record with one shape, and every case below is a
# shape a real release body already carries. The corpus is heavy-duty/crew#346
# — 21 members, a literal "## The members, in claim order" narration heading,
# rows citing merged PRs and another repository, one row annotating an issue as
# explicitly NOT a member, a verification lane that is not in the build queue,
# and a "## Task list" progress view beside all of it.
# Bracketed, so the assertion is the WHOLE set and not a prefix of it: a
# substring match on a bare list would let an extra member in silently, which
# is the one direction every mutation below travels.
membership_set() { # release body on stdin -> its members as one bracketed line
printf '[%s]\n' "$(membership_references | tr '\n' ' ' | sed 's/[[:space:]]*$//')"
}
membership_body="$(printf '%s\n' \
'The 0.6.0 window. Blocked by #249.' \
'A quoted declaration in narration: "Blocked by #906" is what the epic says.' \
'' \
'## The members, in claim order' \
'- #900 — a narration heading, not the record' \
'' \
'## Members' \
'- [ ] #253 — landed as #901, ports heavy-duty/crew#346, and #902 is not a member' \
'- #257' \
'- [x] #264 — landed' \
'- crew#348 — a parallel track in another repository' \
'- #266, #276 — two references on one row' \
'- the verification lane, not in the build queue' \
'- #249 — the sink itself' \
'' \
'## Task list' \
'- [ ] #281 — the progress view')"
check "the record enrols exactly its rows' bare first tokens" 0 "[249 253 257 264]" \
membership_set <<<"$membership_body"
# The heading is ANCHORED. crew#346 carries this exact narration heading, so a
# substring or prefix match reads it as the record and enrols its rows.
check "a narrated members heading is not the record" 1 "" \
grep -qx 900 < <(membership_references <<<"$membership_body")
# The row's FIRST token, not every reference in it. epic_references prints the
# whole row and takes them all, which is right for a progress view and wrong
# here: these three are a merged PR, a sibling repository, and an issue the row
# itself names as a non-member.
check "a row's prose PR reference is not a member" 1 "" \
grep -qx 901 < <(membership_references <<<"$membership_body")
check "...nor an issue the row names as explicitly not a member" 1 "" \
grep -qx 902 < <(membership_references <<<"$membership_body")
check "...nor a qualified reference in the prose" 1 "" \
grep -qx 346 < <(membership_references <<<"$membership_body")
# Silence, not a guess: a first token that is not a bare local reference
# contributes nothing, whether it is qualified, punctuated, or prose.
check "a qualified first token contributes no member" 1 "" \
grep -qx 348 < <(membership_references <<<"$membership_body")
check "a punctuated first token contributes no member" 1 "" \
grep -qx 266 < <(membership_references <<<"$membership_body")
check "...and the second reference on that row contributes none either" 1 "" \
grep -qx 276 < <(membership_references <<<"$membership_body")
# The record ends at the next heading, so the progress view beside it is not
# membership — that separation is the whole reason the two lists are distinct.
check "a task-list reference is not a member" 1 "" \
grep -qx 281 < <(membership_references <<<"$membership_body")
check "...nor a reference in narration outside the record" 1 "" \
grep -qx 906 < <(membership_references <<<"$membership_body")
check "an unchecked row and a checked row enrol alike" 0 $'253\n264' \
membership_references <<<"$membership_body"
check "a bare row with no checkbox enrols too" 0 "257" \
membership_references <<<"$membership_body"
# Case-insensitive, trailing whitespace tolerated — the shape `## Task list`
# already has, stated once in RELEASES.md and implemented once here.
check "the heading matches case-insensitively with trailing whitespace" 0 "412" \
membership_references <<<"$(printf '%s\n' '## MEMBERS ' '- #412 — admitted')"
# Every CommonMark list marker opens a row, and only those. A row is whatever a
# reader sees as one, so a marker class narrower than the set Markdown renders
# would drop a member a human wrote — and take the standing window down with
# it, which is silence in the one place D2 does not want silence. A class WIDER
# than it is the same error mirrored, and the more dangerous direction: a line
# no renderer reads as a row becomes a member, and one phantom open member
# keeps a window standing and suppresses its non-member flag. Only the first
# token rule decides what a row MEANS; the marker class decides what a row IS.
marker_body="$(printf '%s\n' \
'## Members' \
'- #412 — a hyphen row' \
'* #413 — an asterisk row' \
'+ #414 — a plus row' \
'1. #415 — an ordered row' \
'2) #416 — an ordered row, the paren form' \
'123456789. #419 — nine digits, the widest ordered marker there is' \
'1234567890. #420 — ten digits, which CommonMark does not render as a row' \
' #417 — no marker at all, so not a row')"
check "every Markdown list marker opens a member row" 0 \
"[412 413 414 415 416 419]" \
membership_set <<<"$marker_body"
check "a plus row enrols its member" 0 "" \
grep -qx 414 < <(membership_references <<<"$marker_body")
check "an ordered row enrols its member" 0 "" \
grep -qx 415 < <(membership_references <<<"$marker_body")
check "...and so does its paren form" 0 "" \
grep -qx 416 < <(membership_references <<<"$marker_body")
# The bound is CommonMark 5.2's: an ordered marker is 1 to 9 digits then `.` or
# `)`. Both sides of it are asserted, because one alone is met by a class that
# is merely different rather than right — the 9-digit row is the widest marker
# a renderer accepts and must enrol, the 10-digit line is not a list row at all
# and must contribute nothing. The bound is written twice in the parser, in the
# row match and in the marker strip; a widening of either reds the pair.
check "the widest ordered marker CommonMark allows enrols its member" 0 "" \
grep -qx 419 < <(membership_references <<<"$marker_body")
check "a tenth digit is not an ordered marker, so the line is not a row" 1 "" \
grep -qx 420 < <(membership_references <<<"$marker_body")
# The marker is what makes the line a row, so a bare reference on its own line
# is narration inside the record, not a member. Indented deliberately: at
# column 0 a `#` would end the record as a heading, and this assertion is about
# the marker, not the terminator.
check "a line with no list marker is not a row" 1 "" \
grep -qx 417 < <(membership_references <<<"$marker_body")
# Indentation bounds the row the way the digit count bounds the marker, and both
# sides are asserted for the same reason: a bound met on one side alone is a
# class merely different rather than right. Three spaces still open a row —
# CommonMark 4.4 allows up to three, and refusing them would drop a member a
# human wrote and reads. A fourth does not, and what it means depends on context
# the line itself does not carry: GitHub renders ` - #N` after `## Members`
# as `<pre><code>`, and the same bytes under a `- #N` row as a nested `<li>`.
# The record is FLAT, so both are silence: an indented code block is not a row at
# all, and a sub-bullet annotating a member row is not a second member. Enrolling
# either is the phantom-member direction — an open reference taken from non-row
# content keeps a window standing and suppresses its non-member flag. A leading
# tab is four columns wherever indentation decides block structure, so it falls
# under the same bound.
indent_body="$(printf '%s\n' \
'## Members' \
'- #421 — column zero' \
' - #422 — three spaces, the deepest indentation that still opens a row' \
' - #423 — four spaces: a sub-row here, an indented code block alone' \
$'\t- #424 — a tab, the same four columns, so neither is it')"
check "the record admits exactly its unindented and shallowly indented rows" 0 \
"[421 422]" \
membership_set <<<"$indent_body"
check "three spaces still open a row" 0 "" \
grep -qx 422 < <(membership_references <<<"$indent_body")
check "a row indented past the bound is a sub-row, and not a second member" 1 "" \
grep -qx 423 < <(membership_references <<<"$indent_body")
check "...and neither is the tab-indented one" 1 "" \
grep -qx 424 < <(membership_references <<<"$indent_body")
# The same bytes with no row above them, which is the shape the panel found: no
# list is open, so the renderer reads an indented code block and there is nothing
# for the record to enrol. A body whose record is entirely non-rows enumerates
# no membership, and D4 then applies to it like any other empty record.
code_block_body="$(printf '%s\n' \
'## Members' \
' - #425 — four spaces with no list open: an indented code block' \
$'\t- #426 — and a tab, the same block')"
check "an indented code block inside the record enrols nobody" 0 "[]" \
membership_set <<<"$code_block_body"
# The terminator itself, pinned where it can be seen: the record ends at the
# next line starting with `#`, the shape `## Task list` already has. A bare
# unindented reference is therefore the end of the record, not a member of it,
# and the rows after it are outside.
check "an unindented bare reference ends the record" 0 "[412]" \
membership_set <<<"$(printf '%s\n' '## Members' '- #412' '#417' '- #418')"
# -- the carrier decision reads the record (#343 D3, D4, D5) ----------------
check "an open member in the record makes the release issue a carrier" 0 \
$'249\t253\n249\t257' \
release_window_members 249 $'249\n253' \
<<<"$(printf '%s\n' '## Members' '- #253' '- #257')"
# D4: no fallback. This is THE defect's own state — #317 from its mint until
# #249 closed at 2026-08-05T11:12Z, and crew's fifteen version epics at 0.6.0
# adoption: a version epic declaring its predecessor exactly as *Gates*
# instructs and enumerating nothing. Falling back to the gate here restores
# the reading that made a shut window stand.
check "a declared open predecessor with no record is not a carrier (D4)" 1 "" \
grep -q . < <(release_window_members 317 $'249\n317\n343' <<<'Blocked by #249.')
check "...and an empty record is not a carrier either" 1 "" \
grep -q . < <(release_window_members 249 $'249\n253' \
<<<"$(printf '%s\n' '## Members' '' '## Task list' '- [ ] #253')")
check "...nor is a record whose every member has closed" 1 "" \
grep -q . < <(release_window_members 249 $'249\n264' \
<<<"$(printf '%s\n' '## Members' '- #218' '- #230')")
# D5: #327's self-exclusion, inherited rather than re-decided. Both readings
# share release_window_records, so the guard cannot hold on one side only.
check "a membership row naming the carrier contributes no member (D5)" 1 "" \
grep -q . < <(release_window_members 249 $'249\n253' \
<<<"$(printf '%s\n' '## Members' '- #249 — the sink itself')")
# shellcheck disable=SC2016 # awk fields belong to awk, not the shell
check "...and never contributes to WINDOW_MEMBERS beside a real member" 1 "" \
awk -F '\t' '$2 == 249 { found = 1 } END { exit !found }' \
< <(release_window_members 249 $'249\n253' \
<<<"$(printf '%s\n' '## Members' '- #249' '- #253')")
# The snapshot may nominate a flag before this issue's own queue branch runs;
# the pure second gate reads the state that branch actually concluded.
check "a pass concluding ready still permits both board flags" 0 "" \
board_flags_in_scope ready
check "a pass concluding claimed still permits both board flags" 0 "" \
board_flags_in_scope claimed
check "a pass concluding post-merge silences both board flags" 1 "" \
board_flags_in_scope post-merge
# -- the window decision (#292 D1) ------------------------------------------ # -- the window decision (#292 D1) ------------------------------------------
window_board=$'249\tblocked,release\tRelease 0.6.0 — the board empties\n253\tclaimed\tissueflow-reconcile — a member\n264\tready\tTRIAGE.md — a non-member\n270\tepic\tsome epic — exempt\n271\tpost-merge\tsome item — exempt\n272\tblocked\tsome issue — already placed' window_board=$'249\tblocked,release\tRelease 0.6.0 — the board empties\n253\tclaimed\tissueflow-reconcile — a member\n264\tready\tTRIAGE.md — a non-member\n270\tepic\tsome epic — exempt\n271\tpost-merge\tsome item — exempt\n272\tblocked\tsome issue — already placed'
check "a ready non-member is flagged during a standing window" 0 "264" \ check "a ready non-member is flagged during a standing window" 0 "264" \
@ -2474,7 +2717,7 @@ board_run() {
# them — six `ready` non-members against a standing gate, and one deliverable # them — six `ready` non-members against a standing gate, and one deliverable
# carried three times in two spellings. # carried three times in two spellings.
board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \
'Blocked by #253.' "$(printf '%s\n' 'Blocked by #253.' '' '## Members' '- #253')"
board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1 board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1
board_issue 257 ready 'actions/issueflow-reconcile — a failed board read sweeps an empty board' board_issue 257 ready 'actions/issueflow-reconcile — a failed board read sweeps an empty board'
board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag' board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag'
@ -2534,7 +2777,18 @@ check "the window comment names #292's invariant" 0 "" \
grep -qF "#292's invariant" "$BOARD/edits" grep -qF "#292's invariant" "$BOARD/edits"
# shellcheck disable=SC2016 # backticks are the comment body's own Markdown # shellcheck disable=SC2016 # backticks are the comment body's own Markdown
check "...and states the subset rule with its exemptions" 0 "" \ check "...and states the subset rule with its exemptions" 0 "" \
grep -qF 'the `ready` set is a subset of the gate' "$BOARD/edits" grep -qF 'the `ready` set is a subset of that' "$BOARD/edits"
# shellcheck disable=SC2016 # backticks are the comment body's own Markdown
check "...and tells triage where membership is actually read from" 0 "" \
grep -qF 'Membership is read from the release issue'"'"'s own `## Members` record' \
"$BOARD/edits"
# shellcheck disable=SC2016 # backticks are the comment body's own Markdown
check "...and says what a release issue's Blocked by line does answer" 0 "" \
grep -qF 'answers its predecessor gate and never its membership' "$BOARD/edits"
check "...and asks the third write for a row, not a declaration" 0 "" \
grep -qF 'the release issue gains a row for' "$BOARD/edits"
check "no window comment sends triage to a Blocked by declaration" 1 "" \
grep -qF 'The gate is read from the release issue' "$BOARD/edits"
check "both comments carry idempotency markers (D4)" 0 "" \ check "both comments carry idempotency markers (D4)" 0 "" \
grep -qF '<!-- issueflow:collision-' "$BOARD/edits" grep -qF '<!-- issueflow:collision-' "$BOARD/edits"
check "...the window one too" 0 "" grep -qF '<!-- issueflow:window-nonmember-' "$BOARD/edits" check "...the window one too" 0 "" grep -qF '<!-- issueflow:window-nonmember-' "$BOARD/edits"
@ -2599,7 +2853,8 @@ check "...while every other collision on the board still speaks" 0 "2" \
# oldest-first, the reconciler chain chained, and every one of them a gate # oldest-first, the reconciler chain chained, and every one of them a gate
# member. Every flag above must go quiet, or the flag is reporting the fix. # member. Every flag above must go quiet, or the flag is reporting the fix.
board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \
'Blocked by #253, #257, #264, #266, #276, #281, #282, #284.' "$(printf '%s\n' 'Blocked by #253.' '' '## Members' \
'- #253' '- #257' '- #264' '- #266' '- #276' '- #281' '- #282' '- #284')"
board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1 board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1
board_issue 257 blocked 'actions/issueflow-reconcile — a failed board read sweeps an empty board' \ board_issue 257 blocked 'actions/issueflow-reconcile — a failed board read sweeps an empty board' \
'Blocked by #253.' 'Blocked by #253.'
@ -2622,21 +2877,23 @@ check "...and no window flag either" 1 "" grep -qF ': window flag' <<<"$ruled_ou
check "...and still reports a whole pass" 0 'issueflow: reconciled.' \ check "...and still reports a whole pass" 0 'issueflow: reconciled.' \
printf '%s\n' "$ruled_out" printf '%s\n' "$ruled_out"
# -- an emptied gate leaves the window flag dormant (test plan) ------------- # -- an emptied window leaves the flag dormant (test plan) ------------------
# A gate DECLARATION never empties: #249 names fifteen members and still names # A membership RECORD never empties: #249 names fifteen members and still
# fifteen after all fifteen close. So the precondition is the gate's OPEN # names fifteen after all fifteen close. So the precondition is the record's OPEN
# members, not its parse — read straight off the board, which already is the # members, not its parse — read straight off the board, which already is the
# open set. Under the declaration reading the release issue, now `ready`, is # open set. Under the declaration reading the release issue, now `ready`, is
# itself an open unblocked non-`epic` non-member, and D3 would flag the sink # itself an open unblocked non-`epic` non-member, and D3 would flag the sink
# at the exact moment the window ends. # at the exact moment the window ends.
board_issue 249 ready,release 'Release 0.6.0 — the board empties into the tag' \ board_issue 249 ready,release 'Release 0.6.0 — the board empties into the tag' \
'Blocked by #218, #230, #232, #236, #237, #238, #241, #242, #247, #248, #251, #252, #253, #254, #257.' "$(printf '%s\n' 'Blocked by #217.' '' '## Members' \
'- #218' '- #230' '- #232' '- #236' '- #237' '- #238' '- #241' '- #242' \
'- #247' '- #248' '- #251' '- #252' '- #253' '- #254' '- #257')"
board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag' board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag'
# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown # shellcheck disable=SC2016 # the backticks are the real issue title's Markdown
board_issue 266 ready 'TRIAGE.md — the epic task-list heading is literally `## Task list`' board_issue 266 ready 'TRIAGE.md — the epic task-list heading is literally `## Task list`'
board_assemble 249 264 266 board_assemble 249 264 266
empty_gate_out="$(board_run)" empty_gate_out="$(board_run)"
check "a fifteen-member declaration with every member closed leaves D3 dormant" 1 "" \ check "a fifteen-member record with every member closed leaves D3 dormant" 1 "" \
grep -qF ': window flag' <<<"$empty_gate_out" grep -qF ': window flag' <<<"$empty_gate_out"
check "...and the release issue is never flagged as its own non-member" 1 "" \ check "...and the release issue is never flagged as its own non-member" 1 "" \
grep -qF 'issueflow: #249' <<<"$empty_gate_out" grep -qF 'issueflow: #249' <<<"$empty_gate_out"
@ -2683,7 +2940,7 @@ printf '%s\n' \
'{"data":{"repository":{"pullRequests":{"nodes":[{"number":403,"body":"","closingIssuesReferences":{"nodes":[{"number":402}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ '{"data":{"repository":{"pullRequests":{"nodes":[{"number":403,"body":"","closingIssuesReferences":{"nodes":[{"number":402}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
>"$BOARD/repos_owner_repo_pulls_state_open.json" >"$BOARD/repos_owner_repo_pulls_state_open.json"
board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \
'Blocked by #253.' "$(printf '%s\n' 'Blocked by #253.' '' '## Members' '- #253')"
board_issue 253 claimed 'issueflow-reconcile — a member holding the window open' '' 1 board_issue 253 claimed 'issueflow-reconcile — a member holding the window open' '' 1
board_issue 402 claimed 'REVIEWER.md — a non-member with a builder and a round' '' 1 board_issue 402 claimed 'REVIEWER.md — a non-member with a builder and a round' '' 1
board_assemble 249 253 402 board_assemble 249 253 402
@ -2731,7 +2988,7 @@ printf '%s\n' \
'{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ '{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
>"$BOARD/repos_owner_repo_pulls_state_open.json" >"$BOARD/repos_owner_repo_pulls_state_open.json"
board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \
'Blocked by #293, #307.' "$(printf '%s\n' 'Blocked by #307.' '' '## Members' '- #293' '- #307')"
board_issue 293 claimed 'issueflow-reconcile — the sweep flags what the window and collision rules forbid' '' 1 board_issue 293 claimed 'issueflow-reconcile — the sweep flags what the window and collision rules forbid' '' 1
board_issue 307 blocked 'test/issueflow-reconcile.test.sh — the ruling pre-read is unpinned' \ board_issue 307 blocked 'test/issueflow-reconcile.test.sh — the ruling pre-read is unpinned' \
'Blocked by #293.' 'Blocked by #293.'
@ -2744,6 +3001,37 @@ check "...and no window flag: the claimed member is a member" 1 "" \
check "...and still reports a whole pass" 0 'issueflow: reconciled.' \ check "...and still reports a whole pass" 0 'issueflow: reconciled.' \
printf '%s\n' "$today_out" printf '%s\n' "$today_out"
# -- a predecessor gate is not a membership record (#343) ------------------
board_issue 317 epic,release '0.7.0 — rc becomes native' 'Blocked by #249.'
board_issue 249 epic,release 'Release 0.6.0 — the predecessor, still open' ''
board_issue 343 ready 'RELEASES.md + TRIAGE.md — membership gets its own record'
board_issue 345 ready 'actions/issueflow-reconcile — a failed dependency read'
board_assemble 317 249 343 345
shut_window_out="$(board_run)"
check "the shut window's board replays green" 0 "" test $? -eq 0
check "an epic declaring an open predecessor stands no window" 1 "" \
grep -qF ': window flag' <<<"$shut_window_out"
check "...so the ready issues it would have accused are left alone" 1 "" \
grep -qE '#(343|345): window flag' <<<"$shut_window_out"
check "...and no window state naming it is ever rendered" 1 "" \
grep -qF 'under #317' <<<"$shut_window_out"
check "...and the board is still swept whole" 0 'issueflow: reconciled.' \
printf '%s\n' "$shut_window_out"
# Over-correction control: once the record enumerates an open member, the
# window stands and still flags the unblocked non-member.
board_issue 317 epic,release '0.7.0 — rc becomes native' \
"$(printf '%s\n' 'Blocked by #249.' '' '## Members' '- #343 — the first member')"
board_assemble 317 249 343 345
opened_window_out="$(board_run)"
check "the same epic enumerating an open member does stand a window" 0 \
'issueflow: #345: window flag — an unblocked non-member under #317' \
printf '%s\n' "$opened_window_out"
check "...and its enumerated member is not flagged" 1 "" \
grep -qF 'issueflow: #343: window flag' <<<"$opened_window_out"
check "...one window flag on that board, and only one" 0 "1" \
flag_count window "$opened_window_out"
# -- the invariant is enforced at the source, not remembered ---------------- # -- the invariant is enforced at the source, not remembered ----------------
# Staging only holds while every mutation goes through run(). A future call # Staging only holds while every mutation goes through run(). A future call
# site reaching gh directly would reopen this hole silently, so it is pinned # site reaching gh directly would reopen this hole silently, so it is pinned

View file

@ -67,6 +67,19 @@ rev() { # $1=login $2=state $3=commit $4=body $5=submitted_at → one review obj
reviews() { jq -s '.' <<<"$*"; } # collect review objects into an array reviews() { jq -s '.' <<<"$*"; } # collect review objects into an array
# The approximation replaced by #238, retained only to make crew!96's
# before/after regression explicit. Production reads the forge's live set.
inferred_requests_before_238() {
local login
while IFS= read -r login; do
[ -n "$login" ] || continue
case "$(bot_verdict "$login")" in
APPROVE | BLOCK | FEEDBACK) continue ;;
esac
printf '%s\n' "$login"
done <<<"${1-}"
}
# The blocker:unrequested quiescence inputs (#236 D2). Every fixture below # The blocker:unrequested quiescence inputs (#236 D2). Every fixture below
# inherits a readable, settled world — a head commit an hour before this # inherits a readable, settled world — a head commit an hour before this
# sweep's clock — so the cases written before #236 assert exactly what they # sweep's clock — so the cases written before #236 assert exactly what they
@ -162,6 +175,93 @@ expect "an unreadable head version is silent — never nag on a guess" "" \
expect "a bare head over an unreadable base still warns" yes \ expect "a bare head over an unreadable base still warns" yes \
"$(release_shape_warning 41 2.0.0 "" | grep -qF '::warning::' && echo yes || echo no)" "$(release_shape_warning 41 2.0.0 "" | grep -qF '::warning::' && echo yes || echo no)"
# The pure matrix above proves the warning predicate. These two fixtures drive
# the sweep boundary that chooses WHICH base tree feeds it (#275): the
# reporting Forgejo payload exposes the PR's moving base tip beside its fixed
# merge base, and using the former manufactures a downgrade after an
# intervening release. A missing merge-base field deliberately keeps the
# specified base-tip fallback for backends that do not supply that fact.
release_shape_ref_probe() { # $1 = phantom | bump | fallback
(
# shellcheck disable=SC2030 # this probe intentionally isolates its repository fixture
REPO=owner/repo
LABELS_CONF="$FIXTURE_CONF"
CEREMONY_FORGE=github
mode="$1"
refs="$RTMP/release-shape-$mode-refs"
: >"$refs"
case "$mode" in
phantom)
head_ver=1.3.0 base_tip_ver=1.4.0 merge_base_ver=1.3.0
merge_base_json='"merge-base"' ;;
bump)
head_ver=1.4.0 base_tip_ver=1.3.0 merge_base_ver=1.3.0
merge_base_json='"merge-base"' ;;
fallback)
head_ver=1.4.0 base_tip_ver=1.4.0 merge_base_ver=unused
merge_base_json=null ;;
*) return 2 ;;
esac
# shellcheck disable=SC2317 # reached through the GitHub backend selected above
gh() {
if [ "$1" = label ] && [ "$2" = list ]; then
core_label_rows | cut -d'|' -f1
return 0
fi
if [ "$1" = pr ] && [ "$2" = list ]; then
printf '701\n'
return 0
fi
if [ "$1" = pr ] && [ "$2" = view ]; then
jq -n '{mergeable:"MERGEABLE",statusCheckRollup:[]}'
return 0
fi
if [ "$1" = issue ] && [ "$2" = edit ]; then return 0; fi
case "$(forge_stub_path "$*")" in
*'repos/owner/repo/pulls/701 --jq .requested_reviewers'*) return 0 ;;
*repos/owner/repo/pulls/701/reviews*) return 0 ;;
*repos/owner/repo/pulls/701)
jq -n --argjson merge_base "$merge_base_json" \
'{draft:false,user:{login:"fixture-builder"},
head:{sha:"head"},base:{sha:"base-tip"},merge_base:$merge_base,
labels:[{name:"state:addressing"},{name:"blocked"}],
requested_reviewers:[],created_at:"2026-08-31T20:00:00Z"}' ;;
*repos/owner/repo/commits/head*) printf '2026-08-31T20:00:00Z\n' ;;
*repos/owner/repo/contents/VERSION\?ref=head*)
printf 'head\n' >>"$refs"
printf '%s' "$head_ver" | base64 ;;
*repos/owner/repo/contents/VERSION\?ref=base-tip*)
printf 'base-tip\n' >>"$refs"
printf '%s' "$base_tip_ver" | base64 ;;
*repos/owner/repo/contents/VERSION\?ref=merge-base*)
printf 'merge-base\n' >>"$refs"
printf '%s' "$merge_base_ver" | base64 ;;
*) printf '[]\n' ;;
esac
}
main
)
}
phantom_shape="$(release_shape_ref_probe phantom)"
expect "an unchanged branch cut before a later base release emits no release-shape warning" \
no "$(grep -q 'release-shaped' <<<"$phantom_shape" && echo yes || echo no)"
expect "the unchanged-branch guard compares head with the PR merge base" \
$'head\nmerge-base' "$(cat "$RTMP/release-shape-phantom-refs")"
bump_shape="$(release_shape_ref_probe bump)"
expect "a genuine version bump relative to the merge base keeps the warning text" \
yes "$(grep -qF '#701 is release-shaped (version 1.3.0 -> 1.4.0 at its head)' \
<<<"$bump_shape" && echo yes || echo no)"
expect "the genuine-bump guard also compares head with the PR merge base" \
$'head\nmerge-base' "$(cat "$RTMP/release-shape-bump-refs")"
fallback_shape="$(release_shape_ref_probe fallback)"
expect "a null merge base falls back to the base tip without guessing a warning" \
no "$(grep -q 'release-shaped' <<<"$fallback_shape" && echo yes || echo no)"
expect "the null merge-base fallback compares head with the base tip" \
$'head\nbase-tip' "$(cat "$RTMP/release-shape-fallback-refs")"
# -- drafts are building, whoever is requested -------------------------------- # -- drafts are building, whoever is requested --------------------------------
DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]' DRAFT=true HEAD_SHA=head1 REQUESTED="" REVIEWS_JSON='[]'
expect "draft PR is building" state:building "$(decide_state)" expect "draft PR is building" state:building "$(decide_state)"
@ -172,6 +272,39 @@ $BOT2
$BOT3" REVIEWS_JSON='[]' $BOT3" REVIEWS_JSON='[]'
expect "requested bots mean bots-reviewing" state:bots-reviewing "$(decide_state)" expect "requested bots mean bots-reviewing" state:bots-reviewing "$(decide_state)"
# Forgejo materializes each live request as a REQUEST_REVIEW row. The backend
# returns those three logins directly; the rows are not submitted verdicts
# (#235), so an opening round stays with the panel rather than falling through
# to the builder as three comment-only answers.
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" REQUEST_REVIEW "" "" t1)" \
"$(rev "$BOT2" REQUEST_REVIEW "" "" t2)" \
"$(rev "$BOT3" REQUEST_REVIEW "" "" t3)")"
REQUESTED="$BOT1
$BOT2
$BOT3"
expect "three Forgejo request rows keep the opening round with the panel" \
state:bots-reviewing "$(round_state)"
# crew!96 after a fix push: every submitted verdict belongs to the older head,
# and the forge carries no live request row. The old intersection mistakes the
# two stale approvals for requests; the exact empty set correctly gives the
# incomplete round back to the builder (#238).
HEAD_SHA=head2
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" 2026-08-22T23:05:01Z)" \
"$(rev "$BOT2" APPROVED head1 "" 2026-08-22T23:14:45Z)" \
"$(rev "$BOT3" REQUEST_CHANGES head1 "" 2026-08-22T23:03:39Z)")"
REQUESTED="$(inferred_requests_before_238 "$BOT1
$BOT2
$BOT3")"
expect "crew!96 old inferred requests hand the fix round to the panel" \
state:bots-reviewing "$(round_state)"
REQUESTED=""
expect "crew!96 exact empty requests return the fix round to the builder" \
state:addressing "$(round_state)"
HEAD_SHA=head1
# -- a bot that never reviewed keeps the round open --------------------------- # -- a bot that never reviewed keeps the round open ---------------------------
# With a live request that is the bots' ball; with NO request outstanding it # With a live request that is the bots' ball; with NO request outstanding it
# is the agent's, because nothing is coming until somebody asks. # is the agent's, because nothing is coming until somebody asks.
@ -215,6 +348,25 @@ REVIEWS_JSON="$(reviews \
"$(rev "$BOT3" APPROVED head1 "" t3)")" "$(rev "$BOT3" APPROVED head1 "" t3)")"
expect "changes-requested blocks even from an old head" state:addressing "$(decide_state)" expect "changes-requested blocks even from an old head" state:addressing "$(decide_state)"
# bot_verdict grades submitted states from both forges (#235). Each direct
# assertion names one arm so a later vocabulary regression cannot hide behind
# round_state's shared BLOCK/FEEDBACK handling.
expect "GitHub CHANGES_REQUESTED grades as a block" BLOCK \
"$(bot_verdict "$BOT1")"
REVIEWS_JSON="$(reviews "$(rev "$BOT1" REQUEST_CHANGES old1 "blockers below" t1)")"
expect "Forgejo REQUEST_CHANGES grades as a block" BLOCK \
"$(bot_verdict "$BOT1")"
REVIEWS_JSON="$(reviews "$(rev "$BOT1" COMMENT head1 "non-blocking note" t1)")"
expect "Forgejo COMMENT grades as feedback" FEEDBACK \
"$(bot_verdict "$BOT1")"
REVIEWS_JSON="$(reviews "$(rev "$BOT1" FUTURE_FORGE_STATE head1 "" t1)")"
bot_verdict "$BOT1" >"$RTMP/unknown-verdict" 2>"$RTMP/unknown-verdict-log"
expect "an unrecognised review state is conservatively missing" MISSING \
"$(cat "$RTMP/unknown-verdict")"
expect "an unrecognised review state logs the login and spelling" yes \
"$(grep -qF "$BOT1: unrecognised review state FUTURE_FORGE_STATE" \
"$RTMP/unknown-verdict-log" && echo yes || echo no)"
# -- a stale approval must not promote unreviewed code ------------------------ # -- a stale approval must not promote unreviewed code ------------------------
REVIEWS_JSON="$(reviews \ REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED old1 "" t1)" \ "$(rev "$BOT1" APPROVED old1 "" t1)" \
@ -253,6 +405,24 @@ REQUESTED="$HUMAN"
expect "re-requested human is needs-human again" state:needs-human "$(decide_state)" expect "re-requested human is needs-human again" state:needs-human "$(decide_state)"
REQUESTED="" REQUESTED=""
# Forgejo's human-block spelling carries the same meaning (#235). This is
# independently observable because only BLOCK prevents state:needs-human once
# every bot approves.
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)" \
"$(rev "$HUMAN" REQUEST_CHANGES head1 "not yet" t4)")"
expect "Forgejo human request-changes with bots approving is addressing" \
state:addressing "$(decide_state)"
REVIEWS_JSON="$(reviews \
"$(rev "$BOT1" APPROVED head1 "" t1)" \
"$(rev "$BOT2" APPROVED head1 "" t2)" \
"$(rev "$BOT3" APPROVED head1 "" t3)" \
"$(rev "$HUMAN" APPROVED head1 "" t4)")"
expect "the same Forgejo-shaped fixture with human approval reaches needs-human" \
state:needs-human "$(decide_state)"
# -- an old human comment must not wedge the handoff (codex, #85 round 3) ----- # -- an old human comment must not wedge the handoff (codex, #85 round 3) -----
REVIEWS_JSON="$(reviews \ REVIEWS_JSON="$(reviews \
"$(rev "$HUMAN" COMMENTED old1 "early thoughts" t0)" \ "$(rev "$HUMAN" COMMENTED old1 "early thoughts" t0)" \
@ -506,6 +676,61 @@ expect "a rollup of ONLY self entries is honestly NONE, never SUCCESS" NONE \
expect "a genuine foreign FAILURE still blocks beside a cancelled self entry" FAILURE \ expect "a genuine foreign FAILURE still blocks beside a cancelled self entry" FAILURE \
"$(rollup "[$(run_ a FAILURE),\ "$(rollup "[$(run_ a FAILURE),\
$(rec_ CANCELLED 2026-08-01T15:17:56Z 2026-08-01T15:17:59Z)]" | checks_state)" $(rec_ CANCELLED 2026-08-01T15:17:56Z 2026-08-01T15:17:59Z)]" | checks_state)"
# Forgejo exposes Actions checks only as commit statuses. Drive the real
# backend mapper here: fixtures that hand-write workflowName repeat the field
# omission that hid #243 instead of proving the adapter supplies it.
forgejo_rollup() {
local statuses="$1"
(
# shellcheck source=lib/forge-forgejo.sh
. lib/forge-forgejo.sh
forge_api() {
case "$1" in
repos/o/r/pulls/5)
printf '%s\n' '{"head":{"sha":"abc"},"base":{"sha":"base"},"merge_base":"ancestor","draft":false,"mergeable":true}'
;;
repos/o/r/commits/abc/status)
printf '%s\n' "$statuses"
;;
*) return 1 ;;
esac
}
REPO=o/r forge_pr_view 5
)
}
forgejo_self_red='{"state":"failure","statuses":[
{"context":"labels / labels (pull_request)","status":"failure","created_at":"2026-08-23T18:32:00Z","updated_at":"2026-08-23T18:32:00Z"},
{"context":"CI / test (pull_request)","status":"pending","created_at":"2026-08-23T18:32:00Z","updated_at":"2026-08-23T18:32:00Z"}]}'
expect "Forgejo StatusContexts exclude a self FAILURE and retain pending work" PENDING \
"$(forgejo_rollup "$forgejo_self_red" | checks_state)"
forgejo_self_only='{"state":"failure","statuses":[
{"context":"labels / labels (pull_request)","status":"failure","created_at":"2026-08-23T18:32:00Z","updated_at":"2026-08-23T18:32:00Z"}]}'
expect "Forgejo StatusContexts containing only the self workflow are NONE" NONE \
"$(forgejo_rollup "$forgejo_self_only" | checks_state)"
forgejo_foreign_red='{"state":"failure","statuses":[
{"context":"labels / labels (pull_request)","status":"failure","created_at":"2026-08-23T18:32:00Z","updated_at":"2026-08-23T18:32:00Z"},
{"context":"CI / test (pull_request)","status":"failure","created_at":"2026-08-23T18:32:00Z","updated_at":"2026-08-23T18:32:00Z"}]}'
expect "Forgejo StatusContexts retain a genuine foreign FAILURE" FAILURE \
"$(forgejo_rollup "$forgejo_foreign_red" | checks_state)"
forgejo_bare_red='{"state":"failure","statuses":[
{"context":"codecov","status":"failure","created_at":"2026-08-23T18:32:00Z","updated_at":"2026-08-23T18:32:00Z"}]}'
expect "a bare Forgejo context survives a named self-workflow exclusion" FAILURE \
"$(forgejo_rollup "$forgejo_bare_red" | checks_state)"
forgejo_nested_name='{"state":"failure","statuses":[
{"context":"labels / nested / check","status":"failure","created_at":"2026-08-23T18:32:00Z","updated_at":"2026-08-23T18:32:00Z"}]}'
SELF_WORKFLOW="labels / nested"
expect "a Forgejo workflow name containing a separator is never guessed" FAILURE \
"$(forgejo_rollup "$forgejo_nested_name" | checks_state)"
SELF_WORKFLOW=""
expect "a bare Forgejo context also survives an empty self-workflow exclusion" FAILURE \
"$(forgejo_rollup "$forgejo_bare_red" | checks_state)"
# ...and an empty self filters NOTHING: outside Actions no workflow name is # ...and an empty self filters NOTHING: outside Actions no workflow name is
# ambient, and the exclusion must never drop entries on a guess — the same # ambient, and the exclusion must never drop entries on a guess — the same
# displaced-self rollup keeps blocking there, all-cancelled context intact. # displaced-self rollup keeps blocking there, all-cancelled context intact.
@ -1588,50 +1813,56 @@ expect "...with no 'reconciled.' token in the output" \
expect "...naming the attempt that did not happen" \ expect "...naming the attempt that did not happen" \
yes "$(grep -q 'label edit FAILED' <<<"$sf_out" && echo yes || echo no)" yes "$(grep -q 'label edit FAILED' <<<"$sf_out" && echo yes || echo no)"
# --------------------------------------------------------------------------- # Drive the ingestion expression through main(), independently of
# outstanding_requests — the portable "who still owes a verdict" (#188 term 4) # bot_verdict (#235). Capturing REVIEWS_JSON at the review-request read that
# # follows it proves REQUEST_REVIEW never reaches the grader; the COMMENT and
# GitHub clears requested_reviewers when a verdict lands; Forgejo never does. # APPROVED controls prove both gradeable Forgejo states and rows generally
# Measured 2026-08-02: rig!140 listed all three panelists with all three # survive the filter.
# verdicts in, and rig!146 still lists three while MERGED. Read raw on review_filter_probe() {
# Forgejo, that pins a PR at state:bots-reviewing for life and stops (
# blocker:unrequested from ever being true. REPO=owner/repo
# --------------------------------------------------------------------------- LABELS_CONF="$FIXTURE_CONF"
HEAD_SHA=head1 CEREMONY_FORGE=github
REVIEWS_JSON="$(reviews \ # shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
"$(rev "$BOT1" APPROVED head1 "" 2026-08-01T00:00:00Z)" \ gh() {
"$(rev "$BOT2" CHANGES_REQUESTED head1 "" 2026-08-01T00:00:00Z)" \ if [ "$1" = label ] && [ "$2" = list ]; then core_label_rows | cut -d'|' -f1; return 0; fi
"$(rev "$BOT3" APPROVED head0 "" 2026-07-01T00:00:00Z)")" if [ "$1" = pr ] && [ "$2" = list ]; then printf '%s\n' 601; return 0; fi
if [ "$1" = pr ] && [ "$2" = view ]; then
expect "a head-current approval is no longer outstanding" "" \ jq -n '{mergeable:"MERGEABLE",statusCheckRollup:[]}'
"$(outstanding_requests "$BOT1")" return 0
expect "a blocking verdict is not outstanding either — it is answered" "" \ fi
"$(outstanding_requests "$BOT2")" if [ "$1" = issue ] && [ "$2" = edit ]; then return 0; fi
# The one that matters: an approval of an OLDER head is not a verdict on this case "$(forge_stub_path "$*")" in
# head, so that reviewer still owes one. Treating STALE as answered would let *repos/owner/repo/pulls/601/reviews*)
# a stale round read as complete. jq -nc \
expect "a stale approval still owes a verdict" "$BOT3" \ '{user:{login:"fixture-bot-one"},state:"REQUEST_REVIEW",commit_id:"",submitted_at:"2026-08-22T00:46:05Z"},
"$(outstanding_requests "$BOT3")" {user:{login:"fixture-bot-three"},state:"COMMENT",commit_id:"head1",submitted_at:"2026-08-22T00:46:35Z"},
expect "a reviewer who never reviewed still owes one" "nobody" \ {user:{login:"fixture-bot-two"},state:"APPROVED",commit_id:"head1",submitted_at:"2026-08-22T00:47:05Z"}' ;;
"$(outstanding_requests "nobody")" */pulls/601)
jq -n '{draft:true,user:{login:"fixture-builder"},head:{sha:"head1"},base:{sha:"base1"},
# The Forgejo shape, end to end: the field lists all three long after every labels:[{name:"state:building"}],requested_reviewers:[],
# verdict landed. Only the stale one may survive the filter. created_at:"2026-08-22T00:45:00Z"}' ;;
expect "the never-cleared forgejo field collapses to who actually owes" \ *) printf '[]\n' ;;
"$BOT3" "$(outstanding_requests "$BOT1 esac
$BOT2 }
$BOT3")" # main normally re-sources the selected backend. This probe already runs
# with the GitHub backend selected at suite startup; keeping that selection
# The GitHub shape: the field is already accurate, so the filter is a no-op # stable lets the verb override below observe the post-REVIEWS_JSON boundary.
# on the set GitHub would have produced (term 5 — behaviour unchanged). forge_select() { return 0; }
expect "on a github-shaped field the filter removes nothing" "nobody" \ # shellcheck disable=SC2317 # main invokes the probe override indirectly
"$(outstanding_requests "nobody")" forge_pr_review_requests() {
expect "an empty request list stays empty" "" "$(outstanding_requests "")" printf '%s\n' "$REVIEWS_JSON" >"$RTMP/gradeable-reviews.json"
}
main >/dev/null
)
}
review_filter_probe
expect "REQUEST_REVIEW is removed before REVIEWS_JSON reaches the grader" \
COMMENT,APPROVED "$(jq -r 'map(.state) | join(",")' "$RTMP/gradeable-reviews.json")"
# The summary and the gate belong at the TRUE end of the file. They sat in the # The summary and the gate belong at the TRUE end of the file. They sat in the
# middle until #188: eight outstanding_requests expects were appended after # middle until #188, so a later failure printed FAIL, was left out of the
# them, so a failure there printed FAIL, was left out of the totals, and the # totals, and the suite still exited 0 (@codex-reviewer-andresmgsl #4780 item
# suite still exited 0 (@codex-reviewer-andresmgsl #4780 item 2). Anything # 2). Anything appended below this line is ungated — so nothing goes below it.
# appended below this line is ungated — so nothing goes below it.
printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail" printf 'labels-reconcile tests: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ] [ "$fail" -eq 0 ]

View file

@ -150,4 +150,33 @@ done
check "pull_request_target keeps the labeled handoff wake" 0 "labeled" \ check "pull_request_target keeps the labeled handoff wake" 0 "labeled" \
trigger_types "$SELF" pull_request_target 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 summary

View file

@ -31,6 +31,15 @@ attention_row='attention|D93F0B|A demand is parked here for the assignee: pick u
check "attention core row is emitted once, byte-exact" 0 "1" bash -c \ check "attention core row is emitted once, byte-exact" 0 "1" bash -c \
'source "$1"; core_label_rows | grep -cxF "$2"' _ \ 'source "$1"; core_label_rows | grep -cxF "$2"' _ \
"$ROOT/actions/labels-reconcile/labels-reconcile.sh" "$attention_row" "$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 # shellcheck disable=SC2016 # fields are intentionally split in the nested shell
check "attention description survives label field splitting" 0 \ 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" \ "A demand is parked here for the assignee: pick up the thread, ack by removing this label" \
@ -246,7 +255,7 @@ sed 's/^panel=/panel=ghost-bot /' "$CONF" >"$mut_conf"
check "an identity in the conf but not the table goes red" 1 "ghost-bot" \ check "an identity in the conf but not the table goes red" 1 "ghost-bot" \
roster_in_sync "$mut_conf" "$CONTRIB" roster_in_sync "$mut_conf" "$CONTRIB"
# shellcheck disable=SC2016 # the backticks are the table's, not a subshell # shellcheck disable=SC2016 # the backticks are the table's, not a subshell
sed 's/^| `glm-reviewer-andresmgsl`/| `ghost-bot`/' "$CONTRIB" >"$mut_contrib" 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" \ check "an identity in the table but not the conf goes red" 1 "ghost-bot" \
roster_in_sync "$CONF" "$mut_contrib" roster_in_sync "$CONF" "$mut_contrib"

121
test/preflight.test.sh Executable file
View file

@ -0,0 +1,121 @@
#!/usr/bin/env bash
# Contract tests for lib/preflight.sh (issue #273) — every row of the
# merge-door resume table, offline. set -u, not -e: refusals 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"
PREFLIGHT="$ROOT/lib/preflight.sh"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
VER=1.2.3
MERGE_SHA=1111111111111111111111111111111111111111
FOREIGN_SHA=2222222222222222222222222222222222222222
# preflight <VER> <MERGE_SHA> <TAG_SHAS> <RELEASED> — run the pure decision
# with exactly the four gathered facts in its environment.
preflight() {
# shellcheck disable=SC2016 # PREFLIGHT expands inside the isolated child
env VER="$1" MERGE_SHA="$2" TAG_SHAS="$3" RELEASED="$4" \
PREFLIGHT="$PREFLIGHT" bash -c '. "$PREFLIGHT"; release_preflight'
}
preflight_stdout() {
preflight "$@" 2>/dev/null
}
preflight_stderr() {
{ preflight "$@" >/dev/null; } 2>&1
}
refuses_without_output() {
local out rc
out="$(preflight "$@" 2>/dev/null)"
rc=$?
[ "$rc" -eq 1 ] && [ -z "$out" ]
}
# --- the four table rows ----------------------------------------------------
check "row 1: a published release refuses even with no tag" 1 \
"release '$VER' already exists — this release already happened" \
preflight_stderr "$VER" "$MERGE_SHA" "" yes
check "row 1: a published release refuses with the matching tag" 1 \
"release '$VER' already exists — this release already happened" \
preflight_stderr "$VER" "$MERGE_SHA" "$MERGE_SHA" yes
check "row 1: a published release refuses with a foreign tag" 1 \
"release '$VER' already exists — this release already happened" \
preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" yes
check "row 1: refusal emits no workflow output" 0 "" \
refuses_without_output "$VER" "$MERGE_SHA" "$MERGE_SHA" yes
check "row 2: an ordinary first run proceeds" 0 "resume=no" \
preflight_stdout "$VER" "$MERGE_SHA" "" no
check "row 3: the matching tag resumes" 0 "resume=yes" \
preflight_stdout "$VER" "$MERGE_SHA" "$MERGE_SHA" no
check "row 3: resume notice names the previous failed publish" 0 \
"a previous run of this door tagged and then failed to publish" \
preflight_stdout "$VER" "$MERGE_SHA" "$MERGE_SHA" no
check "row 3: an annotated tag resumes when the peeled ref matches" 0 \
"resume=yes" preflight_stdout "$VER" "$MERGE_SHA" \
"$FOREIGN_SHA"$'\n'"$MERGE_SHA" no
check "row 3: an annotated tag resumes when the direct ref matches" 0 \
"resume=yes" preflight_stdout "$VER" "$MERGE_SHA" \
"$MERGE_SHA"$'\n'"$FOREIGN_SHA" no
check "row 4: a foreign tag refuses" 1 "tag '$VER' already exists" \
preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no
check "row 4: the refusal names the foreign tag SHA" 1 "$FOREIGN_SHA" \
preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no
check "row 4: the refusal names the merge SHA" 1 "$MERGE_SHA" \
preflight_stderr "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no
check "row 4: refusal emits no workflow output" 0 "" \
refuses_without_output "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no
# A ref object that merely contains MERGE_SHA is not the merge commit. Each
# ls-remote object name is compared as a whole line.
PREFIX_SHA="${MERGE_SHA%?}"
check "a prefix of MERGE_SHA does not resume" 1 "already exists at $PREFIX_SHA" \
preflight_stderr "$VER" "$MERGE_SHA" "$PREFIX_SHA" no
check "a line containing MERGE_SHA does not resume" 1 \
"already exists at x${MERGE_SHA}y" \
preflight_stderr "$VER" "$MERGE_SHA" "x${MERGE_SHA}y" no
# --- fact validation --------------------------------------------------------
check "empty VER refuses" 1 "VER is empty" \
preflight_stderr "" "$MERGE_SHA" "" no
check "empty MERGE_SHA refuses" 1 "MERGE_SHA is empty" \
preflight_stderr "$VER" "" "" no
check "empty RELEASED refuses" 1 "RELEASED is empty" \
preflight_stderr "$VER" "$MERGE_SHA" "" ""
check "malformed RELEASED refuses" 1 "RELEASED='maybe' — expected yes or no" \
preflight_stderr "$VER" "$MERGE_SHA" "" maybe
# --- stream discipline and purity ------------------------------------------
notice_stays_on_stdout() {
local stdout stderr
stdout="$(preflight "$VER" "$MERGE_SHA" "$MERGE_SHA" no 2>"$TMP/preflight.err")"
stderr="$(cat "$TMP/preflight.err")"
[ -n "$stdout" ] && [ -z "$stderr" ]
}
refusal_stays_on_stderr() {
local stdout stderr rc
stdout="$(preflight "$VER" "$MERGE_SHA" "$FOREIGN_SHA" no 2>"$TMP/preflight.err")"
rc=$?
stderr="$(cat "$TMP/preflight.err")"
[ "$rc" -eq 1 ] && [ -z "$stdout" ] && [ -n "$stderr" ]
}
no_tool_calls() {
! grep -v '^[[:space:]]*#' "$PREFLIGHT" | grep -Ewq 'git|gh|curl|wget'
}
check "resume notice and output stay on stdout" 0 "" notice_stays_on_stdout
check "refusal stays on stderr" 0 "" refusal_stays_on_stderr
check "preflight calls no git/gh/network tools" 0 "" no_tool_calls
summary

View file

@ -97,6 +97,30 @@ path_check() {
[ -z "$missing" ] && [ -z "$extra" ] [ -z "$missing" ] && [ -z "$extra" ]
} }
path_check_reports_only() {
local tree="$1" expected="$2" output rc
output="$(path_check "$tree" 2>&1)"
rc=$?
if [ "$rc" -ne 1 ] || [ "$output" != "$expected" ]; then
printf 'expected only: %s\ngot (exit %s): %s\n' \
"$expected" "$rc" "$output" >&2
return 1
fi
}
readme_has_no_path_enumeration() {
local token found=no
for token in \
bin/ lib/version.sh lib/decide.sh lib/facts.sh lib/changelog.sh lib/forge.sh
do
if grep -qF "$token" "$ROOT/drills/README.md"; then
printf 'drill doctrine enumerates release path: %s\n' "$token" >&2
found=yes
fi
done
[ "$found" = no ]
}
fixture() { fixture() {
local name="$1" tree local name="$1" tree
tree="$TMP/$name" tree="$TMP/$name"
@ -106,6 +130,7 @@ fixture() {
"\$ROOT/lib/changelog.sh" >"$tree/bin/assemble" "\$ROOT/lib/changelog.sh" >"$tree/bin/assemble"
printf '#!/usr/bin/env bash\n' >"$tree/lib/changelog.sh" printf '#!/usr/bin/env bash\n' >"$tree/lib/changelog.sh"
printf '#!/usr/bin/env bash\n' >"$tree/lib/decide.sh" printf '#!/usr/bin/env bash\n' >"$tree/lib/decide.sh"
printf '#!/usr/bin/env bash\n' >"$tree/lib/preflight.sh"
# facts.sh sources BOTH on this tree: version.sh, and the forge shim #191 # facts.sh sources BOTH on this tree: version.sh, and the forge shim #191
# put on the doors' path so a Forgejo consumer can publish (#198). The # put on the doors' path so a Forgejo consumer can publish (#198). The
# synthetic tree mirrors the real one, or every fixture below reports # synthetic tree mirrors the real one, or every fixture below reports
@ -121,61 +146,65 @@ fixture() {
# Exact output is the record author's copy-paste source. # Exact output is the record author's copy-paste source.
check "manifest prints the specified ordered release path" 0 \ check "manifest prints the specified ordered release path" 0 \
$'.github/workflows/release.yml\nbin/\nlib/version.sh\nlib/decide.sh\nlib/facts.sh\nlib/changelog.sh\nlib/forge.sh' \ $'.github/workflows/release.yml\nbin/\nlib/version.sh\nlib/decide.sh\nlib/preflight.sh\nlib/facts.sh\nlib/changelog.sh\nlib/forge.sh' \
bash "$PATH_SCRIPT" bash "$PATH_SCRIPT"
check "real workflow and transitive dependencies match the manifest" 0 "" \ check "real workflow and transitive dependencies match the manifest" 0 "" \
path_check "$ROOT" path_check "$ROOT"
check "drill doctrine does not duplicate the executable release path" 0 "" \
readme_has_no_path_enumeration
# A door growing a dependency must name the missing path (#237 D7). # A door growing a dependency must name the missing path (#237 D7).
tree="$(fixture missing)" tree="$(fixture missing)"
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\nrun: . "%s"\n' \ printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\nrun: . "%s"\nrun: . "%s"\n' \
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \ "\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
"\$CEREMONY_DIR/lib/changelog.sh" "\$CEREMONY_DIR/lib/version.sh" \ "\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \
"\$CEREMONY_DIR/lib/ruling.sh" \ "\$CEREMONY_DIR/lib/version.sh" "\$CEREMONY_DIR/lib/ruling.sh" \
>"$tree/.github/workflows/release.yml" >"$tree/.github/workflows/release.yml"
printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh" printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh"
check "a new workflow library fails with its missing path" 1 \ check "a new workflow library fails with only its missing path" 0 "" \
"missing dependency: lib/ruling.sh" path_check "$tree" path_check_reports_only "$tree" \
"release-path: missing dependency: lib/ruling.sh"
# A library growing a sibling dependency in the production idiom must also # A library growing a sibling dependency in the production idiom must also
# name the missing path; a literal lib/ marker in a comment is not evidence. # name the missing path; a literal lib/ marker in a comment is not evidence.
tree="$(fixture missing-transitive)" tree="$(fixture missing-transitive)"
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \ printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\n' \
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \ "\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
"\$CEREMONY_DIR/lib/changelog.sh" \ "\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \
>"$tree/.github/workflows/release.yml" >"$tree/.github/workflows/release.yml"
printf '# shellcheck source=lib/ruling.sh\n. "%s"\n' \ printf '# shellcheck source=lib/ruling.sh\n. "%s"\n' \
"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/ruling.sh" \ "\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/ruling.sh" \
>>"$tree/lib/facts.sh" >>"$tree/lib/facts.sh"
printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh" printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh"
check "a new sibling library fails with its missing path" 1 \ check "a new sibling library fails with only its missing path" 0 "" \
"missing dependency: lib/ruling.sh" path_check "$tree" path_check_reports_only "$tree" \
"release-path: missing dependency: lib/ruling.sh"
# A manifest may not rot into a safe-looking superset. # A manifest may not rot into a safe-looking superset.
tree="$(fixture extra)" tree="$(fixture extra)"
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \ printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\n' \
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \ "\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
"\$CEREMONY_DIR/lib/changelog.sh" \ "\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \
>"$tree/.github/workflows/release.yml" >"$tree/.github/workflows/release.yml"
sed -i 's| lib/forge.sh$| lib/forge.sh \\|' \ sed -i '$ s|$| \\|' \
"$tree/.github/scripts/release-path.sh" "$tree/.github/scripts/release-path.sh"
printf ' lib/ruling.sh\n' >>"$tree/.github/scripts/release-path.sh" printf ' lib/ruling.sh\n' >>"$tree/.github/scripts/release-path.sh"
printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh" printf '#!/usr/bin/env bash\n' >"$tree/lib/ruling.sh"
check "a path no door reads fails as stale" 1 "stale path: lib/ruling.sh" \ check "a path no door reads fails with only its stale path" 0 "" \
path_check "$tree" path_check_reports_only "$tree" "release-path: stale path: lib/ruling.sh"
# Transitive sourcing is part of the derivation, not decoration. # Transitive sourcing is part of the derivation, not decoration.
tree="$(fixture transitive)" tree="$(fixture transitive)"
printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\n' \ printf 'run: bash "%s"\nrun: bash "%s"\nrun: . "%s"\nrun: . "%s"\n' \
"\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \ "\$CEREMONY_DIR/lib/facts.sh" "\$CEREMONY_DIR/lib/decide.sh" \
"\$CEREMONY_DIR/lib/changelog.sh" \ "\$CEREMONY_DIR/lib/preflight.sh" "\$CEREMONY_DIR/lib/changelog.sh" \
>"$tree/.github/workflows/release.yml" >"$tree/.github/workflows/release.yml"
# Only the version source is dropped; the forge source #191 added stays, or # Only the version source is dropped; the forge source #191 added stays, or
# the fixture reports two stale paths and proves neither of them (#198). # the fixture reports two stale paths and proves neither of them (#198).
printf '#!/usr/bin/env bash\n# shellcheck source=lib/forge.sh\n. "%s"\n' \ printf '#!/usr/bin/env bash\n# shellcheck source=lib/forge.sh\n. "%s"\n' \
"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/forge.sh" \ "\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)/forge.sh" \
>"$tree/lib/facts.sh" >"$tree/lib/facts.sh"
check "removing facts' version source fails as a stale path" 1 \ check "removing facts' version source fails with only its stale path" 0 "" \
"stale path: lib/version.sh" path_check "$tree" path_check_reports_only "$tree" "release-path: stale path: lib/version.sh"
summary summary