2026-07-22 19:21:01 +00:00
#!/usr/bin/env bash
set -u
ROOT = " $( cd " $( dirname " ${ BASH_SOURCE [0] } " ) /.. " && pwd ) "
# shellcheck source=test/harness.sh
source " $ROOT /test/harness.sh "
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
# The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary
# now, and forge_api/forge_issue_edit/... resolve to the gh invocations those
# stubs already intercept (#188). Without this the verbs are undefined.
# shellcheck source=lib/forge.sh
. " $ROOT /lib/forge.sh "
forge_select github
2026-07-22 19:21:01 +00:00
# shellcheck source=actions/issueflow-reconcile/issueflow-reconcile.sh
source " $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh "
TMP = " $( mktemp -d) "
trap 'rm -rf "$TMP"' EXIT
printf '%s\n' \
'panel=one two' \
'triage-actors=triage-one triage-two' \
'scope:one|C5DEF5|First scope' >" $TMP /good.conf "
check "triage actors parse beside panel and labels" 0 "" load_issueflow_config " $TMP /good.conf "
load_issueflow_config " $TMP /good.conf "
check "triage actor is recognized" 0 "" is_triage_actor triage-two
check "non-triage actor is rejected" 1 "" is_triage_actor builder
printf '%s\n' 'panel=one' >" $TMP /missing.conf "
check "missing triage actors fails loudly" 1 "missing triage-actors=" load_issueflow_config " $TMP /missing.conf "
printf '%s\n' 'triage-actors=one' 'triage-actors=two' >" $TMP /duplicate.conf "
check "duplicate triage actors fails loudly" 1 "duplicate triage-actors" load_issueflow_config " $TMP /duplicate.conf "
2026-08-02 13:23:41 +00:00
# A panel[<login>]= row (#224 D8) must not take the issue board down: this
# loader ignores every line that is not triage-actors=. That tolerance was
# incidental; this row makes it deliberate, so a future tightening cannot
# break the sweep as a side effect.
printf '%s\n' \
'panel=one two' \
'panel[builder-z]=two' \
'triage-actors=triage-one' >" $TMP /bracketed.conf "
check "a per-author panel row is tolerated by the issue-flow loader" 0 "" \
load_issueflow_config " $TMP /bracketed.conf "
2026-07-22 19:21:01 +00:00
2026-07-23 10:47:30 +00:00
# The dogfood caller and reusable workflow must expose the same runtime facts
# as the documented consumer stub. Static pins catch YAML blocks drifting to
# the adjacent composite step, which otherwise fails only after merge.
check "dogfood caller wakes on issue events" 0 " issues:" \
grep -F " issues:" " $ROOT /.github/workflows/self-labels.yml "
dogfood_pr_step = " $( sed -n \
'/name: reconcile state + stale (dogfood/,/name: reconcile issue flow/p' \
2026-08-01 16:20:00 +00:00
" $ROOT /.github/workflows/labels-sweep.yml " ) "
2026-07-23 10:49:36 +00:00
# shellcheck disable=SC2016 # GitHub expressions are asserted as literals
2026-07-23 10:47:30 +00:00
check "dogfood PR reconcile receives repository" 0 ' REPO: ${{ github.repository }}' \
grep -F ' REPO: ${{ github.repository }}' <<< " $dogfood_pr_step "
2026-07-23 10:49:36 +00:00
# shellcheck disable=SC2016 # GitHub expressions are asserted as literals
2026-07-23 10:47:30 +00:00
check "dogfood PR reconcile receives token" 0 ' GH_TOKEN: ${{ github.token }}' \
grep -F ' GH_TOKEN: ${{ github.token }}' <<< " $dogfood_pr_step "
2026-07-22 19:21:01 +00:00
# Invariant 1: exactly one queue category.
check "one ready queue label is valid" 0 "KEEP" queue_decision <<< "ready"
check "zero queue labels is derivably needs-triage" 0 "ADD_NEEDS_TRIAGE" queue_decision <<< "enhancement"
check "multiple queue labels are ambiguous" 0 "FLAG_CONFLICT" queue_decision <<< $'ready\nblocked'
check "needs-triage plus queue is a conflict" 0 "FLAG_CONFLICT" queue_decision <<< $'needs-triage\nready'
2026-07-25 00:11:42 +00:00
check "claimed plus post-merge is a conflict" 0 "FLAG_CONFLICT" \
queue_decision <<< $'claimed\npost-merge'
check "post-merge plus needs-ruling is healthy" 0 "KEEP" \
queue_decision <<< $'post-merge\nneeds-ruling'
2026-07-22 19:21:01 +00:00
# Invariant 2: claims have an owner and either a PR or recent activity.
check "claim with open PR stays claimed" 0 "KEEP" claim_decision 1 true 999999
check "unassigned claim is flagged" 0 "FLAG_UNASSIGNED" claim_decision 0 false 60
2026-07-22 19:24:45 +00:00
check "quiet unassigned claim is also reclaimed" 0 "RECLAIM" claim_decision 0 false $(( STALE_AFTER + 1 ))
2026-07-23 10:29:00 +00:00
# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process
2026-07-23 10:30:34 +00:00
check "injected clock: below stale boundary stays claimed" 0 "KEEP" \
bash -c 'ISSUEFLOW_NOW=100000 ISSUEFLOW_STALE_HOURS=1 source "$1"; claim_decision_at 1 false 96401' _ \
2026-07-23 10:29:00 +00:00
" $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh "
# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process
2026-07-23 10:30:34 +00:00
check "injected clock: exact stale boundary stays claimed" 0 "KEEP" \
bash -c 'ISSUEFLOW_NOW=100000 ISSUEFLOW_STALE_HOURS=1 source "$1"; claim_decision_at 1 false 96400' _ \
" $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh "
# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process
check "injected clock: past stale boundary is reclaimed" 0 "RECLAIM" \
bash -c 'ISSUEFLOW_NOW=100000 ISSUEFLOW_STALE_HOURS=1 source "$1"; claim_decision_at 1 false 96399' _ \
2026-07-23 10:29:00 +00:00
" $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh "
2026-07-23 10:49:36 +00:00
# shellcheck disable=SC2016 # expansion belongs to the isolated bash -c process
check "invalid injected clock fails loudly" 1 "ISSUEFLOW_NOW must be UTC epoch seconds" \
bash -c 'ISSUEFLOW_NOW=garbage source "$1"' _ \
" $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh "
check "reclaim marker is stable within a claim episode" 0 "claim-reclaimed-96399" \
claim_reclaim_marker 96399
check "a later claim episode receives a new reclaim marker" 0 "claim-reclaimed-99999" \
claim_reclaim_marker 99999
2026-07-23 12:48:04 +00:00
check "offsite exempts the claim clock" 0 "EXEMPT" claim_clock_exempt <<< "offsite"
check "needs-ruling still exempts through the shared gate" 0 "EXEMPT" \
claim_clock_exempt <<< "needs-ruling"
check "both quiet flags still produce one exemption verdict" 0 "EXEMPT" \
claim_clock_exempt <<< $'offsite\nneeds-ruling'
check "blocked does not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt <<< "blocked"
2026-07-23 17:28:02 +00:00
check "attention does not exempt a claimed issue" 0 "SWEEP" \
claim_clock_exempt <<< "attention"
2026-07-23 12:48:04 +00:00
check "ready does not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt <<< "ready"
check "empty labels do not exempt a claimed issue" 0 "SWEEP" claim_clock_exempt </dev/null
2026-07-25 04:29:37 +00:00
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 "" \
test " $( refs_references <<< " $refs_body " ) " = $'8\n12\n175'
2026-08-03 15:49:57 +00:00
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' \
open_pr_issues <<< " $open_records "
check "cross-repo Refs never enter the local open PR set" 0 "" \
open_pr_issues <<< $'BODY\tRefs heavy-duty/rig#112'
check "an issue named by both linkage paths appears exactly once" 0 "1" \
grep -cxF 5 <<< " $( open_pr_issues <<< " $open_records " ) "
2026-07-25 00:11:42 +00:00
check "unchecked criteria preserve their source lines verbatim" 0 \
2026-07-25 04:29:37 +00:00
$'- [ ] first criterion\n * [ ] indented criterion\n1. [ ] numbered criterion' \
unchecked_criteria <<< $'- [x] done\n- [ ] first criterion\r\n * [ ] indented criterion\n1. [ ] numbered criterion'
2026-07-25 00:11:42 +00:00
check "merged Refs with unchecked criteria transitions" 0 "TRANSITION" \
2026-07-25 04:29:37 +00:00
post_merge_decision 12 false false <<< "- [ ] verify after merge"
2026-07-25 00:11:42 +00:00
check "open Refs does not transition" 0 "KEEP" \
2026-07-25 04:29:37 +00:00
post_merge_decision 12 true false <<< "- [ ] verify after merge"
check "a handled merged Refs episode does not transition again" 0 "KEEP" \
post_merge_decision 12 false true <<< "- [ ] verify after merge"
2026-07-25 00:11:42 +00:00
check "merged Refs with all criteria checked does not transition" 0 "KEEP" \
2026-07-25 04:29:37 +00:00
post_merge_decision 12 false false </dev/null
2026-08-03 16:27:43 +00:00
# The deliverable is the PR that merged last, not the one numbered highest
# (#242). The first row is crew#176's measured shape — #184 merged
# 19:05:16Z, #182 merged 19:05:18Z — so it fails against the old sort -n.
MERGED_REF_PR_RECORDS = $'176\t184\t2026-07-30T19:05:16Z\n176\t182\t2026-07-30T19:05:18Z'
check "the later merge wins over the higher PR number" 0 "182" \
post_merge_pr_for_issue 176
MERGED_REF_PR_RECORDS = $'12\t100\t2026-07-30T10:00:00Z\n12\t101\t2026-07-30T11:00:00Z'
check "number order agreeing with merge order still answers the later merge" 0 "101" \
post_merge_pr_for_issue 12
MERGED_REF_PR_RECORDS = $'176\t184\t2026-07-30T19:05:16Z\n321\t326\t2026-08-03T14:44:46Z\n176\t182\t2026-07-30T19:05:18Z\n321\t322\t2026-08-03T16:00:00Z'
check "interleaved issues each resolve to their own last merge" 0 "182" \
post_merge_pr_for_issue 176
check "...and a neighbouring issue's later merge never leaks in" 0 "322" \
post_merge_pr_for_issue 321
# Two PRs can share a mergedAt second, so the tie-break is specified rather
# than left to whichever record the sweep happened to emit first.
MERGED_REF_PR_RECORDS = $'55\t70\t2026-07-30T19:05:16Z\n55\t71\t2026-07-30T19:05:16Z'
check "an identical mergedAt breaks to the highest PR number" 0 "71" \
post_merge_pr_for_issue 55
MERGED_REF_PR_RECORDS = $'55\t71\t2026-07-30T19:05:16Z\n55\t70\t2026-07-30T19:05:16Z'
check "...and swapping the two input lines gives the same answer" 0 "71" \
post_merge_pr_for_issue 55
MERGED_REF_PR_RECORDS = $'176\t184\t2026-07-30T19:05:16Z'
check "an issue with no merged Refs PR still answers empty" 0 "" \
test -z " $( post_merge_pr_for_issue 999) "
check "...so its post-merge decision is KEEP" 0 "KEEP" \
post_merge_decision " $( post_merge_pr_for_issue 999) " false false \
<<< "- [ ] verify after merge"
MERGED_REF_PR_RECORDS = ""
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
# merged_at must stay a field on the PR list already fetched: the record shape
# gets richer, the request count does not (#242). The gathers are two REST
# list reads since #188 — Forgejo serves no GraphQL — so the pin counts those.
# shellcheck disable=SC2016 # the pin is the literal source text, not an expansion
check "the sweep still issues exactly two PR-list gathers" 0 "2" \
grep -c 'forge_api --paginate "repos/$REPO/pulls?state=' \
2026-08-03 16:27:43 +00:00
" $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh "
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
check "...with merged_at selected on the PR list it already fetched" 0 "" \
grep -qF 'select(.merged_at != null)' \
" $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh "
# Comment lines are excluded on purpose: this file's own header explains at
# length what GraphQL used to do here, and a pin that counted prose would go
# red on its own documentation (@kimi-reviewer-andresmgsl, #198).
# shellcheck disable=SC2016 # positional parameters belong to bash -c
check "...and no GraphQL survives in executable code on this surface" 0 "0" \
bash -c 'grep -n graphql "$1" | grep -vcE "^[0-9]+: *#" || true' \
_ " $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh "
2026-08-03 16:29:21 +00:00
2026-07-23 13:17:39 +00:00
check "one closed offsite PR nudges" 0 "NUDGE" offsite_resolved_decision <<< "CLOSED"
check "two closed offsite PRs nudge" 0 "NUDGE" offsite_resolved_decision <<< $'CLOSED\nCLOSED'
check "one open offsite PR keeps quiet" 0 "QUIET" offsite_resolved_decision <<< $'CLOSED\nOPEN'
check "no visible offsite PR keeps quiet" 0 "QUIET" offsite_resolved_decision </dev/null
check "an unreadable offsite PR keeps quiet" 0 "QUIET" offsite_resolved_decision <<< $'CLOSED\nUNKNOWN'
timeline = ' [
{ "event" :"cross-referenced" ,"source" :{ "issue" :{ "number" :112,"repository" :{ "full_name" :"heavy-duty/rig" } ,"pull_request" :{ "url" :"x" } } } } ,
{ "event" :"cross-referenced" ,"source" :{ "issue" :{ "number" :7,"repository" :{ "full_name" :"heavy-duty/rig" } } } } ,
{ "event" :"mentioned" ,"source" :{ "issue" :{ "number" :9,"repository" :{ "full_name" :"heavy-duty/box" } ,"pull_request" :{ "url" :"x" } } } } ,
{ "event" :"assigned" }
] '
check "offsite timeline extracts only cross-referenced PRs" 0 "heavy-duty/rig#112" \
offsite_cross_referenced_prs <<< " $timeline "
check "empty offsite timeline extracts nothing" 0 "" offsite_cross_referenced_prs <<< "[]"
2026-07-22 19:21:01 +00:00
# Invariant 3: blocked declarations parse and release only when all close.
2026-07-23 00:42:24 +00:00
refs = " $( blocked_references <<< $'Context #99. Blocked by #12 (first), #7 (second). Blocks #44.' ) "
2026-07-22 19:21:01 +00:00
check "blocked declaration extracts only declared refs" 0 $'7\n12' printf '%s\n' " $refs "
2026-07-23 10:49:36 +00:00
body = $'Blocked by #12 (first),\n#7 (soft-wrapped second). Blocks #44.'
check "soft-wrapped blocker declaration retains continuation refs" 0 "" test \
" $( blocked_references <<< " $body " ) " = $'7\n12'
body = $'Blocked by #12 (known)\nFollow-up context mentions #7 without a sentence boundary'
check "unterminated blocker prose errs toward retaining dependencies" 0 "" test \
" $( blocked_references <<< " $body " ) " = $'7\n12'
2026-07-23 00:42:24 +00:00
body = "Part of #1. Blocked by #11 (needs a ceremony tag to pin), #12 (must be executed from the guide), #19 (the conversion vendors the doctrine). Blocks #14, #15 (they inherit the pilot's lessons)."
check "real issue 13 inline blockers parse" 0 "" test \
" $( blocked_references <<< " $body " ) " = $'11\n12\n19'
body = "Part of #1. Blocked by #13 (inherits the pilot's lessons). Can run in parallel with #15."
check "real issue 14 inline blocker parses" 0 "" test \
" $( blocked_references <<< " $body " ) " = "13"
body = "Part of #1. Blocked by #13 (pilot lessons). Can run in parallel with #14."
check "real issue 15 inline blocker parses" 0 "" test \
" $( blocked_references <<< " $body " ) " = "13"
body = "Part of #1. Blocked by #11, #12 (needs a released ceremony + the bootstrap guide); benefits from #13's lessons but does not need #14/#15."
check "real issue 16 inline blockers parse" 0 "" test \
" $( blocked_references <<< " $body " ) " = $'11\n12'
2026-07-23 11:38:14 +00:00
check "qualified short repository reference drops" 0 "" test \
-z " $( blocked_references <<< "Blocked by rig#112." ) "
check "qualified owner/repository reference drops" 0 "" test \
-z " $( blocked_references <<< "Blocked by heavy-duty/box#9." ) "
check "parenthesized local reference survives" 0 "13" blocked_references <<< "Blocked by (#13)."
check "slash-adjacent local references survive" 0 $'14\n15' \
blocked_references <<< "Blocked by #14/#15."
check "comma-adjacent local references survive" 0 $'11\n12' \
blocked_references <<< "Blocked by #11, #12."
2026-07-25 13:58:39 +00:00
# A repeated declaration contributes every sentence, not just the first —
# rig#154's body promoted on `#152` alone while #153 and #148 were open (#184).
body = "Part of #151. Blocked by #152. Blocked by #153. Blocked by #148 — the registry PR merges under landed governance. Blocks #155."
check "repeated blocker sentences all contribute" 0 "" test \
" $( blocked_references <<< " $body " ) " = $'148\n152\n153'
body = $'Note: restored because it is blocked by an operator act. See below.\nBlocked by #152, #153, #148.'
check "earlier blocked-by prose does not hijack the declaration" 0 "" test \
" $( blocked_references <<< " $body " ) " = $'148\n152\n153'
body = $'This was blocked by #9 before the split.\nBlocked by #12.'
check "prose refs are retained beside the declaration, never substituted" 0 "" test \
" $( blocked_references <<< " $body " ) " = $'9\n12'
repeated_refs = " $( blocked_references <<< "Blocked by #152. Blocked by #153." ) "
check "repeated declaration with one open blocker keeps issue blocked" 0 "KEEP" \
blocked_decision " $repeated_refs " $'CLOSED\nOPEN'
check "cross-repo ref in a later clause still flags" 0 "FLAG_CROSS_REPO" \
blocked_decision "12" "CLOSED" \
" $( blocked_cross_references <<< "Blocked by #12. Blocked by rig#7." ) "
2026-07-22 19:21:01 +00:00
check "open blocker keeps issue blocked" 0 "KEEP" blocked_decision " $refs " $'CLOSED\nOPEN'
check "all closed blockers release issue" 0 "READY" blocked_decision " $refs " $'CLOSED\nCLOSED'
check "missing blocked declaration is flagged" 0 "FLAG_UNPARSEABLE" blocked_decision "" ""
check "unreadable blocker is flagged" 0 "FLAG_UNPARSEABLE" blocked_decision "12" "UNKNOWN"
2026-07-23 11:38:14 +00:00
check "cross-repo-only blocker is flagged distinctly" 0 "FLAG_CROSS_REPO" \
blocked_decision "" "" "rig#112"
check "cross-repo blocker prevents false promotion when locals close" 0 "FLAG_CROSS_REPO" \
blocked_decision "9" "CLOSED" "rig#9"
2026-08-03 19:28:31 +00:00
# The parse echo (#252): the machine states what it read, so a
# readable-but-wrong declaration is visible in one sweep instead of five days.
check "the rendered set names the locals in parse order" 0 "{#7, #12}" \
blocked_parse_set " $( printf '7\n12\n' ) " ""
check "a single blocker still renders as a set" 0 "{#12}" blocked_parse_set "12" ""
check "an empty parse renders as the empty set" 0 "{}" blocked_parse_set "" ""
check "cross-repo references are echoed beside the locals" 0 "{#12, rig#9}" \
blocked_parse_set "12" "rig#9"
check "a cross-repo-only parse is echoed too" 0 "{heavy-duty/box#9}" \
blocked_parse_set "" "heavy-duty/box#9"
# crew#308: a *negated* marker phrase unions as the thing it denies, and the
# silent result was a set nobody saw until a human ran the parser. Echoed, the
# union is visible in the thread that contains the declaration.
echo_308 = " $( blocked_parse_set \
" $( blocked_references <<< 'Blocked by #162, #265. It is no longer blocked by #221.' ) " \
" $( blocked_cross_references <<< 'Blocked by #162, #265. It is no longer blocked by #221.' ) " ) "
check "the #308 shape echoes the negation-unioned blocker verbatim" 0 "" test \
" $echo_308 " = "{#162, #221, #265}"
# The marker is scoped to the SET's value — the whole idempotency contract.
# Mutation proof, both directions: same set must reuse its marker (or a
# 15-minute cron repeats itself forever), different set must not (or a
# misparse is echoed under a marker the thread already carries, and stays
# invisible — exactly the failure this change exists to close).
check "an unchanged set reuses its marker" 0 "" test \
" $( blocked_parse_marker '{#7, #12}' ) " = " $( blocked_parse_marker '{#7, #12}' ) "
check "a changed set takes a different marker" 1 "" test \
" $( blocked_parse_marker '{#7, #12}' ) " = " $( blocked_parse_marker '{#7, #12, #19}' ) "
2026-08-03 20:05:04 +00:00
check "the empty set has a marker of its own" 0 "blockers-parsed-none-44136fa355b3" \
2026-08-03 19:28:31 +00:00
blocked_parse_marker "{}"
check "the marker survives a cross-repo reference's punctuation" 0 \
2026-08-03 20:05:04 +00:00
"blockers-parsed-12-heavy-duty-box-9-c8e36fe3b793" \
2026-08-03 19:28:31 +00:00
blocked_parse_marker "{#12, heavy-duty/box#9}"
2026-08-03 20:05:04 +00:00
# The readable half of the marker is many-to-one and must not be the identity.
2026-08-03 20:39:14 +00:00
# Every pair below is two reference tokens `issue_references` classifies, that
2026-08-03 20:05:04 +00:00
# slug identically; a slug-keyed marker made the second one find the first
# one's marker and post nothing — silence in the one case the echo exists to
# speak about. Distinguishing `/` alone would close the first pair and leave
# the rest, so the digest of the whole rendered set is what decides.
2026-08-03 20:39:14 +00:00
#
# Classifies, not parses: three of the four are reachable as a declared set —
# `{acme.widgets#9}` is not, because the clause parser stops at the `.` and
# `blocked_reference_records` never hands that token through, though
# `issue_references` does answer CROSS for it. It stays in the family because
# the marker's contract is over the tokens the classifier admits, not over the
# subset today's clause parser happens to reach; the class proof does not rest
# on it either way, since `{acme-widgets#9}` vs `{acme_widgets#9}` is reachable
# on both sides and reds the cheap fix on its own.
2026-08-03 20:05:04 +00:00
check "the slug alone cannot separate a qualified ref from a hyphenated one" 0 "" \
test " $( printf '%s' '{acme/widgets#9}' | tr -c '[:alnum:]' '-' | sed 's/--*/-/g; s/^-//; s/-$//' ) " \
= " $( printf '%s' '{acme-widgets#9}' | tr -c '[:alnum:]' '-' | sed 's/--*/-/g; s/^-//; s/-$//' ) "
2026-08-03 20:07:23 +00:00
# Pairwise, and deliberately so. Anchoring every pair on the `/` spelling
# would pass under a fix that only taught the slug about `/` — and that fix
# leaves `{acme-widgets#9}`, `{acme_widgets#9}` and `{acme.widgets#9}` sharing
# one marker. The contract is that no two distinct parses collide, so the test
# is every pair, not every pair through one representative.
marker_family = (
'{acme/widgets#9}' '{acme-widgets#9}' '{acme_widgets#9}' '{acme.widgets#9}'
)
for left in " ${ marker_family [@] } " ; do
for right in " ${ marker_family [@] } " ; do
[ " $left " != " $right " ] || continue
check " ...but the marker does: $left vs $right " 1 "" test \
" $( blocked_parse_marker " $left " ) " = " $( blocked_parse_marker " $right " ) "
done
2026-08-03 20:05:04 +00:00
done
check "the qualifier's punctuation reaches the marker's identity" 1 "" test \
" $( blocked_parse_marker '{#12, heavy-duty/box#9}' ) " \
= " $( blocked_parse_marker '{#12, heavy-duty-box#9}' ) "
2026-07-23 11:40:55 +00:00
# shellcheck disable=SC2016 # expansions belong to the generated fake gh
printf '%s\n' \
'#!/usr/bin/env bash' \
'if [ "$1" = api ]; then [ ! -f "$GH_COMMENTS" ] || cat "$GH_COMMENTS"; exit; fi' \
'if [ "$1 $2" = "issue comment" ]; then' \
' while [ "$#" -gt 0 ]; do' \
' if [ "$1" = --body ]; then shift; printf "%s\n" "$1" >>"$GH_COMMENTS"; exit; fi' \
' shift' \
' done' \
'fi' >" $TMP /gh "
chmod +x " $TMP /gh "
: >" $TMP /comments "
# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process
check "cross-repo warning is idempotent across two sweeps" 0 "" \
env PATH = " $TMP : $PATH " GH_COMMENTS = " $TMP /comments " bash -c \
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
' source " $1 " ; forge_select github; REPO = heavy-duty/ceremony
2026-07-23 11:40:55 +00:00
ensure_comment 99 blocked-cross-repo "cross-repo warning"
ensure_comment 99 blocked-cross-repo "cross-repo warning"
test " $( grep -cF "<!-- issueflow:blocked-cross-repo -->" " $GH_COMMENTS " ) " -eq 1' \
_ " $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh "
2026-07-22 19:21:01 +00:00
# Invariant 4: only configured triage actors mint directly into the queue.
check "triage-authored ready issue is accepted" 0 "KEEP" author_decision true <<< "ready"
check "outside author receives needs-triage" 0 "ADD_NEEDS_TRIAGE" author_decision false <<< "ready"
check "outside author already marked needs-triage is stable" 0 "KEEP" author_decision false <<< "needs-triage"
2026-07-22 19:41:26 +00:00
check "later sweep accepts a normalized outside-authored issue" 0 "KEEP" queue_decision <<< "ready"
2026-07-22 19:21:01 +00:00
# Invariant 5: completed epics get one nudge; incomplete/unparseable do not.
2026-07-23 00:42:24 +00:00
epic_refs = " $( epic_references <<< $'## Definition of done\n- [ ] outside #8\n\n## Task list\n- [ ] #3 first\n- [x] #2 done\nplain #9' ) "
2026-07-22 19:21:01 +00:00
check "epic parser reads task-list refs only" 0 $'2\n3' printf '%s\n' " $epic_refs "
2026-07-23 00:42:24 +00:00
body = $'## Task list\n- [x] #2 done\n- [x] #3 done\n\n## Definition of done\n- [ ] open issue #99 must not suppress the nudge'
check "epic parser stops before later checkbox sections" 0 "" test \
" $( epic_references <<< " $body " ) " = $'2\n3'
2026-07-23 11:40:55 +00:00
# shellcheck disable=SC2016 # backticks and ${{ }}-shaped prose are fixture literals
body = $'## Task list\n\n- [x] #2 Scaffold: layout, test harness, shellcheck + actionlint CI\n- [x] #3 `lib/version.sh` — one version abstraction, two backends\n- [x] #4 `lib/changelog.sh` — the one canonical section extractor\n- [x] #5 `actions/changelog-armed` — the version-keyed arming guard\n- [x] #6 `actions/changelog-monotonic` — shipped release headings are append-only\n- [x] #7 `actions/drill-recorded` — a release carries its evidence\n- [x] #8 `lib/decide.sh` — the merge door' \' 's decision, pure\n- [x] #9 The reusable release workflow — both doors, one implementation\n- [x] #10 Labels machinery: reusable workflow + core/scope split\n- [x] #11 Dogfood: ceremony releases itself (0.1.0) — **shipped: tag `0.1.0`, release, `drills/0.1.0.md`; main re-armed at `0.1.1-dev`**\n- [x] #12 `docs/CONSUMERS.md` + README doctrine\n- [ ] #13 Convert rig (pilot) — **PR [rig#112](https://github.com/heavy-duty/rig/pull/112) is approved by the whole panel on head `3c72c1b` and sits at `state:needs-human` since 2026-07-23 10:47Z; the merge is the human' \' 's. #14/#15 unblock when it lands**\n- [ ] #14 Convert box\n- [ ] #15 Convert cast (artifact hook debut)\n- [ ] #16 Adopt in incubator (greenfield consumer)\n\nAdjacent, same repo, separable from the release chain: the **agent team flow** (discussion → triage → issue → build → review → human merge) landed as doctrine in PR #17 (CONTRIBUTING.md, LABELS.md, TRIAGE.md, BUILDER.md, REVIEWER.md); #10' \' 's bootstrap carries its labels, #12' \' 's guide carries its adoption checklist. Consumption is split by what has a runtime: **machinery by reference** (GitHub materializes pinned workflows/actions at run time), **doctrine as a machine-verified mirror** (`.ceremony/` in each consumer, byte-identical to the pin, CI-guarded — agents read rules from the checkout, never cross-repo):\n\n- [x] #18 Issue-flow reconciliation — the work-queue sweep — **shipped 2026-07-23** in #32 (`66f1c08`)\n- [ ] #61 issueflow-reconcile — cross-repo references must not be read as local issue numbers (found in triage hygiene against the live corpus after #18 shipped; it is why this epic cannot currently be nudged complete)\n- [x] #19 actions/docs-sync — the vendored-doctrine mirror + guard\n- [x] #24 Entry templates — the pipeline' \' 's doors made mechanical (from discussion #23)\n- [ ] #50 `needs-ruling` — the pending-human-decision flag (its own epic; from discussion #30)\n- [ ] #56 Fleet scope and cross-repo discovery — the two guards, the runner hole, the roster question (its own epic; from discussion #55, filed at @danmt' \' 's request). Children: #57 (BUILDER/REVIEWER/FLEET discovery guards), #58 (`actions/runner-isolated`). Added to this list by triage 2026-07-23 — it is agent-team-flow work like #50, so a scan of this epic must see it.'
check "real epic 1 task list drops rig PR and retains local references" 0 \
$'2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n18\n19\n23\n24\n30\n32\n50\n55\n56\n57\n58\n61' \
2026-07-23 11:38:14 +00:00
epic_references <<< " $body "
2026-07-22 19:21:01 +00:00
check "completed epic is nudged" 0 "NUDGE" epic_decision " $epic_refs " $'CLOSED\nCLOSED'
check "open epic child suppresses nudge" 0 "KEEP" epic_decision " $epic_refs " $'CLOSED\nOPEN'
check "epic without parseable children is stable" 0 "KEEP" epic_decision "" ""
2026-07-23 11:57:20 +00:00
# Invariant 1 keeps ignoring the ruling flag (#50 D8): it composes with the
# queue labels and is not one of them.
check "claimed plus a pending ruling is a healthy issue" 0 "KEEP" \
queue_decision <<< $'claimed\nneeds-ruling'
check "a ruling flag alone is still invariant 1's violation" 0 "ADD_NEEDS_TRIAGE" \
queue_decision <<< $'needs-ruling'
2026-07-23 12:48:04 +00:00
check "claimed plus offsite is a healthy issue" 0 "KEEP" \
queue_decision <<< $'claimed\noffsite'
check "offsite alone still needs triage" 0 "ADD_NEEDS_TRIAGE" \
queue_decision <<< "offsite"
2026-07-23 17:28:02 +00:00
check "claimed plus attention is a healthy issue" 0 "KEEP" \
queue_decision <<< $'claimed\nattention'
2026-07-23 11:57:20 +00:00
# ---------------------------------------------------------------------------
# The ruling pass on the issue surface (#52), against a recording stub: the
# reclaim clock stops under a pending ruling, an applied stale heals off,
# label churn is not activity, the nudge resets on its own comment, and no
# edit anywhere names the flag (#50 D9). The stub serves fixture JSON per
# endpoint with the caller's --jq applied by real jq, appends posted comments
# back into the fixture (a second sweep sees the first one's writes), and
# records every label edit.
# ---------------------------------------------------------------------------
INOW = 2000000000
iso_at( ) { date -u -d " @ $1 " +%Y-%m-%dT%H:%M:%SZ; }
fix(issueflow): a failed read never reaches a decision function
`gh api` prints a 5xx response body to stdout AND exits non-zero, and
GitHub's 5xx body is a JSON object. Inside the per-issue subshell that
payload passed `has("pull_request") | not`, emptied `.labels[]`, and
`queue_decision` — correct on the input it was handed — wrote
`needs-triage` onto a healthy epic. The run then logged `reconciled.`
and exited 0 (crew#329, #247).
errexit could not have caught it: a command whose status is tested by
`||` runs with errexit suppressed, and the suppression extends through
the whole subshell body, so the `|| log` handler is what disables the
errexit that would have aborted at the failed read. Removing the handler
revives errexit and loses #91's resilience, and an inline `set -e` does
not re-arm it. Explicit per-read checks are the mechanism.
Every read inside that subshell is now checked — the issue read on its
status AND on its payload shape (an HTTP 200 whose body is `null` exits
0 and empties the label set just the same), both reads in
`last_issue_activity`, and the comments read in
`issue_comment_has_marker`. On failure the issue is left exactly as it
is, the reason rides its own `#$n:` line, and the subshell exits with a
distinguished status the sweep counts, so a deliberate skip is not
reported as a crash and a genuine crash is still named byte-identically.
`read_failure_reason` moves to lib/read.sh beside a new `guarded_read`,
sourced by both reconcilers: labels-reconcile's copy was the only one,
and the issue surface needs the identical rule.
Refs #247
2026-08-03 18:01:58 +00:00
# gh's own rendering of a 5xx whose body carries a `message` key — the line
# crew#329's job log carried, verbatim (#247), and the payload beside it.
GH_STUB_STDERR = "gh: We couldn't respond to your request in time. (HTTP 504)"
GH_STUB_ERROR_BODY = '{"message":"We could not respond to your request in time.","documentation_url":"https://docs.github.com/rest"}'
2026-08-03 18:06:13 +00:00
export GH_STUB_STDERR # the PATH-stubbed gh of the executable runs reads it too
fix(issueflow): a failed read never reaches a decision function
`gh api` prints a 5xx response body to stdout AND exits non-zero, and
GitHub's 5xx body is a JSON object. Inside the per-issue subshell that
payload passed `has("pull_request") | not`, emptied `.labels[]`, and
`queue_decision` — correct on the input it was handed — wrote
`needs-triage` onto a healthy epic. The run then logged `reconciled.`
and exited 0 (crew#329, #247).
errexit could not have caught it: a command whose status is tested by
`||` runs with errexit suppressed, and the suppression extends through
the whole subshell body, so the `|| log` handler is what disables the
errexit that would have aborted at the failed read. Removing the handler
revives errexit and loses #91's resilience, and an inline `set -e` does
not re-arm it. Explicit per-read checks are the mechanism.
Every read inside that subshell is now checked — the issue read on its
status AND on its payload shape (an HTTP 200 whose body is `null` exits
0 and empties the label set just the same), both reads in
`last_issue_activity`, and the comments read in
`issue_comment_has_marker`. On failure the issue is left exactly as it
is, the reason rides its own `#$n:` line, and the subshell exits with a
distinguished status the sweep counts, so a deliberate skip is not
reported as a crash and a genuine crash is still named byte-identically.
`read_failure_reason` moves to lib/read.sh beside a new `guarded_read`,
sourced by both reconcilers: labels-reconcile's copy was the only one,
and the issue surface needs the identical rule.
Refs #247
2026-08-03 18:01:58 +00:00
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
2026-07-23 11:57:20 +00:00
issue_stub_gh( ) {
if [ " $1 " = api ] ; then
shift
local jqexpr = "" endpoint = "" file
while [ $# -gt 0 ] ; do
case " $1 " in
--jq) jqexpr = " $2 " ; shift ; ;
-*) ; ;
*) [ -n " $endpoint " ] || endpoint = " $1 " ; ;
esac
shift
done
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
endpoint = " $( forge_stub_path " $endpoint " ) "
2026-07-23 11:57:20 +00:00
file = " $TMP / $( printf '%s' " $endpoint " | tr '/' '_' ) .json "
2026-07-23 13:19:11 +00:00
printf '%s\n' " $endpoint " >>" $TMP /api-calls "
fix(issueflow): a failed read never reaches a decision function
`gh api` prints a 5xx response body to stdout AND exits non-zero, and
GitHub's 5xx body is a JSON object. Inside the per-issue subshell that
payload passed `has("pull_request") | not`, emptied `.labels[]`, and
`queue_decision` — correct on the input it was handed — wrote
`needs-triage` onto a healthy epic. The run then logged `reconciled.`
and exited 0 (crew#329, #247).
errexit could not have caught it: a command whose status is tested by
`||` runs with errexit suppressed, and the suppression extends through
the whole subshell body, so the `|| log` handler is what disables the
errexit that would have aborted at the failed read. Removing the handler
revives errexit and loses #91's resilience, and an inline `set -e` does
not re-arm it. Explicit per-read checks are the mechanism.
Every read inside that subshell is now checked — the issue read on its
status AND on its payload shape (an HTTP 200 whose body is `null` exits
0 and empties the label set just the same), both reads in
`last_issue_activity`, and the comments read in
`issue_comment_has_marker`. On failure the issue is left exactly as it
is, the reason rides its own `#$n:` line, and the subshell exits with a
distinguished status the sweep counts, so a deliberate skip is not
reported as a crash and a genuine crash is still named byte-identically.
`read_failure_reason` moves to lib/read.sh beside a new `guarded_read`,
sourced by both reconcilers: labels-reconcile's copy was the only one,
and the issue surface needs the identical rule.
Refs #247
2026-08-03 18:01:58 +00:00
# A `.http-error` sentinel is the real 5xx (#247): `gh api` prints the
# response body — GitHub's JSON error object — to STDOUT, says why on
# stderr, and exits non-zero. The `.error` sentinel models a failure with
# no payload, which is the *safe* path (an empty label set is empty either
# way), and is why this class was never caught. Both now speak on stderr,
# because the real gh always does and the reason line renders it.
if [ -f " $file .http-error " ] ; then
2026-08-03 18:08:19 +00:00
# A --jq call gets the filter applied to the error body, as gh does.
# That is what "yields no timestamps" looks like — the shape that let
# last_issue_activity fall back to created_at and reclaim a live claim.
if [ -n " $jqexpr " ] ; then
jq -r " $jqexpr " " $file .http-error " 2>/dev/null || true
else
cat " $file .http-error "
fi
fix(issueflow): a failed read never reaches a decision function
`gh api` prints a 5xx response body to stdout AND exits non-zero, and
GitHub's 5xx body is a JSON object. Inside the per-issue subshell that
payload passed `has("pull_request") | not`, emptied `.labels[]`, and
`queue_decision` — correct on the input it was handed — wrote
`needs-triage` onto a healthy epic. The run then logged `reconciled.`
and exited 0 (crew#329, #247).
errexit could not have caught it: a command whose status is tested by
`||` runs with errexit suppressed, and the suppression extends through
the whole subshell body, so the `|| log` handler is what disables the
errexit that would have aborted at the failed read. Removing the handler
revives errexit and loses #91's resilience, and an inline `set -e` does
not re-arm it. Explicit per-read checks are the mechanism.
Every read inside that subshell is now checked — the issue read on its
status AND on its payload shape (an HTTP 200 whose body is `null` exits
0 and empties the label set just the same), both reads in
`last_issue_activity`, and the comments read in
`issue_comment_has_marker`. On failure the issue is left exactly as it
is, the reason rides its own `#$n:` line, and the subshell exits with a
distinguished status the sweep counts, so a deliberate skip is not
reported as a crash and a genuine crash is still named byte-identically.
`read_failure_reason` moves to lib/read.sh beside a new `guarded_read`,
sourced by both reconcilers: labels-reconcile's copy was the only one,
and the issue surface needs the identical rule.
Refs #247
2026-08-03 18:01:58 +00:00
printf '%s\n' " $GH_STUB_STDERR " >& 2
return 1
fi
[ ! -f " $file .error " ] || { printf '%s\n' " $GH_STUB_STDERR " >& 2; return 1; }
2026-08-03 18:14:19 +00:00
# An absent fixture answers an empty list, and a --jq call gets the filter
# applied to it — the arrival stub's shape, and gh's. Returning the raw
# `[]` to a --jq caller made every missing fixture answer a literal `[]`
# where the real API answers nothing, and `[]` outsorts an ISO-8601
# timestamp in the C locale but not in a UTF-8 one, so last_issue_activity
# dated an issue by a stub artifact on the runner and by its created_at
# here. The old code swallowed the resulting date failure; #247's guards
# turn it into a skip, which is what made the lie visible.
local payload = '[]'
[ ! -f " $file " ] || payload = " $( cat " $file " ) "
if [ -n " $jqexpr " ] ; then jq -r " $jqexpr " <<< " $payload " ; else printf '%s\n' " $payload " ; fi
2026-07-23 11:57:20 +00:00
elif [ " $1 " = issue ] && [ " $2 " = comment ] ; then
local n = " $3 " body = "" file
shift 3
while [ $# -gt 0 ] ; do
case " $1 " in --body) body = " $2 " ; shift ; ; esac
shift
done
printf '%s\n----\n' " $body " >>" $TMP /posted- $n "
2026-08-04 11:43:44 +00:00
file = " $TMP / $( printf 'repos/%s/issues/%s/comments' " $REPO " " $n " | tr / _) .json "
2026-07-23 11:57:20 +00:00
[ -f " $file " ] || printf '[]\n' >" $file "
jq --arg b " $body " --arg at " $( iso_at " $INOW " ) " \
'. + [{"user":{"login":"sweep-bot"},"created_at":$at,"html_url":"https://x/posted","body":$b}]' \
" $file " >" $file .tmp " && mv " $file .tmp " " $file "
elif [ " $1 " = issue ] && [ " $2 " = edit ] ; then
printf '%s\n' " $* " >>" $TMP /issue-edits "
fi
}
2026-08-03 15:51:35 +00:00
issue_probe( ) { # $1 issue, $2 labels, $3 assignees, $4 false|closing|refs, $5 merged PR specs, $6 body
2026-07-23 11:57:20 +00:00
(
2026-08-03 16:27:43 +00:00
local assignees = " ${ 3 :- 1 } " open_pr = " ${ 4 :- false } " merged_ref_prs = " ${ 5 :- } "
2026-08-03 15:51:35 +00:00
local body = " ${ 6 :- } " assignee_json = '[]' open_pr_records = "" spec pr merged_at
2026-07-23 12:48:04 +00:00
[ " $assignees " -eq 0 ] || assignee_json = '[{"login":"owner-bot"}]'
2026-08-03 23:06:47 +00:00
# `PROBE_NOW` moves the sweep's clock without moving the fixtures — the
# only way to prove a rule that self-rate-limits on its own comment's
# timestamp (#254): sweep, then sweep again a day later and watch the
# nudge stay silent because the comment it posted is now the activity.
2026-08-04 11:43:44 +00:00
REPO = " ${ PROBE_REPO :- owner /repo } " NOW = " ${ PROBE_NOW :- $INOW } "
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
# The nudge links the issue on the forge the job is running on, so the
# probe stands on one. PROBE_SERVER_URL lets a case assert the link moves
# with the forge instead of being written into the source (#198).
GITHUB_SERVER_URL = " ${ PROBE_SERVER_URL :- https : //github.com } "
2026-07-23 11:57:20 +00:00
ISSUE_LABELS = " $2 "
ISSUE_JSON = " $( jq -n --arg at " $( iso_at $(( INOW - 10 * 86400 )) ) " \
2026-07-25 00:11:42 +00:00
--argjson assignees " $assignee_json " --arg body " $body " \
'{created_at: $at, assignees: $assignees, body: $body}' ) "
2026-08-03 15:51:35 +00:00
case " $open_pr " in
true| closing) open_pr_records = " $( printf 'CLOSING\t%s\n' " $1 " ) " ; ;
refs| draft-refs) open_pr_records = " $( printf 'BODY\tRefs #%s\n' " $1 " ) " ; ;
esac
OPEN_PR_ISSUES = " $( open_pr_issues <<< " $open_pr_records " ) "
2026-08-03 16:27:43 +00:00
# Records are ISSUE<TAB>PR<TAB>MERGED_AT (#242). A spec is `PR` or
# `PR@<iso>`; the bare form takes a fixed hour-old merge, which is every
# probe that does not care about merge order. An empty list is no record
# at all, so the no-merged-PR probes read exactly as they did.
MERGED_REF_PR_RECORDS = " $(
# shellcheck disable=SC2086 # the spec list is deliberately word-split
for spec in $merged_ref_prs ; do
pr = " ${ spec %%@* } "
merged_at = " ${ spec #*@ } "
[ " $merged_at " != " $spec " ] || merged_at = " $( iso_at $(( INOW - 3600 )) ) "
printf '%s\t%s\t%s\n' " $1 " " $pr " " $merged_at "
done ) "
2026-07-23 11:57:20 +00:00
run( ) { " $@ " ; }
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
2026-07-23 11:57:20 +00:00
gh( ) { issue_stub_gh " $@ " ; }
reconcile_issue " $1 " 2>& 1
)
}
tfix( ) { printf '%s/repos_owner_repo_issues_%s_timeline.json' " $TMP " " $1 " ; }
cfix( ) { printf '%s/repos_owner_repo_issues_%s_comments.json' " $TMP " " $1 " ; }
2026-08-04 10:48:55 +00:00
# -- release epics announce an opened declared gate, comment-only (#253) -----
printf '{"state":"closed"}\n' >" $TMP /repos_owner_repo_issues_201.json "
printf '{"state":"closed"}\n' >" $TMP /repos_owner_repo_issues_202.json "
printf '{"state":"open"}\n' >" $TMP /repos_owner_repo_issues_203.json "
printf '[]\n' >" $( cfix 53) "
: >" $TMP /issue-edits "
release_init_edits_before = " $( wc -l <" $TMP /issue-edits " ) "
release_init_body = $'Blocked by #201, #202.\n\n## Task list\n- [ ] #203 later work'
release_init = " $( issue_probe 53 $'epic\nrelease' 0 false "" " $release_init_body " ) "
check "a release epic with every declared blocker closed announces init" 0 "" \
grep -qF '<!-- issueflow:release-init-due -->' " $TMP /posted-53 "
check "the init announce names all five steps" 0 "5" \
grep -cE '^[1-5]\. ' " $TMP /posted-53 "
2026-08-23 01:06:54 +00:00
# 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 "
2026-08-04 11:19:59 +00:00
# shellcheck disable=SC2016 # backticks are the literal portable doctrine citation
2026-08-04 11:17:51 +00:00
check "the init announce cites the portable vendored doctrine path" 0 "" \
grep -qF 'See `.ceremony/RELEASES.md`.' " $TMP /posted-53 "
2026-08-04 10:48:55 +00:00
check "the init announce names the never-automated operator blessing" 0 "" \
grep -qF 'operator blessing the order is the one step this chain never automates' \
" $TMP /posted-53 "
check "the opened release gate is logged" 0 "" \
grep -qF '#53: release-init due' <<< " $release_init "
2026-08-04 11:17:51 +00:00
release_init_gate_reads_before_repeat = " $(
grep -cE 'repos/owner/repo/issues/(201|202)$' " $TMP /api-calls "
) "
2026-08-04 10:48:55 +00:00
issue_probe 53 $'epic\nrelease' 0 false "" " $release_init_body " >/dev/null
check "an unchanged opened gate announces only once" 0 "1" \
grep -cF '<!-- issueflow:release-init-due -->' " $TMP /posted-53 "
2026-08-04 11:17:51 +00:00
check "an announced gate does not re-read its durable blockers" 0 \
" $release_init_gate_reads_before_repeat " \
grep -cE 'repos/owner/repo/issues/(201|202)$' " $TMP /api-calls "
2026-08-04 10:48:55 +00:00
2026-08-04 11:43:44 +00:00
printf '{"state":"closed"}\n' >" $TMP /repos_heavy-duty_ceremony_issues_201.json "
printf '{"state":"closed"}\n' >" $TMP /repos_heavy-duty_ceremony_issues_202.json "
printf '[]\n' >" $TMP /repos_heavy-duty_ceremony_issues_53_comments.json "
PROBE_REPO = heavy-duty/ceremony \
issue_probe 53 $'epic\nrelease' 0 false "" " $release_init_body " >/dev/null
# shellcheck disable=SC2016 # backticks are the literal dogfood doctrine citation
check "the ceremony dogfood announce cites its root doctrine path" 0 "" \
grep -qF 'See `RELEASES.md`.' " $TMP /posted-53 "
2026-08-04 10:48:55 +00:00
printf '[]\n' >" $( cfix 54) "
issue_probe 54 $'epic\nrelease' 0 false "" \
$'Blocked by #203.\n\n## Task list\n- [x] #201 complete' >/dev/null
check "an open declared blocker suppresses init despite a complete task list" 1 "" \
grep -qF '<!-- issueflow:release-init-due -->' " $TMP /posted-54 "
check "the independent epic-complete nudge still fires" 0 "" \
grep -qF '<!-- issueflow:epic-complete -->' " $TMP /posted-54 "
printf '[]\n' >" $( cfix 55) "
issue_probe 55 $'epic\nrelease' 0 false "" \
$'Blocked by #201.\n\n## Task list\n- [x] #202 complete' >/dev/null
check "release-init and epic-complete can coexist" 0 "2" \
grep -c -- '^----$' " $TMP /posted-55 "
2026-08-04 10:50:55 +00:00
# shellcheck disable=SC2016 # positional parameter belongs to the isolated shell
2026-08-04 10:48:55 +00:00
check "the coexisting comments keep distinct markers" 0 "" \
bash -c 'grep -qF "<!-- issueflow:release-init-due -->" "$1" && grep -qF "<!-- issueflow:epic-complete -->" "$1"' \
_ " $TMP /posted-55 "
for n in 56 57 58 59; do printf '[]\n' >" $( cfix " $n " ) " ; done
issue_probe 56 $'epic\nrelease' 0 false "" 'No dependency declaration.' >/dev/null
check "a release epic without a Blocked by declaration stays silent" 1 "" \
test -f " $TMP /posted-56 "
issue_probe 57 $'epic\nrelease' 0 false "" 'Blocked by heavy-duty/rig#9.' >/dev/null
check "a cross-repo release gate stays silent" 1 "" test -f " $TMP /posted-57 "
issue_probe 58 $'epic\nrelease' 0 false "" 'Blocked by #204.' >/dev/null
check "an unreadable release gate stays silent" 1 "" test -f " $TMP /posted-58 "
: >" $TMP /api-calls "
issue_probe 59 epic 0 false "" 'Blocked by #201.' >/dev/null
check "a plain epic does not parse or announce a release gate" 1 "" \
test -f " $TMP /posted-59 "
check "a plain epic pays no Blocked by reference read" 1 "" \
grep -qF 'repos/owner/repo/issues/201' " $TMP /api-calls "
printf '[]\n' >" $( cfix 60) "
issue_probe 60 $'ready\nrelease' 0 false "" 'Blocked by #201.' >/dev/null
check "a non-epic release issue does not announce init" 1 "" \
test -f " $TMP /posted-60 "
2026-08-04 10:50:55 +00:00
# shellcheck disable=SC2016 # positional parameter belongs to the isolated shell
2026-08-04 10:48:55 +00:00
check "every release-init probe is comment-only" 0 " $release_init_edits_before " \
bash -c 'wc -l <"$1"' _ " $TMP /issue-edits "
2026-08-03 19:53:25 +00:00
# -- malformed attention targets are diagnosed, never repaired (#232) -------
attention_episode( ) { # $1 issue, $2 labeled timestamp
jq -n --arg at " $2 " \
'[{"event":"labeled","label":{"name":"attention"},"actor":{"login":"setter"},"created_at":$at}]' \
>" $( tfix " $1 " ) "
printf '[]\n' >" $( cfix " $1 " ) "
}
: >" $TMP /issue-edits "
attention_edits_before = " $( wc -l <" $TMP /issue-edits " ) "
for n in 61 62 63; do
attention_episode " $n " " $( iso_at $(( INOW - 60 )) ) "
done
# The assignment event is the claim clock's own activity fact. The live issue
# has since lost its assignee, which is exactly the claimed-unassigned shape.
jq --arg at " $( iso_at $(( INOW - 60 )) ) " \
'. + [{"event":"assigned","created_at":$at}]' \
" $( tfix 63) " >" $( tfix 63) .tmp " && mv " $( tfix 63) .tmp " " $( tfix 63) "
printf '{"state":"open"}\n' >" $TMP /repos_owner_repo_issues_999.json "
issue_probe 61 $'ready\nattention' 0 >/dev/null
check "unassigned attention under ready is diagnosed once" 0 "1" \
grep -cF '<!-- ceremony:attention-malformed:' " $TMP /posted-61 "
issue_probe 62 $'blocked\nattention' 0 false "" 'Blocked by #999' >/dev/null
check "unassigned attention under blocked is diagnosed once" 0 "1" \
grep -cF '<!-- ceremony:attention-malformed:' " $TMP /posted-62 "
claimed_attention = " $( issue_probe 63 $'claimed\nattention' 0) "
check "claimed-unassigned owns the only board comment" 0 "1" \
grep -c -- '^----$' " $TMP /posted-63 "
check "the claimed-unassigned marker, not attention's, was posted" 0 "1" \
grep -cF '<!-- issueflow:claimed-unassigned -->' " $TMP /posted-63 "
check "the suppressed attention detection remains in the log" 0 "1" \
grep -cF 'comment suppressed by claimed-unassigned precedence' <<< " $claimed_attention "
attention_episode 64 " $( iso_at $(( INOW - 60 )) ) "
issue_probe 64 $'ready\nattention' 1 >/dev/null
check "assigned attention under ready is healthy" 1 "" test -f " $TMP /posted-64 "
attention_episode 65 " $( iso_at $(( INOW - 60 )) ) "
issue_probe 65 $'claimed\nattention' 1 true >/dev/null
check "assigned attention under claimed is healthy" 1 "" test -f " $TMP /posted-65 "
attention_episode 66 " $( iso_at $(( INOW - 60 )) ) "
issue_probe 66 $'blocked\nattention' 1 false "" 'Blocked by #999' >/dev/null
2026-08-03 20:44:09 +00:00
# A `blocked` issue now always carries one comment — the parse echo (#252) —
# so "healthy" can no longer be spelled "no comment file at all". It is spelled
# the way this section's other cases already spell it: no attention diagnostic.
# Both halves are pinned, so the case still fails if an attention comment
# appears beside the echo, or if a second comment of any kind does.
check "assigned attention under blocked is healthy" 1 "" \
grep -qF '<!-- ceremony:attention-malformed:' " $TMP /posted-66 "
check "...drawing the #252 parse echo and nothing else" 0 "1" \
grep -c -- '^----$' " $TMP /posted-66 "
2026-08-03 19:53:25 +00:00
attention_episode 67 " $( iso_at $(( INOW - 60 )) ) "
2026-08-03 23:06:47 +00:00
# Recent activity keeps the evidence nudge (#254) off this probe: it is a
# precedence case, and "exactly one comment" is the assertion doing the work.
# The nudge's own coexistence with the post-merge diagnostic is pinned in its
# section below, on a probe that is quiet on purpose.
jq -n --arg at " $( iso_at $(( INOW - 60 )) ) " \
'[{"user":{"login":"triage-one"},"created_at":$at,"html_url":"https://x/c67","body":"still waiting on the tag"}]' \
>" $( cfix 67) "
2026-08-03 19:53:25 +00:00
post_merge_attention = " $( issue_probe 67 $'post-merge\nattention' 0) "
check "post-merge precedence leaves exactly its existing comment" 0 "1" \
grep -c -- '^----$' " $TMP /posted-67 "
check "post-merge's existing diagnostic wins" 0 "1" \
grep -cF '<!-- issueflow:post-merge-assigned -->' " $TMP /posted-67 "
check "the post-merge suppression remains in the log" 0 "1" \
grep -cF 'comment suppressed by post-merge-assigned precedence' <<< " $post_merge_attention "
attention_episode 68 " $( iso_at $(( INOW - 120 )) ) "
issue_probe 68 $'ready\nattention' 0 >/dev/null
issue_probe 68 $'ready\nattention' 0 >/dev/null
check "two sweeps in one malformed episode post once" 0 "1" \
grep -cF '<!-- ceremony:attention-malformed:' " $TMP /posted-68 "
jq --arg at " $( iso_at $(( INOW - 30 )) ) " \
'. + [{"event":"labeled","label":{"name":"attention"},"actor":{"login":"setter"},"created_at":$at}]' \
" $( tfix 68) " >" $( tfix 68) .tmp " && mv " $( tfix 68) .tmp " " $( tfix 68) "
issue_probe 68 $'ready\nattention' 0 >/dev/null
check "a re-set flag creates a second episode comment" 0 "2" \
grep -cF '<!-- ceremony:attention-malformed:' " $TMP /posted-68 "
2026-08-03 19:55:32 +00:00
# shellcheck disable=SC2016 # positional parameter belongs to the isolated shell
2026-08-03 19:53:25 +00:00
check "the two comments carry distinct episode markers" 0 "2" \
bash -c 'grep -F "<!-- ceremony:attention-malformed:" "$1" | sort -u | wc -l' _ \
" $TMP /posted-68 "
: >" $( tfix 69) .error "
printf '[]\n' >" $( cfix 69) "
unreadable_attention = " $( issue_probe 69 $'ready\nattention' 0) "
check "an unreadable attention timeline posts nothing" 1 "" test -f " $TMP /posted-69 "
check "the unreadable fact is visible in the log" 0 "1" \
grep -cF 'attention timeline unreadable' <<< " $unreadable_attention "
: >" $TMP /api-calls "
printf '[]\n' >" $( tfix 70) "
printf '[]\n' >" $( cfix 70) "
issue_probe 70 ready 0 >/dev/null
check "a flag-free issue performs no attention episode read" 1 "" \
grep -qF 'repos/owner/repo/issues/70/timeline' " $TMP /api-calls "
2026-08-03 19:55:32 +00:00
# shellcheck disable=SC2016 # positional parameter belongs to the isolated shell
2026-08-03 19:53:25 +00:00
check "the attention sweep probes perform no issue edits" 0 " $attention_edits_before " \
bash -c 'wc -l <"$1"' _ " $TMP /issue-edits "
2026-07-23 11:57:20 +00:00
# -- the reclaim clock stops under a pending ruling (48h quiet, no PR) -------
jq -n --arg l " $( iso_at $(( INOW - 10 * 86400 )) ) " \
' [ { "event" :"labeled" ,"label" :{ "name" :"needs-ruling" } ,"actor" :{ "login" :"setter" } ,"created_at" :$l } ,
{ "event" :"assigned" ,"created_at" :$l } ] ' >" $( tfix 21) "
test: cover the shape check and the ladder's rungs
Pure decisions (shape presence, rung boundaries, Default: parse for wording
only), sweep probes for every AC path (malformed-once, conforming silence,
rungs despite activity, cron progression, missed-moment skip, re-flag
episode, unreadable comment list, malformed+rung same pass), and the
existing nudge fixtures updated to conforming escalations with pre-seeded
rung markers so each probe observes one behavior alone.
Part of #73.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:49:31 +00:00
# The escalation is conforming and the rung markers are pre-seeded — by 10
# days in both rungs fired long ago (#73), so this probe observes the nudge
# wiring alone; shape and rung behavior have their own probes in
# test/ruling.test.sh.
2026-07-23 11:57:20 +00:00
jq -n --arg at " $( iso_at $(( INOW - 10 * 86400 - 60 )) ) " \
test: cover the shape check and the ladder's rungs
Pure decisions (shape presence, rung boundaries, Default: parse for wording
only), sweep probes for every AC path (malformed-once, conforming silence,
rungs despite activity, cron progression, missed-moment skip, re-flag
episode, unreadable comment list, malformed+rung same pass), and the
existing nudge fixtures updated to conforming escalations with pre-seeded
rung markers so each probe observes one behavior alone.
Part of #73.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:49:31 +00:00
--arg b $'Options: A — x B — y\nRecommend: A, because x.\nBlocked: z\nDefault: none — hard block' \
--arg r12 " $( iso_at $(( INOW - 10 * 86400 + 13 * 3600 )) ) " \
--arg r24 " $( iso_at $(( INOW - 10 * 86400 + 25 * 3600 )) ) " \
' [ { "user" :{ "login" :"setter" } ,"created_at" :$at ,"html_url" :"https://x/esc21" ,"body" :$b } ,
{ "user" :{ "login" :"sweep-bot" } ,"created_at" :$r12 ,"html_url" :"https://x/r12" ,"body" :"<!-- ceremony:needs-ruling-rung12 -->\nrung" } ,
{ "user" :{ "login" :"sweep-bot" } ,"created_at" :$r24 ,"html_url" :"https://x/r24" ,"body" :"<!-- ceremony:needs-ruling-rung24 -->\nrung" } ] ' \
2026-07-23 11:57:20 +00:00
>" $( cfix 21) "
exempt = " $( issue_probe 21 $'claimed\nneeds-ruling' ) "
check "a 10-day-quiet claim under a ruling is not reclaimed" 1 "" \
grep -q 'reclaimed' <<< " $exempt "
check "...the same silence still nudges the pending ruling" 0 "" \
grep -q 'ruling nudge' <<< " $exempt "
# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process
check "...and the nudge went to the decider with the escalation linked" 0 "" \
bash -c 'grep -qF "@danmt" "$1" && grep -qF "https://x/esc21" "$1"' _ " $TMP /posted-21 "
again = " $( issue_probe 21 $'claimed\nneeds-ruling' ) "
check "the sweep right after the nudge holds its silence" 1 "" \
grep -q 'ruling nudge' <<< " $again "
check "exactly one nudge across both sweeps" 0 "1" \
grep -c -- '^----$' " $TMP /posted-21 "
# -- control: the same silence without the flag is reclaimed -----------------
jq -n --arg l " $( iso_at $(( INOW - 10 * 86400 )) ) " \
'[{"event":"assigned","created_at":$l}]' >" $( tfix 22) "
printf '[]\n' >" $( cfix 22) "
control = " $( issue_probe 22 claimed) "
check "the flag-free control is reclaimed (the clock still runs elsewhere)" 0 "" \
grep -q 'stale claim reclaimed -> ready' <<< " $control "
2026-07-25 00:11:42 +00:00
# -- merged Refs work releases the claim before the reclaim clock ------------
printf '[]\n' >" $( cfix 35) "
2026-08-23 01:06:54 +00:00
COLLISION_FLAGS = $'35\tissueflow-reconcile=34'
WINDOW_FLAGS = $'35\t#50'
2026-07-25 04:29:37 +00:00
transition = " $( issue_probe 35 claimed 1 false 350 $'- [x] built\n- [ ] verify dispatch\n * [ ] confirm warning clears' ) "
2026-08-23 01:06:54 +00:00
unset COLLISION_FLAGS WINDOW_FLAGS
2026-07-25 00:11:42 +00:00
check "merged Refs + unchecked criteria transitions in the sweep body" 0 "" \
grep -q 'merged Refs PR -> post-merge; claim released' <<< " $transition "
2026-08-23 01:06:54 +00:00
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 "
2026-07-25 00:14:23 +00:00
# shellcheck disable=SC2016 # positional parameters belong to bash -c
2026-07-25 00:11:42 +00:00
check "...names every remaining criterion verbatim in the comment" 0 "" \
bash -c ' grep -qF -- "- [ ] verify dispatch" " $1 " &&
grep -qF -- " * [ ] confirm warning clears" " $1 " ' _ " $TMP /posted-35 "
check "...states triage owes completion with owner and wake condition" 0 "" \
grep -qF 'Triage owes completion in a follow-up comment that names the owner and wake condition.' \
" $TMP /posted-35 "
check "...unassigns and swaps claimed to post-merge" 0 "" \
grep -qF -- '--remove-assignee owner-bot --remove-label claimed --add-label post-merge' \
" $TMP /issue-edits "
printf '[]\n' >" $( cfix 36) "
post_merge_quiet = " $( issue_probe 36 post-merge 0) "
check "quiet unassigned post-merge work is not reclaimed" 1 "" \
grep -q 'reclaimed' <<< " $post_merge_quiet "
2026-08-03 23:06:47 +00:00
# The quiet itself is now visible (#254) — this probe is 10 days old with no
# activity, so it draws the evidence nudge and nothing else. It used to
# assert no comment at all; that assertion described the starvation this
# issue exists to end, and the edit half of it is what still matters.
check "...and causes no edit" 1 "" grep -qF -- 'issue edit 36' " $TMP /issue-edits "
check "...only the evidence nudge speaks" 0 "1" \
grep -c -- '^----$' " $TMP /posted-36 "
2026-07-25 00:11:42 +00:00
printf '[]\n' >" $( cfix 37) "
2026-08-03 23:38:53 +00:00
# The live shape of an assigned `post-merge` issue: the assignee in the issue
# payload AND the `assigned` event that put it there in the timeline. With an
# empty timeline this fixture could not see the defect it exists to guard —
# the evidence clock counting that hour-old assignment as activity and
# silencing the nudge for another 7 days, on the one board state where an
# assignee is itself the bug being reported.
jq -n --arg at " $( iso_at $(( INOW - 3600 )) ) " \
'[{"event":"assigned","created_at":$at}]' >" $( tfix 37) "
2026-07-25 00:11:42 +00:00
issue_probe 37 post-merge 1 >/dev/null
check "assigned post-merge is flagged" 0 "" \
grep -qF '<!-- issueflow:post-merge-assigned -->' " $TMP /posted-37 "
check "...and the hand-assignment is not repaired" 1 "" \
grep -qF -- 'issue edit 37' " $TMP /issue-edits "
2026-08-03 23:06:47 +00:00
# The flag and the nudge answer different questions — a board bug and a
# starved wake condition — so neither suppresses the other (#254).
check "...and the evidence nudge rides beside it, neither suppressed" 0 "2" \
grep -c -- '^----$' " $TMP /posted-37 "
# -- the post-merge evidence nudge (#254), the ruling nudge's twin ----------
# The ruling nudge solved "a wait goes quiet and nobody is told" for
# `needs-ruling`; `post-merge` had no equivalent, and crew#181's real-host
# criterion starved four times across two releases for want of one. Same
# 7-day constant (`ruling_nudge_decision`, reused not mirrored), same
# deliberate absence of an idempotency marker, and — unlike the ruling
# nudge — addressed to the triage actor, because `post-merge` is triage's
# completion queue and the operator owes nothing here (#254 D1).
nudge_edits_before = " $( wc -l <" $TMP /issue-edits " ) "
quiet_comment( ) { # $1 issue, $2 seconds of quiet — one ordinary comment, then silence
jq -n --arg at " $( iso_at $(( INOW - $2 )) ) " \
'[{"user":{"login":"triage-one"},"created_at":$at,"html_url":"https://x/c","body":"evidence pending"}]' \
>" $( cfix " $1 " ) "
printf '[]\n' >" $( tfix " $1 " ) "
}
quiet_comment 80 $(( 8 * 86400 ))
nudged = " $( issue_probe 80 post-merge 0) "
check "8 quiet days on a post-merge item draws the evidence nudge" 0 "" \
grep -q 'post-merge evidence nudge' <<< " $nudged "
# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process
check "...addressed to the triage actor, never the human reviewer" 0 "" \
bash -c 'grep -qF "@triage-one" "$1" && ! grep -qF "@danmt" "$1"' _ " $TMP /posted-80 "
check "...with the issue link as the payload" 0 "" \
grep -qF 'https://github.com/owner/repo/issues/80' " $TMP /posted-80 "
fix(198): the workflow declares and refuses instead of being exempted by name (#198)
@codex-reviewer-andresmgsl's blocker 1 is right and the filename exemption was
the wrong shape. It exempted the whole FILE — any later `gh` call anywhere in
labels.yml would have ridden in free — and it let the merge ship a step that
dies with `command not found` on every sweep on this forge, which #197's bar
does not permit.
The declaration mechanism already existed; a workflow simply could not reach
it. It can: `CEREMONY_FORGE_CLIENT: gh` in the step's env is the same
declaration actions/refs-not-closing carries, and the refusal that a script
gets from forge_preflight is inline here because a workflow has no shell to
call it from. The dispatch now warns by name, cites #205, and exits 0 rather
than reddening every sweep for a known gap.
So the guard needs no exemption list at all. It now requires the pair —
declared AND refusing — and reports a declaration that carries no refusal,
which is a permission slip for `command not found`.
That predicate was wrong on its first write, and its mutation test caught it:
`refuses_when_unavailable` matched the word `forge_preflight` inside
labels.yml's own comment explaining that it has NO forge_preflight to call. A
guard reading prose as evidence is the blind sweep again, in the guard written
to forbid it. Comments are stripped now, as gh_calls already stripped them.
Blocker 4: the nudge strips a trailing slash from the server URL. Reverting the
strip reds two cases.
Blockers 2 and 3 were already fixed in 97e63ac, before either review landed.
test/run.sh 28 files 0 failed under CI's env; shellcheck 0.10.0 (CI's pin),
actionlint, self-ref, marker, vendored and changelog-armed all clean, with
every file tracked this time.
Refs #198
2026-08-05 12:16:53 +00:00
# The host comes from the environment, and a server URL that carries a
# trailing slash must not render `//owner/repo` (@codex-reviewer-andresmgsl,
# #198). Same forge, same issue, one character of difference in the input.
quiet_comment 80 $(( 8 * 86400 ))
PROBE_SERVER_URL = https://forgejo.example.test/ issue_probe 80 post-merge 0 >/dev/null
check "a trailing slash on the server URL does not double the separator" 0 "" \
grep -qF 'https://forgejo.example.test/owner/repo/issues/80' " $TMP /posted-80 "
check "...and no doubled separator appears at all" 1 "" \
grep -qF 'forgejo.example.test//owner' " $TMP /posted-80 "
quiet_comment 80 $(( 8 * 86400 ))
: >" $TMP /posted-80 "
issue_probe 80 post-merge 0 >/dev/null
2026-08-03 23:06:47 +00:00
check "...carrying the do-not-add-a-marker warning in the comment" 0 "" \
grep -qF 'Do not add a marker.' " $TMP /posted-80 "
# Asserted directly, not merely omitted: a marker would turn "once per 7
# quiet days" into "once per issue, forever" — the exact "fix" lib/ruling.sh's
# header records as the thing that breaks this rule.
check "...and no idempotency marker on the path" 1 "" \
grep -qF '<!-- issueflow:' " $TMP /posted-80 "
# Self-rate-limiting, proven by the property and not by the mechanism: sweep
# again a day later, against fixtures the first sweep's own comment mutated.
nudged_again = " $( PROBE_NOW = $(( INOW + 86400 )) issue_probe 80 post-merge 0) "
check "a sweep one day after the nudge holds its silence" 1 "" \
grep -q 'post-merge evidence nudge' <<< " $nudged_again "
check "...so exactly one nudge exists across both sweeps" 0 "1" \
grep -c -- '^----$' " $TMP /posted-80 "
quiet_comment 81 $(( 6 * 86400 ))
six_days = " $( issue_probe 81 post-merge 0) "
check "6 quiet days is inside the window and draws nothing" 1 "" \
grep -q 'post-merge evidence nudge' <<< " $six_days "
check "...and posts no comment at all" 1 "" test -f " $TMP /posted-81 "
# Label churn is not activity, or the sweep resets its own clock. The rule is
# `last_issue_activity`'s, shared with the reclaim and ruling clocks rather
# than restated here — this probe pins that the nudge inherits it.
jq -n --arg at " $( iso_at $(( INOW - 3600 )) ) " \
' [ { "event" :"labeled" ,"label" :{ "name" :"scope:docs" } ,"created_at" :$at } ,
{ "event" :"unlabeled" ,"label" :{ "name" :"scope:docs" } ,"created_at" :$at } ] ' >" $( tfix 82) "
printf '[]\n' >" $( cfix 82) "
churn = " $( issue_probe 82 post-merge 0) "
check "an hour-old label churn does not reset the 7-day clock" 0 "" \
grep -q 'post-merge evidence nudge' <<< " $churn "
quiet_comment 83 3600
fresh = " $( issue_probe 83 post-merge 0) "
check "an hour-old comment does reset it" 1 "" \
grep -q 'post-merge evidence nudge' <<< " $fresh "
2026-08-03 23:38:53 +00:00
# Neither is an assignment. That event is the *claim* clock's activity fact —
# 48 hours of silence must not include the seconds between a claim and its
# required draft PR — and `post-merge` has no claim for it to protect: an
# assignee here is the invalid composition the flag above reports. Counting
# it would let a broken board buy the item another 7 days of quiet, which is
# this issue's failure direction taken backwards. No current assignee on this
# probe, so nothing stands between the clock rule and the nudge.
quiet_comment 93 $(( 8 * 86400 ))
jq -n --arg at " $( iso_at $(( INOW - 3600 )) ) " \
'[{"event":"assigned","created_at":$at},{"event":"unassigned","created_at":$at}]' >" $( tfix 93) "
assigned_clock = " $( issue_probe 93 post-merge 0) "
check "an hour-old assignment does not reset the evidence clock either" 0 "" \
grep -q 'post-merge evidence nudge' <<< " $assigned_clock "
# The two clocks over the one computation, asserted directly rather than
# through a probe: same fixture, one input's difference, and the reclaim
# clock is pinned unmoved by the split.
jq -n --arg at " $( iso_at $(( INOW - 5 * 86400 )) ) " \
'[{"user":{"login":"triage-one"},"created_at":$at,"html_url":"https://x/c95","body":"evidence pending"}]' \
>" $( cfix 95) "
jq -n --arg at " $( iso_at $(( INOW - 3600 )) ) " \
'[{"event":"assigned","created_at":$at}]' >" $( tfix 95) "
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
2026-08-03 23:38:53 +00:00
two_clocks = " $( ( REPO = owner/repo; gh( ) { issue_stub_gh " $@ " ; }
printf '%s %s\n' \
" $( last_issue_activity 95 " $( iso_at $(( INOW - 10 * 86400 )) ) " ) " \
" $( last_issue_comment_activity 95 " $( iso_at $(( INOW - 10 * 86400 )) ) " ) " ) ) "
check "the claim clock counts the assignment, the evidence clock the comment" 0 \
" $(( INOW - 3600 )) $(( INOW - 5 * 86400 )) " printf '%s\n' " $two_clocks "
# One body, two callers: a second activity computation is the drift the
# reuse exists to prevent, so the timeline read has exactly one spelling.
2026-08-03 23:40:48 +00:00
# shellcheck disable=SC2016 # the read is asserted as a literal, unexpanded
2026-08-03 23:38:53 +00:00
check "the timeline read is not respelled for the evidence clock" 0 "1" \
grep -c 'issues/\$n/timeline' \
" $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh "
2026-08-03 23:06:47 +00:00
# Both waits are quiet, both are owed, and to different parties: suppressing
# one because the other spoke is a starved criterion, which is the failure
# this nudge exists to remove.
jq -n --arg l " $( iso_at $(( INOW - 9 * 86400 )) ) " \
'[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$l}]' \
>" $( tfix 84) "
jq -n --arg at " $( iso_at $(( INOW - 9 * 86400 - 60 )) ) " \
--arg b $'Options: A — x B — y\nRecommend: A, because x.\nBlocked: z\nDefault: none — hard block' \
--arg r12 " $( iso_at $(( INOW - 9 * 86400 + 13 * 3600 )) ) " \
--arg r24 " $( iso_at $(( INOW - 9 * 86400 + 25 * 3600 )) ) " \
' [ { "user" :{ "login" :"setter" } ,"created_at" :$at ,"html_url" :"https://x/esc84" ,"body" :$b } ,
{ "user" :{ "login" :"sweep-bot" } ,"created_at" :$r12 ,"html_url" :"https://x/r12" ,"body" :"<!-- ceremony:needs-ruling-rung12 -->\nrung" } ,
{ "user" :{ "login" :"sweep-bot" } ,"created_at" :$r24 ,"html_url" :"https://x/r24" ,"body" :"<!-- ceremony:needs-ruling-rung24 -->\nrung" } ] ' \
>" $( cfix 84) "
both = " $( issue_probe 84 $'post-merge\nneeds-ruling' 0) "
check "a quiet post-merge item under a pending ruling nudges both waits" 0 "" \
grep -q 'post-merge evidence nudge' <<< " $both "
check "...and the ruling nudge is not suppressed by it" 0 "" \
grep -q 'ruling nudge' <<< " $both "
# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process
check "...each addressing its own party" 0 "" \
bash -c 'grep -qF "@triage-one" "$1" && grep -qF "@danmt" "$1"' _ " $TMP /posted-84 "
# Every other queue state: the nudge is `post-merge`'s alone. Each is equally
# quiet, and `claimed` carries an open PR so its own reclaim clock — the one
# other 10-day rule on this path — stays out of the way.
non_post_merge = ( 85:ready:0:false 86:claimed:1:true 87:blocked:0:false 88:epic:0:false 89:needs-triage:0:false)
for spec in " ${ non_post_merge [@] } " ; do
IFS = : read -r n state probe_assignees probe_pr <<< " $spec "
printf '[]\n' >" $( cfix " $n " ) "
printf '[]\n' >" $( tfix " $n " ) "
check " a 10-day-quiet $state issue draws no evidence nudge " 1 "" \
grep -q 'post-merge evidence nudge' \
<<< " $( issue_probe " $n " " $state " " $probe_assignees " " $probe_pr " ) "
done
2026-08-03 23:10:42 +00:00
# The machine never judges prose: which criterion starved is not a fact the
# sweep reads, so a body it could not parse if it tried still nudges.
quiet_comment 90 $(( 8 * 86400 ))
unparseable_body = " $( issue_probe 90 post-merge 0 false "" '¯\_(ツ)_/¯ wake: ask danmt sometime' ) "
check "an unparseable body still nudges — the link is the payload" 0 "" \
grep -q 'post-merge evidence nudge' <<< " $unparseable_body "
check "...and the nudge quotes none of it" 1 "" \
grep -qF 'ask danmt sometime' " $TMP /posted-90 "
# One spelling of the 7-day rule. `lib/ruling.sh` exists because this family
# already paid for two copies of a constant; a second one here is the drift,
# and it is cheap to pin at the grep level.
# Code lines only: the branch's comment names the constant it must not
# respell, which is the sentence a future reader needs and not a second copy.
check "the 7-day rule is not respelled in the sweep" 1 "" \
grep -nE '^[^#]*(7 \* 24 \* 3600|604800)' \
" $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh "
# shellcheck disable=SC2016 # the call site is asserted as a literal
check "...it is reused from lib/ruling.sh" 0 "" \
2026-08-03 23:38:53 +00:00
grep -qF 'ruling_nudge_decision "$NOW" "$evidence_age"' \
2026-08-03 23:10:42 +00:00
" $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh "
2026-08-03 23:06:47 +00:00
# `post-merge` is the one queue state whose whole meaning is that the machine
# owes nothing (LABELS.md: the sweep never reclaims it). This issue makes the
# quiet visible; it must never make it actionable.
# shellcheck disable=SC2016 # positional parameter belongs to the isolated shell
check "the evidence-nudge probes perform no issue edits" 0 " $nudge_edits_before " \
bash -c 'wc -l <"$1"' _ " $TMP /issue-edits "
2026-07-25 00:11:42 +00:00
2026-08-04 16:16:19 +00:00
# -- the issue-side ruling clock is comments-only (#284) ---------------------
# #52 D10's "reuse the activity computation" made the issue-side ruling nudge
# ride the claim-reclamation clock, `assigned` events included — so claiming
# a flagged issue dated it, and the escalation the flag exists to keep
# visible went quiet for another 7 days at exactly the moment somebody
# started working through it. The ruling clock is now the comments-only one
# (D1); the reclaim clock keeps the assignment (D2), because there the
# assignment IS the claim. Every must-nudge probe below was run against the
# pre-#284 sweep and went red — the #274 round-1 discipline: the fixture
# proves the defect, not merely the fix.
ruling_clock_edits_before = " $( wc -l <" $TMP /issue-edits " ) "
ruling_quiet( ) { # $1 issue — conforming escalation, both rungs fired, ~9d of comment quiet
jq -n --arg l " $( iso_at $(( INOW - 10 * 86400 )) ) " \
'[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$l}]' \
>" $( tfix " $1 " ) "
jq -n --arg at " $( iso_at $(( INOW - 10 * 86400 - 60 )) ) " \
--arg b $'Options: A — x B — y\nRecommend: A, because x.\nBlocked: z\nDefault: none — hard block' \
--arg r12 " $( iso_at $(( INOW - 10 * 86400 + 13 * 3600 )) ) " \
--arg r24 " $( iso_at $(( INOW - 10 * 86400 + 25 * 3600 )) ) " \
' [ { "user" :{ "login" :"setter" } ,"created_at" :$at ,"html_url" :"https://x/esc" ,"body" :$b } ,
{ "user" :{ "login" :"sweep-bot" } ,"created_at" :$r12 ,"html_url" :"https://x/r12" ,"body" :"<!-- ceremony:needs-ruling-rung12 -->\nrung" } ,
{ "user" :{ "login" :"sweep-bot" } ,"created_at" :$r24 ,"html_url" :"https://x/r24" ,"body" :"<!-- ceremony:needs-ruling-rung24 -->\nrung" } ] ' \
>" $( cfix " $1 " ) "
}
timeline_add( ) { # $1 issue, $2 event, $3 seconds ago
jq --arg e " $2 " --arg at " $( iso_at $(( INOW - $3 )) ) " \
'. + [{"event":$e,"created_at":$at}]' \
" $( tfix " $1 " ) " >" $( tfix " $1 " ) .tmp " && mv " $( tfix " $1 " ) .tmp " " $( tfix " $1 " ) "
}
comment_add( ) { # $1 issue, $2 seconds ago
jq --arg at " $( iso_at $(( INOW - $2 )) ) " \
'. + [{"user":{"login":"decider"},"created_at":$at,"html_url":"https://x/d","body":"still thinking"}]' \
" $( cfix " $1 " ) " >" $( cfix " $1 " ) .tmp " && mv " $( cfix " $1 " ) .tmp " " $( cfix " $1 " ) "
}
# The live shape, and the defect: a builder claims the flagged issue, the
# assignment is an hour old, the decider has been silent ~9 days.
ruling_quiet 101
timeline_add 101 assigned 3600
claimed_fresh = " $( issue_probe 101 $'claimed\nneeds-ruling' 1 true ) "
check "an hour-old claim does not silence a ruling 9 days quiet" 0 "" \
grep -q 'ruling nudge' <<< " $claimed_fresh "
check "...and the fresh assignment is not reclaim bait either" 1 "" \
grep -q 'reclaimed' <<< " $claimed_fresh "
2026-08-04 19:39:05 +00:00
# The claimed branch's top read, pinned. `claimed` + `needs-ruling` with no
# assignee is the one composition where this branch posts BEFORE the ruling
# block: `claim_decision` returns FLAG_UNASSIGNED and the
# `claimed-unassigned` comment goes out, so a ruling clock read down there
# would date the issue by the sweep's own writing and buy the escalation
# another 7 quiet days — the #274 hazard, on the very branch #284 D6 exists
# to hold. Probe 101 does not reach it: it carries an assignee and an open
# PR. One sweep, both outputs — the board diagnostic and the nudge it must
# not silence — because asserting only the nudge would pass with the
# diagnostic silently gone. Reds the moment the top read drifts below the
# post, and the deletion of that read is what proved it (#284 D6, #307).
ruling_quiet 110
timeline_add 110 assigned 3600
unassigned_flag = " $( issue_probe 110 $'claimed\nneeds-ruling' 0 false ) "
check "an unassigned claim under a ruling still draws its board flag" 0 "1" \
grep -cF '<!-- issueflow:claimed-unassigned -->' " $TMP /posted-110 "
check "...and the ruling nudge fires beside it, undated by it" 0 "" \
grep -q 'ruling nudge' <<< " $unassigned_flag "
2026-08-04 16:16:19 +00:00
# The clock rule alone, no assignee in the way: an assigned/unassigned pair
# in the timeline is the claim's history, not activity toward the ruling.
ruling_quiet 102
timeline_add 102 assigned 3600
timeline_add 102 unassigned 3500
ready_pair = " $( issue_probe 102 $'ready\nneeds-ruling' 0) "
check "a ready issue nudges through an hour-old assignment pair" 0 "" \
grep -q 'ruling nudge' <<< " $ready_pair "
# post-merge + needs-ruling fires BOTH nudges in one sweep, from one read
# taken before either write. This probe is also the read-order pin: the
# evidence nudge posts first and the stub stamps it as fresh activity, so
# restoring a ruling-clock read below `ensure_comment` turns the second
# check red — the hazard #274 met and killed inside one round.
ruling_quiet 103
timeline_add 103 assigned 3600
timeline_add 103 unassigned 3500
both_fresh = " $( issue_probe 103 $'post-merge\nneeds-ruling' 0) "
check "a fresh assignment starves neither post-merge wait" 0 "" \
grep -q 'post-merge evidence nudge' <<< " $both_fresh "
check "...the ruling nudge fires beside it, not behind it" 0 "" \
grep -q 'ruling nudge' <<< " $both_fresh "
# blocked composes the same way. The #252 parse echo is pre-seeded old so
# the probe isolates the clock rule — steady state, where the echo for this
# parse set already exists and the branch posts nothing before the tail.
ruling_quiet 104
timeline_add 104 assigned 3600
refs_104 = " $( blocked_references <<< 'Blocked by #999.' ) "
cross_104 = " $( blocked_cross_references <<< 'Blocked by #999.' ) "
marker_104 = " $( blocked_parse_marker " $( blocked_parse_set " $refs_104 " " $cross_104 " ) " ) "
jq --arg m " $marker_104 " --arg at " $( iso_at $(( INOW - 9 * 86400 )) ) " \
'. + [{"user":{"login":"sweep-bot"},"created_at":$at,"html_url":"https://x/echo","body":("<!-- issueflow:" + $m + " -->\necho")}]' \
" $( cfix 104) " >" $( cfix 104) .tmp " && mv " $( cfix 104) .tmp " " $( cfix 104) "
blocked_fresh = " $( issue_probe 104 $'blocked\nneeds-ruling' 0 false "" 'Blocked by #999.' ) "
check "a blocked issue nudges through an hour-old assignment" 0 "" \
grep -q 'ruling nudge' <<< " $blocked_fresh "
# 6 days of comment quiet is 6, with or without an assignment inside it.
ruling_quiet 105
timeline_add 105 assigned 3600
comment_add 105 $(( 6 * 86400 ))
six_days = " $( issue_probe 105 $'claimed\nneeds-ruling' 1 true ) "
check "6 days of comment quiet draws no nudge" 1 "" \
grep -q 'ruling nudge' <<< " $six_days "
# Label churn is not activity on this clock either — it reads no timeline
# at all, which closes the class rather than the spelling.
ruling_quiet 106
timeline_add 106 labeled 1800
timeline_add 106 unlabeled 1700
churn = " $( issue_probe 106 $'ready\nneeds-ruling' 0) "
check "hour-old label churn does not hold the nudge back" 0 "" \
grep -q 'ruling nudge' <<< " $churn "
# Self-rate-limiting, asserted as the property (#254's discipline): sweep
# again a day after probe 101's nudge and the nudge it posted is the
# activity that keeps it silent — no marker involved.
day_after = " $( PROBE_NOW = $(( INOW + 86400 )) issue_probe 101 $'claimed\nneeds-ruling' 1 true ) "
check "the sweep a day after its nudge holds its silence" 1 "" \
grep -q 'ruling nudge' <<< " $day_after "
# No flag, no nudge, whatever the clock says.
quiet_comment 107 $(( 60 * 86400 ))
noflag = " $( issue_probe 107 ready 0) "
check "an unflagged issue draws no ruling nudge at any age" 1 "" \
grep -q 'ruling nudge' <<< " $noflag "
# D2's input doing its job — the one thing a "the clocks are the same now,
# merge them" refactor would break. Red the instant `last_issue_activity`
# loses `assigned`.
printf '[]\n' >" $( cfix 108) "
jq -n --arg at " $( iso_at $(( INOW - 600 )) ) " \
'[{"event":"assigned","created_at":$at}]' >" $( tfix 108) "
not_reclaimed = " $( issue_probe 108 claimed 1 false ) "
check "a 10-minute-old claim on a silent issue is not reclaimed" 1 "" \
grep -q 'reclaimed' <<< " $not_reclaimed "
# One fixture, two clocks, asserted directly and not by inspection: the
# newest event is the assignment; the reclaim clock returns it and the
# ruling clock returns the older comment.
jq -n --arg at " $( iso_at $(( INOW - 8 * 86400 )) ) " \
'[{"user":{"login":"decider"},"created_at":$at,"html_url":"https://x/c9","body":"x"}]' >" $( cfix 109) "
jq -n --arg at " $( iso_at $(( INOW - 3600 )) ) " \
'[{"event":"assigned","created_at":$at}]' >" $( tfix 109) "
clock_read( ) { # $1 clock fn — both against fixture 109
( REPO = owner/repo
# shellcheck disable=SC2317 # reached indirectly, through the clock under test
gh( ) { issue_stub_gh " $@ " ; }
" $1 " 109 " $( iso_at $(( INOW - 10 * 86400 )) ) " )
}
check "one fixture, two clocks: the reclaim clock returns the assignment" \
0 " $(( INOW - 3600 )) " clock_read last_issue_activity
check "...and the ruling clock returns the older comment" \
0 " $(( INOW - 8 * 86400 )) " clock_read last_issue_comment_activity
# The mechanical call-site pins: the reclaim clock can never reach
# reconcile_ruling, on any path, asserted against the source and not the
# diff. Beside them, the one-spelling pins this issue inherits stay green.
# shellcheck disable=SC2016 # the call sites are asserted as literals
check "reconcile_ruling is never handed the reclaim clock" 1 "" \
grep -E '^[^#]*reconcile_ruling.*\$age' \
" $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh "
# shellcheck disable=SC2016 # the call site is asserted as a literal
check "...its one call site is fed the comments-only clock" 0 "1" \
grep -cF 'reconcile_ruling "$n" "$ruling_age" "$NOW"' \
" $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh "
# shellcheck disable=SC2016 # the guarded_read is asserted as a literal
check "...and ruling_age is never fed by the reclaim clock" 1 "" \
grep -E 'ruling_age.*last_issue_activity ' \
" $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh "
# shellcheck disable=SC2016 # positional parameter belongs to the isolated shell
check "the #284 probes perform no issue edits" 0 " $ruling_clock_edits_before " \
bash -c 'wc -l <"$1"' _ " $TMP /issue-edits "
2026-07-25 00:11:42 +00:00
2026-07-25 00:14:23 +00:00
# -- non-triggers stay byte-for-byte outside the transition ------------------
recent_timeline( ) {
jq -n --arg at " $( iso_at $(( INOW - 60 )) ) " \
'[{"event":"assigned","created_at":$at}]' >" $( tfix " $1 " ) "
printf '[]\n' >" $( cfix " $1 " ) "
}
edit_count_before = " $( wc -l <" $TMP /issue-edits " ) "
recent_timeline 38
2026-08-03 15:51:35 +00:00
open_refs = " $( issue_probe 38 claimed 1 refs 380 '- [ ] verify after merge' ) "
check "issue_probe: open Refs PR leaves the issue exactly as found" 0 "" \
2026-07-25 00:14:23 +00:00
test -z " $open_refs "
# shellcheck disable=SC2016 # positional parameters belong to bash -c
check "...with no edit or comment" 0 "" \
bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \
" $edit_count_before " " $TMP /issue-edits " " $TMP /posted-38 "
2026-08-03 15:51:35 +00:00
recent_timeline 46
open_closing = " $( issue_probe 46 claimed 1 closing 460 '- [ ] verify after merge' ) "
check "issue_probe: closing-linked open PR remains the unchanged control" 0 "" \
test -z " $open_closing "
2026-07-25 00:14:23 +00:00
recent_timeline 39
2026-07-25 04:29:37 +00:00
merged_closes = " $( issue_probe 39 claimed 1 false "" '- [ ] verify after merge' ) "
2026-07-25 00:14:23 +00:00
check "merged Closes PR leaves a recent claim exactly as found" 0 "" \
test -z " $merged_closes "
# shellcheck disable=SC2016 # positional parameters belong to bash -c
check "...with no edit or comment" 0 "" \
bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \
" $edit_count_before " " $TMP /issue-edits " " $TMP /posted-39 "
recent_timeline 40
2026-07-25 04:29:37 +00:00
all_checked = " $( issue_probe 40 claimed 1 false 400 '- [x] verified after merge' ) "
2026-07-25 00:14:23 +00:00
check "merged Refs with zero unchecked boxes leaves the issue exactly as found" 0 "" \
test -z " $all_checked "
# shellcheck disable=SC2016 # positional parameters belong to bash -c
check "...with no edit or comment" 0 "" \
bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \
" $edit_count_before " " $TMP /issue-edits " " $TMP /posted-40 "
printf '[]\n' >" $( cfix 41) "
2026-07-25 04:29:37 +00:00
attention_transition = " $( issue_probe 41 $'claimed\nattention' 1 false 410 '- [ ] verify' ) "
2026-07-25 00:14:23 +00:00
check "derived post-merge transition clears attention with the released claim" 0 "" \
grep -qF -- '--remove-label claimed,attention --add-label post-merge' " $TMP /issue-edits "
check "...still completes the transition" 0 "" \
grep -qF 'merged Refs PR -> post-merge; claim released' <<< " $attention_transition "
2026-07-25 04:29:37 +00:00
recent_timeline 43
jq -n --arg b '<!-- issueflow:post-merge-transition-pr-430 -->' \
--arg at " $( iso_at $(( INOW - 60 )) ) " \
'[{"body":$b,"created_at":$at}]' >" $( cfix 43) "
reentry_edit_count = " $( wc -l <" $TMP /issue-edits " ) "
historical = " $( issue_probe 43 claimed 1 false 430 '- [ ] corrective verification' ) "
check "a handled historical Refs merge cannot steal a re-entered claim" 0 "" \
test -z " $historical "
# shellcheck disable=SC2016 # positional parameters belong to bash -c
check "...and re-entry produces no edit or duplicate transition comment" 0 "" \
bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \
" $reentry_edit_count " " $TMP /issue-edits " " $TMP /posted-43 "
printf '[]\n' >" $( cfix 44) "
second_transition = " $( issue_probe 44 claimed 1 false 441 '- [ ] second verification' ) "
check "a later merged Refs PR gets an episode-specific transition comment" 0 "" \
grep -qF '<!-- issueflow:post-merge-transition-pr-441 -->' " $TMP /posted-44 "
check "...and the later episode still transitions" 0 "" \
grep -qF 'merged Refs PR -> post-merge; claim released' <<< " $second_transition "
2026-08-03 16:27:43 +00:00
# End to end on the crew#321 shape: the later merge is the *lower*-numbered
# PR, and its marker is already on the issue. Selecting by number would find
# no marker for #461, fire the transition a second time, and release a claim
# the board already released (#242).
recent_timeline 46
jq -n --arg b '<!-- issueflow:post-merge-transition-pr-460 -->' \
--arg at " $( iso_at $(( INOW - 60 )) ) " \
'[{"body":$b,"created_at":$at}]' >" $( cfix 46) "
spent_edit_count = " $( wc -l <" $TMP /issue-edits " ) "
spent = " $( issue_probe 46 claimed 1 false \
" 461@ $( iso_at $(( INOW - 7200 )) ) 460@ $( iso_at $(( INOW - 3600 )) ) " \
'- [ ] verify after merge' ) "
check "the marker of the later-merged lower-numbered PR is the one read" 0 "" \
test -z " $spent "
# shellcheck disable=SC2016 # positional parameters belong to bash -c
check "...so the spent transition is not fired a second time" 0 "" \
bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \
" $spent_edit_count " " $TMP /issue-edits " " $TMP /posted-46 "
2026-07-25 04:29:37 +00:00
printf '[]\n' >" $( cfix 45) "
issue_probe 45 $'claimed\npost-merge' >/dev/null
# shellcheck disable=SC2016 # Markdown backticks are literal evidence
check "queue-conflict evidence lists every category including post-merge" 0 "" \
grep -qF 'needs-triage`, `epic`, `ready`, `claimed`, `blocked`, or `post-merge`' \
" $TMP /posted-45 "
2026-07-25 00:14:23 +00:00
printf '[]\n' >" $( cfix 42) "
issue_probe 42 $'post-merge\nattention' 0 >/dev/null
check "hand-created post-merge plus attention is flagged, not rewritten" 0 "" \
grep -qF '<!-- issueflow:post-merge-assigned -->' " $TMP /posted-42 "
2026-07-23 12:48:04 +00:00
# -- offsite stops only the reclaim clock ------------------------------------
offsite = " $( issue_probe 25 $'claimed\noffsite' ) "
check "a 10-day-quiet offsite claim is not reclaimed" 1 "" \
grep -q 'reclaimed' <<< " $offsite "
2026-07-23 12:48:59 +00:00
issue_probe 26 $'claimed\noffsite' 0 >/dev/null
2026-07-23 12:48:04 +00:00
check "an unassigned offsite claim is still flagged" 0 "" \
grep -q 'issueflow:claimed-unassigned' " $TMP /posted-26 "
offsite_open = " $( issue_probe 27 $'claimed\noffsite' 1 true ) "
check "an offsite claim with an open PR stays claimed" 1 "" \
grep -q 'reclaimed' <<< " $offsite_open "
offsite_both = " $( issue_probe 28 $'claimed\noffsite\nneeds-ruling' ) "
check "offsite plus needs-ruling stays claimed" 1 "" \
grep -q 'reclaimed' <<< " $offsite_both "
2026-07-23 13:19:11 +00:00
# -- resolved offsite work nudges once and only from complete evidence -------
jq -n --arg at " $( iso_at $(( INOW - 3600 )) ) " \
' [ { "event" :"assigned" ,"created_at" :$at } ,
{ "event" :"cross-referenced" ,"source" :{ "issue" :{ "number" :112,"repository" :{ "full_name" :"heavy-duty/rig" } ,"pull_request" :{ "url" :"x" } } } } ] ' \
>" $( tfix 29) "
printf '{"state":"closed"}\n' >" $TMP /repos_heavy-duty_rig_pulls_112.json "
printf '[]\n' >" $( cfix 29) "
resolved = " $( issue_probe 29 $'claimed\noffsite' ) "
check "a closed cross-referenced PR nudges and names the PR" 0 "" \
grep -q 'heavy-duty/rig#112 is closed' " $TMP /posted-29 "
check "the resolved nudge leaves the claim untouched" 1 "" \
grep -q 'reclaimed' <<< " $resolved "
issue_probe 29 $'claimed\noffsite' >/dev/null
check "the resolved nudge is idempotent across sweeps" 0 "1" \
grep -cF '<!-- issueflow:offsite-resolved -->' " $TMP /posted-29 "
jq -n --arg at " $( iso_at $(( INOW - 3600 )) ) " \
' [ { "event" :"assigned" ,"created_at" :$at } ,
{ "event" :"cross-referenced" ,"source" :{ "issue" :{ "number" :112,"repository" :{ "full_name" :"heavy-duty/rig" } ,"pull_request" :{ "url" :"x" } } } } ,
{ "event" :"cross-referenced" ,"source" :{ "issue" :{ "number" :9,"repository" :{ "full_name" :"heavy-duty/box" } ,"pull_request" :{ "url" :"x" } } } } ] ' \
>" $( tfix 30) "
printf '{"state":"open"}\n' >" $TMP /repos_heavy-duty_box_pulls_9.json "
printf '[]\n' >" $( cfix 30) "
issue_probe 30 $'claimed\noffsite' >/dev/null
check "one open cross-referenced PR suppresses the nudge" 1 "" \
test -f " $TMP /posted-30 "
printf '[]\n' >" $( tfix 31) "
printf '[]\n' >" $( cfix 31) "
issue_probe 31 $'claimed\noffsite' >/dev/null
check "no visible cross-referenced PR stays silent" 1 "" test -f " $TMP /posted-31 "
: >" $( tfix 32) .error "
printf '[]\n' >" $( cfix 32) "
unreadable = " $( issue_probe 32 $'claimed\noffsite' ) "
check "an unreadable timeline stays silent" 1 "" test -f " $TMP /posted-32 "
check "...and leaves the sweep running without an alarming log" 1 "" \
grep -qiE 'error|failed' <<< " $unreadable "
2026-08-03 18:08:19 +00:00
# Both checks above still hold, and #247 D1 changed what reaches them:
# last_issue_activity reads the same timeline endpoint, so the issue is now
# skipped before the offsite verification runs. The skip is why nothing is
# posted, and its reason line is a deliberate report rather than an alarm
# (D4). D8 leaves offsite_timeline's own silence alone, so it is pinned here
# directly rather than through a probe that can no longer reach it.
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
2026-08-03 18:08:19 +00:00
offsite_timeline_probe( ) { ( REPO = owner/repo; gh( ) { issue_stub_gh " $@ " ; } ; offsite_timeline " $1 " ) ; }
check "an unreadable offsite timeline yields nothing and still fails closed" 1 "" \
offsite_timeline_probe 32
check "...while a readable one answers its payload" 0 "[]" offsite_timeline_probe 31
2026-07-23 13:19:11 +00:00
: >" $TMP /api-calls "
printf '[]\n' >" $( tfix 33) "
printf '[]\n' >" $( cfix 33) "
issue_probe 33 claimed >/dev/null
check "a non-offsite claim performs only the ordinary timeline read" 0 "1" \
grep -cF 'repos/owner/repo/issues/33/timeline' " $TMP /api-calls "
: >" $TMP /api-calls "
printf '[]\n' >" $( tfix 34) "
printf '[]\n' >" $( cfix 34) "
issue_probe 34 $'claimed\noffsite' >/dev/null
check "an offsite claim performs the one guarded verification read" 0 "2" \
grep -cF 'repos/owner/repo/issues/34/timeline' " $TMP /api-calls "
2026-07-23 12:48:04 +00:00
check "a one-hour claim stays claimed for the ordinary age reason" 0 "KEEP" \
claim_decision 1 false 3600
check "no reconciler mutation names offsite (#68 D4)" 1 "" \
grep -E 'gh (issue|pr) edit.*offsite' \
" $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh " \
" $ROOT /actions/labels-reconcile/labels-reconcile.sh "
2026-08-03 19:28:31 +00:00
# -- the parse echo: one comment per changed set, none per sweep (#252) ------
# The whole point is a sweep-visible statement of what was read, so it is
# probed through the sweep and not only as a rendering: the marker has to
# survive the comment body, the second pass has to find it, and the third has
# to miss it because the declaration changed.
printf '{"state":"open"}\n' >" $TMP /repos_owner_repo_issues_90.json "
printf '{"state":"open"}\n' >" $TMP /repos_owner_repo_issues_91.json "
printf '{"state":"open"}\n' >" $TMP /repos_owner_repo_issues_92.json "
printf '[]\n' >" $( cfix 35) "
echo_edits_before = " $( wc -l <" $TMP /issue-edits " ) "
first_echo = " $( issue_probe 35 blocked 1 false "" "Part of #1. Blocked by #90, #91." ) "
check "a first parse is echoed, naming the set" 0 "" \
grep -qF 'parse to: {#90, #91}' " $TMP /posted-35 "
check "...and the sweep log carries the same set" 0 \
"issueflow: #35: blocked declarations parse to {#90, #91}" \
printf '%s\n' " $first_echo "
issue_probe 35 blocked 1 false "" "Part of #1. Blocked by #90, #91." >/dev/null
check "an unchanged parse draws nothing on the next sweep" 0 "1" \
2026-08-03 20:05:04 +00:00
grep -cF " <!-- issueflow: $( blocked_parse_marker '{#90, #91}' ) --> " " $TMP /posted-35 "
test(issueflow): AC-1's other input is an edit, not a re-sweep
AC-1 names two inputs and says "both by fixture". The suite had the first and,
for the second, only a re-sweep of a BYTE-IDENTICAL body — which is the test
plan's other must-not-echo bullet, and cannot stand in for this one: an
identical body is quiet under both spellings of the decision, the one that
keys on the parse and the one that keys on the prose, so it cannot tell them
apart. Only an edit that changes the prose and preserves the parse can.
The new probe reorders the refs and adds sentences on either side, leaving the
set at {#90, #91}, and asserts the marker count, the thread's total echo count
and the issue-edit count all hold still. What it pins is that the marker is a
function of the PARSE and not of the prose around it — the property the whole
idempotency rests on.
Mutation-proven isolating: an echo that also re-fires when the declaration
prose moved since the last echo — quiet on identical re-sweeps, correct on
every set change — passes the pre-existing suite 304/0 and reds only here.
2026-08-03 21:15:37 +00:00
# AC-1's other input, and it is not the sweep above. A re-sweep of a
# BYTE-IDENTICAL body is quiet under both spellings of the decision — the one
# that keys on the parse and the one that keys on the body — so it cannot tell
# them apart. Only an edit that changes the prose and preserves the parse can:
# the refs are reordered and sentences are added on either side, and the set is
# still {#90, #91}. What this pins is that the marker is a function of the
# PARSE and not of the prose around it, which is the property the echo's whole
# idempotency rests on and which nothing else in the suite states.
preserved_edits_before = " $( wc -l <" $TMP /issue-edits " ) "
issue_probe 35 blocked 1 false "" \
"Some new prose here. Blocked by #91, #90. And more text." >/dev/null
check "a body edit that preserves the parse draws nothing" 0 "1" \
grep -cF " <!-- issueflow: $( blocked_parse_marker '{#90, #91}' ) --> " " $TMP /posted-35 "
check "...and adds no echo under any other marker either" 0 "1" \
grep -cF '<!-- issueflow:blockers-parsed-' " $TMP /posted-35 "
# shellcheck disable=SC2016 # positional parameters belong to bash -c
check "...and writes no label from the parse-preserving path" 0 "" \
bash -c 'test "$1" -eq "$(wc -l <"$2")"' _ " $preserved_edits_before " " $TMP /issue-edits "
2026-08-03 19:28:31 +00:00
changed_echo = " $( issue_probe 35 blocked 1 false "" "Part of #1. Blocked by #90, #91, #92." ) "
check "a body edit that changes the set draws exactly one new echo" 0 "1" \
2026-08-03 20:05:04 +00:00
grep -cF " <!-- issueflow: $( blocked_parse_marker '{#90, #91, #92}' ) --> " " $TMP /posted-35 "
2026-08-03 19:28:31 +00:00
check "...naming the new set" 0 "" \
grep -qF 'parse to: {#90, #91, #92}' " $TMP /posted-35 "
check "...and saying so in the sweep log" 0 \
"issueflow: #35: blocked declarations parse to {#90, #91, #92}" \
printf '%s\n' " $changed_echo "
check "...and leaving the first echo alone" 0 "1" \
2026-08-03 20:05:04 +00:00
grep -cF " <!-- issueflow: $( blocked_parse_marker '{#90, #91}' ) --> " " $TMP /posted-35 "
2026-08-03 19:28:31 +00:00
# shellcheck disable=SC2016 # positional parameters belong to bash -c
check "no label write comes from the echo path" 0 "" \
bash -c 'test "$1" -eq "$(wc -l <"$2")"' _ " $echo_edits_before " " $TMP /issue-edits "
# crew#308, replayed through the sweep: the declaration denies #221 and the
# parse unions it anyway. Nobody saw that set for as long as it stayed inside
# the machine; the echo puts it in the thread that contains the declaration.
printf '{"state":"open"}\n' >" $TMP /repos_owner_repo_issues_162.json "
printf '{"state":"open"}\n' >" $TMP /repos_owner_repo_issues_221.json "
printf '{"state":"open"}\n' >" $TMP /repos_owner_repo_issues_265.json "
printf '[]\n' >" $( cfix 36) "
issue_probe 36 blocked 1 false "" \
'Blocked by #162, #265. It is no longer blocked by #221.' >/dev/null
check "the #308 misparse is echoed verbatim, denial and all" 0 "" \
grep -qF 'parse to: {#162, #221, #265}' " $TMP /posted-36 "
# The empty parse says so, and the flag that catches the UNREADABLE
# declaration is untouched beside it: one comment states what was read, the
# other states that nothing was.
printf '[]\n' >" $( cfix 37) "
issue_probe 37 blocked 1 false "" 'No declaration anywhere in this body.' >/dev/null
check "an empty parse is echoed as the empty set" 0 "" \
grep -qF 'parse to: {}' " $TMP /posted-37 "
check "...and blocked-unparseable still fires beside it" 0 "" \
grep -qF '<!-- issueflow:blocked-unparseable -->' " $TMP /posted-37 "
2026-08-03 20:05:04 +00:00
# The collision the round found, replayed through the sweep: two declarations
# whose parsed sets differ but whose slugs do not. Keyed on the slug, the
# second edit found the first echo's marker and posted nothing — the machine
# silently gating on `acme-widgets#9` while the thread said `acme/widgets#9`,
# which is the readable-but-wrong shape this whole change exists to surface.
# Asserted end-to-end, so it is the second echo landing that is observed.
printf '[]\n' >" $( cfix 38) "
issue_probe 38 blocked 1 false "" 'Blocked by acme/widgets#9.' >/dev/null
check "a qualified cross-repo declaration is echoed" 0 "1" \
grep -cF 'parse to: {acme/widgets#9}' " $TMP /posted-38 "
collision_edits_before = " $( wc -l <" $TMP /issue-edits " ) "
issue_probe 38 blocked 1 false "" 'Blocked by acme-widgets#9.' >/dev/null
check "a slug-colliding edit still draws its own echo" 0 "1" \
grep -cF 'parse to: {acme-widgets#9}' " $TMP /posted-38 "
check "...under a marker of its own" 0 "1" \
grep -cF " <!-- issueflow: $( blocked_parse_marker '{acme-widgets#9}' ) --> " " $TMP /posted-38 "
check "...leaving the colliding first echo alone" 0 "1" \
grep -cF " <!-- issueflow: $( blocked_parse_marker '{acme/widgets#9}' ) --> " " $TMP /posted-38 "
# The cross-repo flag is marker-constant across both parses, so it stays at one
# while the echo moves: what spoke on the second sweep was the changed set.
check "...and not re-flagging cross-repo, which did not change" 0 "1" \
grep -cF '<!-- issueflow:blocked-cross-repo -->' " $TMP /posted-38 "
# shellcheck disable=SC2016 # positional parameters belong to bash -c
check "no label write comes from the colliding-edit path either" 0 "" \
bash -c 'test "$1" -eq "$(wc -l <"$2")"' _ " $collision_edits_before " " $TMP /issue-edits "
2026-08-03 20:35:10 +00:00
# A -> B -> A. The marker names the SET, so the return to A is a marker this
# thread has carried before; the question the echo has to answer is not "have I
# ever said this" but "is this still what I am saying". Searching the whole
# history answers the first, and the return went silent while the thread's
# newest echo asserted B and the sweep gated on A — a stale parse presented as
# the current one, which is the readable-but-wrong shape #252 exists to kill.
# All four sweeps are driven, because the bug is only visible as a sequence.
printf '[]\n' >" $( cfix 52) "
issue_probe 52 blocked 1 false "" 'Blocked by #90, #91.' >/dev/null
issue_probe 52 blocked 1 false "" 'Blocked by #90, #91, #92.' >/dev/null
return_edits_before = " $( wc -l <" $TMP /issue-edits " ) "
issue_probe 52 blocked 1 false "" 'Blocked by #90, #91.' >/dev/null
check "a set edited back to a previously echoed one speaks again" 0 "3" \
grep -cF '<!-- issueflow:blockers-parsed-' " $TMP /posted-52 "
check "...under the returning set's own marker, twice on the thread now" 0 "2" \
grep -cF " <!-- issueflow: $( blocked_parse_marker '{#90, #91}' ) --> " " $TMP /posted-52 "
# The assertion the silence used to fail: it is the NEWEST echo that has to
# name what the sweep gates on, not merely some echo somewhere in the thread.
# shellcheck disable=SC2016 # positional parameters belong to bash -c
check "...leaving the newest echo naming the set the sweep now gates on" 0 \
"parse to: {#90, #91}" \
bash -c 'grep -o "parse to: {[^}]*}" "$1" | tail -n 1' _ " $TMP /posted-52 "
issue_probe 52 blocked 1 false "" 'Blocked by #90, #91.' >/dev/null
check "an unchanged sweep after the return still draws nothing" 0 "3" \
grep -cF '<!-- issueflow:blockers-parsed-' " $TMP /posted-52 "
# shellcheck disable=SC2016 # positional parameters belong to bash -c
check "no label write comes from the returning-set path either" 0 "" \
bash -c 'test "$1" -eq "$(wc -l <"$2")"' _ " $return_edits_before " " $TMP /issue-edits "
2026-07-23 11:57:20 +00:00
# -- an already-applied stale heals off, and no edit names the flag ----------
jq -n --arg l " $( iso_at $(( INOW - 3600 )) ) " \
'[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$l}]' >" $( tfix 23) "
jq -n --arg at " $( iso_at $(( INOW - 3660 )) ) " \
'[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc23","body":"question, options, recommendation"}]' \
>" $( cfix 23) "
healed = " $( issue_probe 23 $'claimed\nneeds-ruling\nstale' ) "
check "an applied stale comes off under a pending ruling" 0 "" \
grep -q 'unstale (a ruling is pending)' <<< " $healed "
check "...via an edit that removes exactly stale" 0 "" \
grep -q -- '--remove-label stale' " $TMP /issue-edits "
check "no issue edit across every probe names the ruling flag (#50 D9)" 1 "" \
grep -q 'needs-ruling' " $TMP /issue-edits "
# -- label churn is not activity: the nudge clock reads comments, not labels --
jq -n --arg flag " $( iso_at $(( INOW - 8 * 86400 )) ) " \
--arg churn " $( iso_at $(( INOW - 2 * 86400 )) ) " \
--arg assigned " $( iso_at $(( INOW - 9 * 86400 )) ) " \
' [ { "event" :"labeled" ,"label" :{ "name" :"needs-ruling" } ,"actor" :{ "login" :"setter" } ,"created_at" :$flag } ,
{ "event" :"labeled" ,"label" :{ "name" :"priority" } ,"actor" :{ "login" :"anyone" } ,"created_at" :$churn } ,
{ "event" :"assigned" ,"created_at" :$assigned } ] ' >" $( tfix 24) "
jq -n --arg at " $( iso_at $(( INOW - 8 * 86400 - 60 )) ) " \
'[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc24","body":"question, options, recommendation"}]' \
>" $( cfix 24) "
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
# shellcheck disable=SC2317 # reached through the forge backend (#188)
2026-07-23 11:57:20 +00:00
churn_last = " $( ( REPO = owner/repo; gh( ) { issue_stub_gh " $@ " ; }
last_issue_activity 24 " $( iso_at $(( INOW - 10 * 86400 )) ) " ) ) "
check "last activity ignores the 2-day-old label churn" 0 "" \
test " $churn_last " = " $(( INOW - 8 * 86400 - 60 )) "
churned = " $( issue_probe 24 $'claimed\nneeds-ruling' ) "
check "8 real-quiet days nudge through a 2-day-old label churn" 0 "" \
grep -q 'ruling nudge' <<< " $churned "
2026-08-03 18:06:13 +00:00
# ---------------------------------------------------------------------------
# An unreadable fact invents no verdict on the issue surface either (#247).
# `gh api` prints a 5xx body to stdout AND exits non-zero, and GitHub's 5xx
# body is a JSON object — so the payload that reached the guards was valid
# JSON, `.labels[]` came back empty, and queue_decision was handed the wrong
# input. The pure guards first, then the two decisions the fall-through
# reached.
# ---------------------------------------------------------------------------
payload_refused( ) { ! issue_payload_valid " $@ " ; } # 0 when the payload is refused
check "a healthy issue payload is accepted" 0 "" \
issue_payload_valid 40 <<< '{"number":40,"labels":[{"name":"ready"}]}'
check "an issue carrying no labels at all is still a valid payload" 0 "" \
issue_payload_valid 40 <<< '{"number":40,"labels":[]}'
# The reported shape: gh renders `gh: <message> (HTTP 504)` from a body with a
# `message` key, which proves the body was valid JSON. The status check is what
# catches this one; the shape check refuses it independently.
check "a JSON error object is not an issue payload" 0 "" \
payload_refused 40 <<< " $GH_STUB_ERROR_BODY "
# The live path a status check alone would leave open (D3): 200, exit 0, and
# `.labels[]` empties exactly as it does on the 504.
check "an HTTP 200 whose body is null is refused" 0 "" payload_refused 40 <<< 'null'
check "a payload missing .labels is refused" 0 "" \
payload_refused 40 <<< '{"number":40}'
check "a payload whose .labels is not an array is refused" 0 "" \
payload_refused 40 <<< '{"number":40,"labels":"ready"}'
check "a payload about a different issue is refused" 0 "" \
payload_refused 40 <<< '{"number":41,"labels":[]}'
check "a payload that is not JSON at all is refused" 0 "" \
payload_refused 40 <<< 'not json'
check "an empty payload is refused" 0 "" payload_refused 40 </dev/null
# The reason line's shape (#101 D3/D4), reachable from this surface too — it
# is one implementation in lib/read.sh, not a second spelling.
check "empty stderr is reported as its own fact" 0 "no error output" \
read_failure_reason ""
check "the captured 504 renders verbatim on one line" 0 \
" $GH_STUB_STDERR " read_failure_reason " $GH_STUB_STDERR "
long_stderr = " $( read_failure_reason " $( printf 'e%.0s' { 1..400} ) " ) "
check "400 chars of stderr truncate to 300 plus an ellipsis" 0 "" \
test " $long_stderr " = " $( printf 'e%.0s' { 1..300} ) … "
# The D6 tail: silent on a whole pass, and naming both count and numbers on a
# partial one.
check "a whole pass adds no tail line" 0 "" test -z " $( skipped_tail 0 "" ) "
check "one skipped issue is named in the singular" 0 \
"1 issue skipped this pass on an unreadable fact: #12" skipped_tail 1 "#12"
check "several skipped issues are all named" 0 \
"2 issues skipped this pass on unreadable facts: #12 #40" \
skipped_tail 2 "#12 #40"
# -- the destroyed claim: a 504 on the comments read of a live claim ---------
# created_at long ago, a comment seconds old, and the comments read fails. The
# swallowed read dated the issue by created_at and reclaimed it, unassigning
# the builder under a comment asserting 48 hours of silence.
jq -n --arg at " $( iso_at $(( INOW - 10 )) ) " \
'[{"user":{"login":"builder"},"created_at":$at,"html_url":"https://x/live","body":"still on it"}]' \
>" $( cfix 50) "
printf '%s\n' " $GH_STUB_ERROR_BODY " >" $( cfix 50) .http-error "
jq -n --arg at " $( iso_at $(( INOW - 10 * 86400 )) ) " \
'[{"event":"assigned","created_at":$at}]' >" $( tfix 50) "
claim_edits_before = " $( wc -l <" $TMP /issue-edits " ) "
check "a 504 on the comments read skips the issue instead of grading its age" \
3 " #50: skipped this pass — could not read its activity history: $GH_STUB_STDERR " \
issue_probe 50 claimed 1
check "...so the live claim is not reclaimed" 1 "" \
grep -q 'stale claim reclaimed -> ready' <<< " $( issue_probe 50 claimed 1) "
# shellcheck disable=SC2016 # positional parameters belong to bash -c
check "...no unassign, no label swap, and no reclaim comment" 0 "" \
bash -c 'test "$1" -eq "$(wc -l <"$2")" && test ! -f "$3"' _ \
" $claim_edits_before " " $TMP /issue-edits " " $TMP /posted-50 "
2026-08-03 23:38:53 +00:00
# -- the quiet diagnostic: a 504 on a post-merge activity read (#254) --------
# The guarded read is unconditional at the top of the branch, so a
# `post-merge` issue can be skipped where before this change it never could —
# and the assigned flag, which needed no read at all, goes quiet with it.
# That is #247 D1's direction (a whole pass or none of it, never a verdict
# derived from a read that did not answer) and the trade `claimed`, `blocked`
# and `needs-ruling` already make. It is still a new way for that diagnostic
# to fall silent, so it is pinned here rather than left to inspection.
jq -n --arg at " $( iso_at $(( INOW - 10 * 86400 )) ) " \
'[{"user":{"login":"triage-one"},"created_at":$at,"html_url":"https://x/c94","body":"evidence pending"}]' \
>" $( cfix 94) "
printf '%s\n' " $GH_STUB_ERROR_BODY " >" $( cfix 94) .http-error "
post_merge_skip_edits = " $( wc -l <" $TMP /issue-edits " ) "
check "a 504 on a post-merge activity read skips the issue" \
3 " #94: skipped this pass — could not read its activity history: $GH_STUB_STDERR " \
issue_probe 94 post-merge 1
check "...so neither the nudge nor the assigned flag speaks" 1 "" test -f " $TMP /posted-94 "
# shellcheck disable=SC2016 # positional parameters belong to bash -c
check "...and the skipped pass edits nothing" 0 "" \
bash -c 'test "$1" -eq "$(wc -l <"$2")"' _ \
" $post_merge_skip_edits " " $TMP /issue-edits "
2026-08-03 18:06:13 +00:00
# -- the suppressed comment: a 504 on the marker read -----------------------
# The marker is on the issue. Read as "no marker", a failed read re-posts the
# comment the marker exists to suppress — every sweep, forever.
jq -n --arg b '<!-- issueflow:blocked-unparseable -->' \
--arg at " $( iso_at $(( INOW - 3600 )) ) " \
'[{"user":{"login":"sweep-bot"},"created_at":$at,"html_url":"https://x/m","body":$b}]' \
>" $( cfix 51) "
printf '%s\n' " $GH_STUB_ERROR_BODY " >" $( cfix 51) .http-error "
check "a 504 on the marker read skips rather than reading it as no marker" \
3 " #51: skipped this pass — could not read its comments: $GH_STUB_STDERR " \
issue_probe 51 blocked 1 false "" "no parseable declaration here"
check "...so no duplicate comment is posted" 1 "" test -f " $TMP /posted-51 "
# -- a deliberate skip is counted; a genuine crash is still named (D4) -------
printf '%s\n' '{"number":60,"labels":[{"name":"ready"}],"assignees":[]}' \
>" $TMP /repos_owner_repo_issues_60.json "
printf '%s\n' '{"number":61,"labels":[{"name":"ready"}],"assignees":[]}' \
>" $TMP /repos_owner_repo_issues_61.json "
printf '%s\n' " $GH_STUB_ERROR_BODY " >" $TMP /repos_owner_repo_issues_61.json.http-error "
pass_probe( ) { # $1 issue; $2 non-empty makes reconcile_issue crash
(
REPO = owner/repo
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
# shellcheck disable=SC2317 # reached through the forge backend, not called directly (#188)
2026-08-03 18:06:13 +00:00
gh( ) { issue_stub_gh " $@ " ; }
[ -z " ${ 2 :- } " ] || reconcile_issue( ) { return 9; }
SKIPPED_COUNT = 0
SKIPPED_ISSUES = ""
reconcile_issue_pass " $1 "
printf 'rc=%s count=%s issues=%s\n' " $? " " $SKIPPED_COUNT " " $SKIPPED_ISSUES "
)
}
check "a genuine non-read crash still names the failure byte-identically" 0 \
"issueflow: #60: reconcile failed — continuing with the remaining issues" \
pass_probe 60 crash
check "...and the pass still returns 0, so the loop reaches the next issue" 0 \
"rc=0" pass_probe 60 crash
check "...and a crash is not counted as a skip" 0 "count=0" pass_probe 60 crash
check "a skipped issue is counted and named" 0 "count=1 issues=#61" pass_probe 61
check "...and is not also reported as a crash" 1 "" \
grep -q 'reconcile failed' <<< " $( pass_probe 61) "
check "...leaving the loop free to continue" 0 "rc=0" pass_probe 61
2026-07-23 20:16:20 +00:00
# ---------------------------------------------------------------------------
# The arrival path, executed the way the action executes it (#91): four
# triage-authored mints died silently because the stand-down `return`s in
# reconcile_opened_issue carried the failed test's status into `set -e`. A
# sourced test takes the `set -u`-only branch and is blind to that class of
# bug by construction, so these run the script as a subprocess behind a
# PATH-stubbed gh — the house pattern from test/release-chain.test.sh.
# ---------------------------------------------------------------------------
ARRIVAL = " $TMP /arrival "
mkdir -p " $ARRIVAL /stub " " $ARRIVAL /fixtures "
printf 'triage-actors=triage-one triage-two\n' >" $ARRIVAL /labels.conf "
cat >" $ARRIVAL /stub/gh " <<'EOF'
#!/usr/bin/env bash
# Endpoints map to files under $GH_FIXTURES ('/?&=' -> '_'); an absent file
# answers an empty list, a .error sentinel fails the call like a dead API.
if [ " $1 " = api ] ; then
shift
2026-08-03 15:48:25 +00:00
endpoint = "" jqexpr = "" query = ""
2026-07-23 20:16:20 +00:00
while [ $# -gt 0 ] ; do
case " $1 " in
--jq) jqexpr = " $2 " ; shift ; ;
2026-08-03 15:48:25 +00:00
-f| -F)
case " $2 " in query = *) query = " ${ 2 #query= } " ; ; esac
shift ; ;
2026-07-23 20:16:20 +00:00
-*) ; ;
*) [ -n " $endpoint " ] || endpoint = " $1 " ; ;
esac
shift
done
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
# Inlined, not the suite's helper: this stub is a standalone executable on
# PATH and cannot see a shell function from the test process. Strips the
# paging the forge shim injects so fixtures stay keyed on the logical
# endpoint (#188).
endpoint = " $( printf '%s' " $endpoint " | sed -E 's/([?&])(per_page|limit|page)=[0-9]+/\1/g; s/[?&]+$//; s/([?&])&+/\1/g' ) "
2026-07-23 20:16:20 +00:00
file = " $GH_FIXTURES / $( printf '%s' " $endpoint " | tr '/?&=' '____' ) .json "
2026-08-03 15:48:25 +00:00
if [ " $endpoint " = graphql ] ; then
case " $query " in
esac
fi
2026-08-03 18:06:13 +00:00
# `.http-error` is the real 5xx (#247): the response body — GitHub's JSON
# error object — goes to STDOUT, the reason to stderr, and the status is
# non-zero. `.error` is the payload-free failure, which is the safe path.
if [ -f " $file .http-error " ] ; then
2026-08-03 18:08:19 +00:00
if [ -n " $jqexpr " ] ; then
jq -r " $jqexpr " " $file .http-error " 2>/dev/null || true
else
cat " $file .http-error "
fi
2026-08-03 18:06:13 +00:00
printf '%s\n' " ${ GH_STUB_STDERR :- } " >& 2
exit 1
fi
[ ! -f " $file .error " ] || { printf '%s\n' " ${ GH_STUB_STDERR :- } " >& 2; exit 1; }
2026-07-23 20:16:20 +00:00
if [ -f " $file " ] ; then payload = " $( cat " $file " ) " ; else payload = '[]' ; fi
if [ -n " $jqexpr " ] ; then jq -r " $jqexpr " <<< " $payload " ; else printf '%s\n' " $payload " ; fi
exit 0
fi
if [ " $1 " = issue ] ; then printf '%s\n' " $* " >>" $GH_FIXTURES /edits " ; exit 0; fi
echo " gh stub: unexpected call: gh $* " >& 2
exit 97
EOF
chmod +x " $ARRIVAL /stub/gh "
2026-08-02 18:41:03 +00:00
# The two PR gathers were GraphQL until #188; they are REST now, so the
# fixtures are the /pulls list both forges return. Empty by default — the
# merged-Refs case below fills the closed one.
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
printf '[]\n' >" $ARRIVAL /fixtures/repos_owner_repo_pulls_state_open.json "
printf '[]\n' >" $ARRIVAL /fixtures/repos_owner_repo_pulls_state_closed.json "
2026-07-23 20:16:20 +00:00
arrival_fixture( ) { printf '%s\n' " $1 " >" $ARRIVAL /fixtures/repos_owner_repo_issues_91.json " ; }
feat(forge): refuse loudly when the client cannot speak the forge
The preflight half of #188, landed first so it stands alone: the forge is
decided once, before any sweep, and a client that cannot speak it exits
non-zero with a named reason.
Measured against forgejo.heavyduty.builders at 84bb1a4 — two of the three
actions reported SUCCESS having read nothing:
labels-scope exit 0 "no .github/labeler.yml" (the file is HTTP 200)
labels-reconcile exit 0 "reconciled." (zero PRs enumerated)
issueflow-reconcile exit 1 "unexpected end of JSON input"
labels-reconcile's blind-sweep warning (#96) could not fire: it counts
unreadable PRs against a list `gh pr list` never produced, and a process
substitution's failure does not trip set -e, so total stayed 0. Installing
gh makes it worse, silencing the one loud failure.
Detection is measured, not inferred from docs: a real forgejo-runner v6.3.1
job (probe task 278) shows Forgejo populating the whole GITHUB_* namespace,
so GITHUB_ACTIONS proves nothing. GITHUB_API_URL's shape, GITEA_ACTIONS and
GITHUB_SERVER_URL do. The same probe shows the runner image carries neither
gh nor stoke, which is what makes the forgejo backend REST.
Tests declare CEREMONY_FORGE at the forge boundary rather than stubbing gh
and staying silent about the forge — the boundary move term 5 asks for.
Refs #188
2026-08-02 18:29:08 +00:00
# CEREMONY_FORGE=github below, and at the executable-sweep driver further
# down: these fixtures ARE a GitHub board (a gh stub on PATH answering
# /api/v3 shapes), so the suite says so at the forge boundary rather than
# letting main()'s preflight infer a forge from whatever env the CI job
# leaked (#188). Stubbing `gh` and staying silent about the forge is the
# boundary this issue moved.
2026-07-23 20:16:20 +00:00
arrival_run( ) {
: >" $ARRIVAL /fixtures/edits "
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
env PATH = " $ARRIVAL /stub: $PATH " CEREMONY_FORGE = github GH_FIXTURES = " $ARRIVAL /fixtures " \
feat(forge): refuse loudly when the client cannot speak the forge
The preflight half of #188, landed first so it stands alone: the forge is
decided once, before any sweep, and a client that cannot speak it exits
non-zero with a named reason.
Measured against forgejo.heavyduty.builders at 84bb1a4 — two of the three
actions reported SUCCESS having read nothing:
labels-scope exit 0 "no .github/labeler.yml" (the file is HTTP 200)
labels-reconcile exit 0 "reconciled." (zero PRs enumerated)
issueflow-reconcile exit 1 "unexpected end of JSON input"
labels-reconcile's blind-sweep warning (#96) could not fire: it counts
unreadable PRs against a list `gh pr list` never produced, and a process
substitution's failure does not trip set -e, so total stayed 0. Installing
gh makes it worse, silencing the one loud failure.
Detection is measured, not inferred from docs: a real forgejo-runner v6.3.1
job (probe task 278) shows Forgejo populating the whole GITHUB_* namespace,
so GITHUB_ACTIONS proves nothing. GITHUB_API_URL's shape, GITEA_ACTIONS and
GITHUB_SERVER_URL do. The same probe shows the runner image carries neither
gh nor stoke, which is what makes the forgejo backend REST.
Tests declare CEREMONY_FORGE at the forge boundary rather than stubbing gh
and staying silent about the forge — the boundary move term 5 asks for.
Refs #188
2026-08-02 18:29:08 +00:00
CEREMONY_FORGE = github \
2026-07-23 20:16:20 +00:00
REPO = owner/repo LABELS_CONF = " $ARRIVAL /labels.conf " \
EVENT_NAME = issues EVENT_ACTION = opened EVENT_ISSUE = 91 \
bash " $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh "
}
arrival_fixture '{"user":{"login":"triage-one"},"labels":[{"name":"ready"}]}'
triage_out = " $( arrival_run 2>& 1) "
triage_rc = $?
check "a triage-authored arrival exits 0 (#91's four dead mints)" 0 "" \
test " $triage_rc " -eq 0
check "...and its output reaches the sweep" 0 "" \
grep -qF 'issueflow: reconciled.' <<< " $triage_out "
check "...and mints nothing" 1 "" test -s " $ARRIVAL /fixtures/edits "
arrival_fixture '{"user":{"login":"outsider"},"labels":[{"name":"ready"}]}'
outside_out = " $( arrival_run 2>& 1) "
outside_rc = $?
check "an outside-authored arrival exits 0" 0 "" test " $outside_rc " -eq 0
check "...still mints needs-triage" 0 "" \
grep -qF 'needs-triage (opened by outsider)' <<< " $outside_out "
check "...still strips the smuggled queue label" 0 "" \
grep -qxF 'issue edit 91 -R owner/repo --add-label needs-triage --remove-label ready' \
" $ARRIVAL /fixtures/edits "
check "...and the sweep still runs after the mint" 0 "" \
grep -qF 'issueflow: reconciled.' <<< " $outside_out "
arrival_fixture '{"user":{"login":"outsider"},"labels":[],"pull_request":{"url":"x"}}'
pr_out = " $( arrival_run 2>& 1) "
pr_rc = $?
check "a PR arrival exits 0" 0 "" test " $pr_rc " -eq 0
check "...stands down without minting" 1 "" test -s " $ARRIVAL /fixtures/edits "
check "...and the sweep still runs" 0 "" \
grep -qF 'issueflow: reconciled.' <<< " $pr_out "
2026-07-25 00:11:42 +00:00
# The merged-Refs transition must survive the executable's set -e path too.
2026-08-02 18:41:03 +00:00
# Keep this at main() granularity: the PR gather and loop are the code a
# sourced decision probe cannot exercise (#91's lesson).
#
# merged_at is what makes this PR merged rather than merely closed — the
# REST replacement for GraphQL's states: MERGED filter (#188). Both forges
# return the field, and both return null on a closed-unmerged PR.
2026-07-25 00:11:42 +00:00
printf '%s\n' \
2026-08-02 18:41:03 +00:00
'[{"number":400,"body":"Refs #40","merged_at":"2026-07-30T00:00:00Z"},{"number":401,"body":"Refs #40","merged_at":null}]' \
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
>" $ARRIVAL /fixtures/repos_owner_repo_pulls_state_closed.json "
2026-07-25 00:11:42 +00:00
printf '[{"number":40}]\n' \
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
>" $ARRIVAL /fixtures/repos_owner_repo_issues_state_open.json "
2026-07-25 00:11:42 +00:00
jq -n --arg at " $( iso_at " $INOW " ) " \
'{number:40,user:{login:"triage-one"},created_at:$at,body:"- [x] built\n- [ ] verify live label",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \
>" $ARRIVAL /fixtures/repos_owner_repo_issues_40.json "
printf '[]\n' >" $ARRIVAL /fixtures/repos_owner_repo_issues_40_comments.json "
: >" $ARRIVAL /fixtures/edits "
2026-08-03 16:33:22 +00:00
transition_out = " $(
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
env PATH = " $ARRIVAL /stub: $PATH " CEREMONY_FORGE = github GH_FIXTURES = " $ARRIVAL /fixtures " \
2026-08-03 16:33:22 +00:00
REPO = owner/repo LABELS_CONF = " $ARRIVAL /labels.conf " \
bash " $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh " 2>& 1
) "
transition_rc = $?
check "an executable sweep with no linked open PR exits 0" 0 "" \
test " $transition_rc " -eq 0
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
check "...reaches the transition through the REST gather and the issue loop" 0 "" \
2026-08-03 16:33:22 +00:00
grep -qF '#40: merged Refs PR -> post-merge; claim released' <<< " $transition_out "
check "...and performs the release edit from the executable path" 0 "" \
grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \
" $ARRIVAL /fixtures/edits "
printf '%s\n' \
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
'[{"number":401,"body":"Refs #40","draft":false,"merged_at":null}]' \
>" $ARRIVAL /fixtures/repos_owner_repo_pulls_state_open.json "
2026-08-03 16:33:22 +00:00
: >" $ARRIVAL /fixtures/edits "
2026-07-25 00:11:42 +00:00
subprocess_out = " $(
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
env PATH = " $ARRIVAL /stub: $PATH " CEREMONY_FORGE = github GH_FIXTURES = " $ARRIVAL /fixtures " \
feat(forge): refuse loudly when the client cannot speak the forge
The preflight half of #188, landed first so it stands alone: the forge is
decided once, before any sweep, and a client that cannot speak it exits
non-zero with a named reason.
Measured against forgejo.heavyduty.builders at 84bb1a4 — two of the three
actions reported SUCCESS having read nothing:
labels-scope exit 0 "no .github/labeler.yml" (the file is HTTP 200)
labels-reconcile exit 0 "reconciled." (zero PRs enumerated)
issueflow-reconcile exit 1 "unexpected end of JSON input"
labels-reconcile's blind-sweep warning (#96) could not fire: it counts
unreadable PRs against a list `gh pr list` never produced, and a process
substitution's failure does not trip set -e, so total stayed 0. Installing
gh makes it worse, silencing the one loud failure.
Detection is measured, not inferred from docs: a real forgejo-runner v6.3.1
job (probe task 278) shows Forgejo populating the whole GITHUB_* namespace,
so GITHUB_ACTIONS proves nothing. GITHUB_API_URL's shape, GITEA_ACTIONS and
GITHUB_SERVER_URL do. The same probe shows the runner image carries neither
gh nor stoke, which is what makes the forgejo backend REST.
Tests declare CEREMONY_FORGE at the forge boundary rather than stubbing gh
and staying silent about the forge — the boundary move term 5 asks for.
Refs #188
2026-08-02 18:29:08 +00:00
CEREMONY_FORGE = github \
2026-07-25 00:11:42 +00:00
REPO = owner/repo LABELS_CONF = " $ARRIVAL /labels.conf " \
bash " $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh " 2>& 1
) "
subprocess_rc = $?
2026-08-03 15:48:25 +00:00
check "an open Refs-bodied PR suppresses the post-merge transition" 0 "" \
2026-07-25 00:11:42 +00:00
test " $subprocess_rc " -eq 0
2026-08-03 15:48:25 +00:00
check "...leaves the live claim assigned" 1 "" \
2026-07-25 00:11:42 +00:00
grep -qF '#40: merged Refs PR -> post-merge; claim released' <<< " $subprocess_out "
2026-08-03 15:48:25 +00:00
check "...performs no release edit" 1 "" \
2026-07-25 00:11:42 +00:00
grep -qF -- 'issue edit 40 -R owner/repo --remove-assignee builder --remove-label claimed --add-label post-merge' \
" $ARRIVAL /fixtures/edits "
2026-08-02 20:44:58 +00:00
# -- the issue/PR discriminator is not `has("pull_request")` ---------------
# Measured on the two list endpoints, 2026-08-02 (#188):
#
# GitHub plain issues OMIT the key -> 0 of 9 carried it
# Forgejo every entry HAS the key -> 10 of 10, valued null on issues
#
# So `select(has("pull_request") | not)` selected ZERO issues on Forgejo and
# the sweep printed "reconciled." over an untouched board — the blind sweep
# again, one layer in, and invisible because the log looks identical to a
# legitimately empty queue. Caught by rehearsing DRY_RUN against rig's live
# board, not by any unit test. `.pull_request == null` is true for an issue on
# both forges (an absent key reads as null) and false for a PR on both.
disc( ) { jq -e " $1 " >/dev/null 2>& 1 && echo issue || echo pr; }
check "github-shaped issue (key absent) reads as an issue" 0 "issue" \
bash -c 'echo "{\"number\":1}" | jq -e ".pull_request == null" >/dev/null && echo issue || echo pr'
check "forgejo-shaped issue (key present, null) reads as an issue" 0 "issue" \
bash -c 'echo "{\"number\":1,\"pull_request\":null}" | jq -e ".pull_request == null" >/dev/null && echo issue || echo pr'
check "a PR reads as a PR on either shape" 0 "pr" \
bash -c 'echo "{\"number\":1,\"pull_request\":{\"url\":\"x\"}}" | jq -e ".pull_request == null" >/dev/null && echo issue || echo pr'
# The old test, kept as the must-fail: it disagrees with the new one on the
# forgejo shape, which is exactly the bug.
check "the old has() test misreads a forgejo issue as a PR" 0 "pr" \
bash -c 'echo "{\"number\":1,\"pull_request\":null}" | jq -e "has(\"pull_request\") | not" >/dev/null && echo issue || echo pr'
fix(issueflow): the board discriminator is .pull_request == null, not has() (#210)
issueflow-reconcile has been blind on this forge since the 0.6.0 merge landed.
Run 368 — #198's own post-merge acceptance probe — printed:
issueflow: no open issues.
issueflow: reconciled.
over a board of nine.
Every Forgejo entry CARRIES the `pull_request` key, valued null on an issue, so
`select(has("pull_request") | not)` selects zero rows. Measured again today:
#209 (an issue) has the key valued null; #208 and #207 (PRs) have it valued as
objects.
This is mine. #188 fixed exactly this and the file's own comment at :1113
states the rule, with :1121 already using it correctly. Resolving hunk 4 of the
merge I took upstream's board block wholesale and carried the wrong
discriminator into three sites — the gather, the release-body gather, and
reconcile_issue_pass — in the PR whose stated purpose was to stop blind sweeps
reporting success.
Cost while it stood: no issue transitions, no claim reclaims, no nudges, no
board flags — and no `post-merge` transitions, which is why #192 and #198 both
still read `claimed` after their PRs merged, and why #198's own closure
criterion could not complete.
Two guards, because a comment did not hold:
* A GATHER-LEVEL CASE against a Forgejo-shaped fixture — every entry carrying
the key. The existing discriminator cases assert jq expressions in
isolation and passed throughout this regression; they never ran the gather
that uses them, which is precisely how it survived review.
* A SOURCE PIN forbidding has("pull_request") on this surface, so a future
sync cannot reintroduce it 40 lines below the comment forbidding it.
Reverting the board gather reds both. Reverting reconcile_issue_pass reds the
pin.
test/run.sh 28/28 under jq 1.7 and jq 1.6; issueflow 503/503; shellcheck 0.10.0
and actionlint clean.
Refs #210
2026-08-05 14:27:40 +00:00
# ...AND THE GATHER, because the rows above assert jq expressions in isolation
# and passed all the way through #210 — a sweep that saw zero issues on every
# pass and printed `reconciled.` The 0.6.0 merge reintroduced the has() form in
# the board gather; these cases could not see it because they never ran it.
#
# The fixture is FORGEJO-SHAPED: every entry carries `pull_request`, valued
# null on an issue and an object on a PR. On a GitHub-shaped board (key absent
# on issues) both discriminators agree, which is why this needs its own board.
FORGEJO_BOARD = " $TMP /forgejo-board "
mkdir -p " $FORGEJO_BOARD "
cp " $ARRIVAL /labels.conf " " $FORGEJO_BOARD /labels.conf " 2>/dev/null || true
printf '[]\n' >" $FORGEJO_BOARD /repos_owner_repo_pulls_state_open.json "
printf '[]\n' >" $FORGEJO_BOARD /repos_owner_repo_pulls_state_closed.json "
printf '%s\n' \
test(issueflow): each site observable through the real path, not through the expression it contains (#210)
@codex-reviewer-andresmgsl's three, and items 1 and 2 were still open after
bada4ff — his review predates that push, but only item 3 (release bodies) was
actually answered by it.
TRAVERSAL, not a non-empty gather. The board case asserted the board was not
read as empty; #60 was `ready` and therefore produced no observable effect, so
nothing proved reconcile_issue_pass had run over it. #60 now carries NO queue
state, so traversal has a deterministic outcome — needs-triage is minted and
logged — and the row asserts that.
THE SCALAR SITE, through the real path. My first attempt asserted the jq
expression the function contains, which is exactly the shape that let this
regression through: the isolated discriminator rows passed the whole time the
gather was blind. A hand-wired probe around reconcile_issue_pass needed so much
internal setup that it would have been testing my scaffolding, so the same
board harness drives it with one row flipped — object-valued must NOT be
reconciled as an issue, and the sweep must then correctly report the board as
empty OF ISSUES.
Per-site mutation, all three now behavioural rather than pin-only:
revert BOARD_RECORDS -> 4 red
revert release_bodies -> 2 red
revert reconcile_issue_pass -> 3 red
test/run.sh 28/28; issueflow 510/510; shellcheck 0.10.0 clean.
Refs #210
2026-08-05 14:44:31 +00:00
' [ { "number" :60,"pull_request" :null,"labels" :[ ] ,"title" :"an issue with no queue state" } ,
fix(issueflow): the board discriminator is .pull_request == null, not has() (#210)
issueflow-reconcile has been blind on this forge since the 0.6.0 merge landed.
Run 368 — #198's own post-merge acceptance probe — printed:
issueflow: no open issues.
issueflow: reconciled.
over a board of nine.
Every Forgejo entry CARRIES the `pull_request` key, valued null on an issue, so
`select(has("pull_request") | not)` selects zero rows. Measured again today:
#209 (an issue) has the key valued null; #208 and #207 (PRs) have it valued as
objects.
This is mine. #188 fixed exactly this and the file's own comment at :1113
states the rule, with :1121 already using it correctly. Resolving hunk 4 of the
merge I took upstream's board block wholesale and carried the wrong
discriminator into three sites — the gather, the release-body gather, and
reconcile_issue_pass — in the PR whose stated purpose was to stop blind sweeps
reporting success.
Cost while it stood: no issue transitions, no claim reclaims, no nudges, no
board flags — and no `post-merge` transitions, which is why #192 and #198 both
still read `claimed` after their PRs merged, and why #198's own closure
criterion could not complete.
Two guards, because a comment did not hold:
* A GATHER-LEVEL CASE against a Forgejo-shaped fixture — every entry carrying
the key. The existing discriminator cases assert jq expressions in
isolation and passed throughout this regression; they never ran the gather
that uses them, which is precisely how it survived review.
* A SOURCE PIN forbidding has("pull_request") on this surface, so a future
sync cannot reintroduce it 40 lines below the comment forbidding it.
Reverting the board gather reds both. Reverting reconcile_issue_pass reds the
pin.
test/run.sh 28/28 under jq 1.7 and jq 1.6; issueflow 503/503; shellcheck 0.10.0
and actionlint clean.
Refs #210
2026-08-05 14:27:40 +00:00
{ "number" :61,"pull_request" :{ "merged" :false} ,"labels" :[ ] ,"title" :"a pull request" } ] ' \
>" $FORGEJO_BOARD /repos_owner_repo_issues_state_open.json "
jq -n --arg at " $( iso_at " $INOW " ) " \
' { number:60,user:{ login:"triage-one" } ,created_at:$at ,body:"" ,pull_request:null,
test(issueflow): each site observable through the real path, not through the expression it contains (#210)
@codex-reviewer-andresmgsl's three, and items 1 and 2 were still open after
bada4ff — his review predates that push, but only item 3 (release bodies) was
actually answered by it.
TRAVERSAL, not a non-empty gather. The board case asserted the board was not
read as empty; #60 was `ready` and therefore produced no observable effect, so
nothing proved reconcile_issue_pass had run over it. #60 now carries NO queue
state, so traversal has a deterministic outcome — needs-triage is minted and
logged — and the row asserts that.
THE SCALAR SITE, through the real path. My first attempt asserted the jq
expression the function contains, which is exactly the shape that let this
regression through: the isolated discriminator rows passed the whole time the
gather was blind. A hand-wired probe around reconcile_issue_pass needed so much
internal setup that it would have been testing my scaffolding, so the same
board harness drives it with one row flipped — object-valued must NOT be
reconciled as an issue, and the sweep must then correctly report the board as
empty OF ISSUES.
Per-site mutation, all three now behavioural rather than pin-only:
revert BOARD_RECORDS -> 4 red
revert release_bodies -> 2 red
revert reconcile_issue_pass -> 3 red
test/run.sh 28/28; issueflow 510/510; shellcheck 0.10.0 clean.
Refs #210
2026-08-05 14:44:31 +00:00
labels:[ ] ,assignees:[ ] } ' \
fix(issueflow): the board discriminator is .pull_request == null, not has() (#210)
issueflow-reconcile has been blind on this forge since the 0.6.0 merge landed.
Run 368 — #198's own post-merge acceptance probe — printed:
issueflow: no open issues.
issueflow: reconciled.
over a board of nine.
Every Forgejo entry CARRIES the `pull_request` key, valued null on an issue, so
`select(has("pull_request") | not)` selects zero rows. Measured again today:
#209 (an issue) has the key valued null; #208 and #207 (PRs) have it valued as
objects.
This is mine. #188 fixed exactly this and the file's own comment at :1113
states the rule, with :1121 already using it correctly. Resolving hunk 4 of the
merge I took upstream's board block wholesale and carried the wrong
discriminator into three sites — the gather, the release-body gather, and
reconcile_issue_pass — in the PR whose stated purpose was to stop blind sweeps
reporting success.
Cost while it stood: no issue transitions, no claim reclaims, no nudges, no
board flags — and no `post-merge` transitions, which is why #192 and #198 both
still read `claimed` after their PRs merged, and why #198's own closure
criterion could not complete.
Two guards, because a comment did not hold:
* A GATHER-LEVEL CASE against a Forgejo-shaped fixture — every entry carrying
the key. The existing discriminator cases assert jq expressions in
isolation and passed throughout this regression; they never ran the gather
that uses them, which is precisely how it survived review.
* A SOURCE PIN forbidding has("pull_request") on this surface, so a future
sync cannot reintroduce it 40 lines below the comment forbidding it.
Reverting the board gather reds both. Reverting reconcile_issue_pass reds the
pin.
test/run.sh 28/28 under jq 1.7 and jq 1.6; issueflow 503/503; shellcheck 0.10.0
and actionlint clean.
Refs #210
2026-08-05 14:27:40 +00:00
>" $FORGEJO_BOARD /repos_owner_repo_issues_60.json "
printf '[]\n' >" $FORGEJO_BOARD /repos_owner_repo_issues_60_comments.json "
forgejo_board_run( ) {
: >" $FORGEJO_BOARD /edits "
env PATH = " $ARRIVAL /stub: $PATH " CEREMONY_FORGE = github GH_FIXTURES = " $FORGEJO_BOARD " \
ISSUEFLOW_NOW = " $INOW " REPO = owner/repo LABELS_CONF = " $ARRIVAL /labels.conf " \
bash " $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh " 2>& 1
}
fjb_out = " $( forgejo_board_run) "
check "a forgejo-shaped board is NOT read as empty" 1 "" \
grep -qF 'issueflow: no open issues.' <<< " $fjb_out "
check "...the sweep completes over it" 0 "" \
grep -qF 'issueflow: reconciled.' <<< " $fjb_out "
test(issueflow): each site observable through the real path, not through the expression it contains (#210)
@codex-reviewer-andresmgsl's three, and items 1 and 2 were still open after
bada4ff — his review predates that push, but only item 3 (release bodies) was
actually answered by it.
TRAVERSAL, not a non-empty gather. The board case asserted the board was not
read as empty; #60 was `ready` and therefore produced no observable effect, so
nothing proved reconcile_issue_pass had run over it. #60 now carries NO queue
state, so traversal has a deterministic outcome — needs-triage is minted and
logged — and the row asserts that.
THE SCALAR SITE, through the real path. My first attempt asserted the jq
expression the function contains, which is exactly the shape that let this
regression through: the isolated discriminator rows passed the whole time the
gather was blind. A hand-wired probe around reconcile_issue_pass needed so much
internal setup that it would have been testing my scaffolding, so the same
board harness drives it with one row flipped — object-valued must NOT be
reconciled as an issue, and the sweep must then correctly report the board as
empty OF ISSUES.
Per-site mutation, all three now behavioural rather than pin-only:
revert BOARD_RECORDS -> 4 red
revert release_bodies -> 2 red
revert reconcile_issue_pass -> 3 red
test/run.sh 28/28; issueflow 510/510; shellcheck 0.10.0 clean.
Refs #210
2026-08-05 14:44:31 +00:00
# TRAVERSAL, not merely a non-empty gather: the null-valued row has a
# deterministic outcome — no queue state means needs-triage is minted — so this
# proves reconcile_issue_pass actually ran over it, which "the board is not
# empty" does not (@codex-reviewer-andresmgsl, #210 review).
check "...the null-valued row is TRAVERSED, with an observable outcome" 0 "" \
grep -qE '^issueflow: #60: needs-triage' <<< " $fjb_out "
check "...and the object-valued PR row is not reconciled as an issue" 1 "" \
fix(issueflow): the board discriminator is .pull_request == null, not has() (#210)
issueflow-reconcile has been blind on this forge since the 0.6.0 merge landed.
Run 368 — #198's own post-merge acceptance probe — printed:
issueflow: no open issues.
issueflow: reconciled.
over a board of nine.
Every Forgejo entry CARRIES the `pull_request` key, valued null on an issue, so
`select(has("pull_request") | not)` selects zero rows. Measured again today:
#209 (an issue) has the key valued null; #208 and #207 (PRs) have it valued as
objects.
This is mine. #188 fixed exactly this and the file's own comment at :1113
states the rule, with :1121 already using it correctly. Resolving hunk 4 of the
merge I took upstream's board block wholesale and carried the wrong
discriminator into three sites — the gather, the release-body gather, and
reconcile_issue_pass — in the PR whose stated purpose was to stop blind sweeps
reporting success.
Cost while it stood: no issue transitions, no claim reclaims, no nudges, no
board flags — and no `post-merge` transitions, which is why #192 and #198 both
still read `claimed` after their PRs merged, and why #198's own closure
criterion could not complete.
Two guards, because a comment did not hold:
* A GATHER-LEVEL CASE against a Forgejo-shaped fixture — every entry carrying
the key. The existing discriminator cases assert jq expressions in
isolation and passed throughout this regression; they never ran the gather
that uses them, which is precisely how it survived review.
* A SOURCE PIN forbidding has("pull_request") on this surface, so a future
sync cannot reintroduce it 40 lines below the comment forbidding it.
Reverting the board gather reds both. Reverting reconcile_issue_pass reds the
pin.
test/run.sh 28/28 under jq 1.7 and jq 1.6; issueflow 503/503; shellcheck 0.10.0
and actionlint clean.
Refs #210
2026-08-05 14:27:40 +00:00
grep -qE '^issueflow: #61' <<< " $fjb_out "
2026-08-23 01:06:54 +00:00
# release_numbers is the THIRD producer and has its own issue-shape filter. A `release`
test(issueflow): each of the three sites is caught by behaviour, not only by the pin (#210)
@codex-reviewer-andresmgsl's two scope items, applied before the first review
round rather than after.
1. THE GUARD IS COMMENT-AWARE, WITH CONTROLS. It already stripped comments —
it has to, because the #188 warning that explains why has("pull_request") is
wrong contains the string. Without controls that was an untested property,
and the pressure it creates is real: a raw grep would push a builder into
deleting the very warning that prevents recurrence. Two fixtures now prove
it: the explanatory comment is allowed, an executable jq filter is rejected.
2. ALL THREE SITES ARE DRIVEN BY BEHAVIOUR. The pin makes any revert red, but a
pin proves a string is absent, not that each replacement means the intended
thing:
BOARD_RECORDS the forgejo-shaped board is not read as empty
release_bodies an open `release` issue whose gate holds an open
member makes a claimable NON-member draw a window
flag — empty carriers, no flag, so the row
discriminates the site instead of merely reaching it
reconcile_issue_pass the scalar payload: key-present-null is an issue,
object-valued is a PR, key-absent is still an issue
The release_bodies row did NOT discriminate on its first write — it asserted
an issue number that BOARD_RECORDS also produces, so reverting the site left
it green. Caught by mutating each site separately rather than trusting the
suite total.
Mutation, per site: BOARD_RECORDS 3 red, release_bodies 2 red,
reconcile_issue_pass 2 red.
test/run.sh 28/28; issueflow 510/510; shellcheck 0.10.0 clean.
Refs #210
2026-08-05 14:37:37 +00:00
# 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).
printf '%s\n' \
' [ { "number" :60,"pull_request" :null,"labels" :[ { "name" :"ready" } ] ,"title" :"an issue" } ,
2026-08-23 01:06:54 +00:00
{ "number" :62,"pull_request" :null,"labels" :[ { "name" :"release" } ] ,"title" :"Release 9.9.9" ,"body" :"Blocked by #59.\n\n## Members\n- #60" } ,
test(issueflow): each of the three sites is caught by behaviour, not only by the pin (#210)
@codex-reviewer-andresmgsl's two scope items, applied before the first review
round rather than after.
1. THE GUARD IS COMMENT-AWARE, WITH CONTROLS. It already stripped comments —
it has to, because the #188 warning that explains why has("pull_request") is
wrong contains the string. Without controls that was an untested property,
and the pressure it creates is real: a raw grep would push a builder into
deleting the very warning that prevents recurrence. Two fixtures now prove
it: the explanatory comment is allowed, an executable jq filter is rejected.
2. ALL THREE SITES ARE DRIVEN BY BEHAVIOUR. The pin makes any revert red, but a
pin proves a string is absent, not that each replacement means the intended
thing:
BOARD_RECORDS the forgejo-shaped board is not read as empty
release_bodies an open `release` issue whose gate holds an open
member makes a claimable NON-member draw a window
flag — empty carriers, no flag, so the row
discriminates the site instead of merely reaching it
reconcile_issue_pass the scalar payload: key-present-null is an issue,
object-valued is a PR, key-absent is still an issue
The release_bodies row did NOT discriminate on its first write — it asserted
an issue number that BOARD_RECORDS also produces, so reverting the site left
it green. Caught by mutating each site separately rather than trusting the
suite total.
Mutation, per site: BOARD_RECORDS 3 red, release_bodies 2 red,
reconcile_issue_pass 2 red.
test/run.sh 28/28; issueflow 510/510; shellcheck 0.10.0 clean.
Refs #210
2026-08-05 14:37:37 +00:00
{ "number" :63,"pull_request" :null,"labels" :[ { "name" :"ready" } ] ,"title" :"a claimable non-member" } ,
{ "number" :61,"pull_request" :{ "merged" :false} ,"labels" :[ ] ,"title" :"a pull request" } ] ' \
>" $FORGEJO_BOARD /repos_owner_repo_issues_state_open.json "
jq -n --arg at " $( iso_at " $INOW " ) " \
' { number:63,user:{ login:"triage-one" } ,created_at:$at ,body:"" ,pull_request:null,
labels:[ { name:"ready" } ] ,assignees:[ ] } ' \
>" $FORGEJO_BOARD /repos_owner_repo_issues_63.json "
printf '[]\n' >" $FORGEJO_BOARD /repos_owner_repo_issues_63_comments.json "
jq -n --arg at " $( iso_at " $INOW " ) " \
2026-08-23 01:06:54 +00:00
' { number:62,user:{ login:"triage-one" } ,created_at:$at ,body:"Blocked by #59.\n\n## Members\n- #60" ,pull_request:null,
test(issueflow): each of the three sites is caught by behaviour, not only by the pin (#210)
@codex-reviewer-andresmgsl's two scope items, applied before the first review
round rather than after.
1. THE GUARD IS COMMENT-AWARE, WITH CONTROLS. It already stripped comments —
it has to, because the #188 warning that explains why has("pull_request") is
wrong contains the string. Without controls that was an untested property,
and the pressure it creates is real: a raw grep would push a builder into
deleting the very warning that prevents recurrence. Two fixtures now prove
it: the explanatory comment is allowed, an executable jq filter is rejected.
2. ALL THREE SITES ARE DRIVEN BY BEHAVIOUR. The pin makes any revert red, but a
pin proves a string is absent, not that each replacement means the intended
thing:
BOARD_RECORDS the forgejo-shaped board is not read as empty
release_bodies an open `release` issue whose gate holds an open
member makes a claimable NON-member draw a window
flag — empty carriers, no flag, so the row
discriminates the site instead of merely reaching it
reconcile_issue_pass the scalar payload: key-present-null is an issue,
object-valued is a PR, key-absent is still an issue
The release_bodies row did NOT discriminate on its first write — it asserted
an issue number that BOARD_RECORDS also produces, so reverting the site left
it green. Caught by mutating each site separately rather than trusting the
suite total.
Mutation, per site: BOARD_RECORDS 3 red, release_bodies 2 red,
reconcile_issue_pass 2 red.
test/run.sh 28/28; issueflow 510/510; shellcheck 0.10.0 clean.
Refs #210
2026-08-05 14:37:37 +00:00
labels:[ { name:"release" } ] ,assignees:[ ] } ' \
>" $FORGEJO_BOARD /repos_owner_repo_issues_62.json "
printf '[]\n' >" $FORGEJO_BOARD /repos_owner_repo_issues_62_comments.json "
fjb2_out = " $( forgejo_board_run) "
2026-08-23 01:06:54 +00:00
# The observable effect of release_numbers being NON-empty: an open `release`
# issue whose membership record still holds an open member makes every claimable non-member
test(issueflow): each of the three sites is caught by behaviour, not only by the pin (#210)
@codex-reviewer-andresmgsl's two scope items, applied before the first review
round rather than after.
1. THE GUARD IS COMMENT-AWARE, WITH CONTROLS. It already stripped comments —
it has to, because the #188 warning that explains why has("pull_request") is
wrong contains the string. Without controls that was an untested property,
and the pressure it creates is real: a raw grep would push a builder into
deleting the very warning that prevents recurrence. Two fixtures now prove
it: the explanatory comment is allowed, an executable jq filter is rejected.
2. ALL THREE SITES ARE DRIVEN BY BEHAVIOUR. The pin makes any revert red, but a
pin proves a string is absent, not that each replacement means the intended
thing:
BOARD_RECORDS the forgejo-shaped board is not read as empty
release_bodies an open `release` issue whose gate holds an open
member makes a claimable NON-member draw a window
flag — empty carriers, no flag, so the row
discriminates the site instead of merely reaching it
reconcile_issue_pass the scalar payload: key-present-null is an issue,
object-valued is a PR, key-absent is still an issue
The release_bodies row did NOT discriminate on its first write — it asserted
an issue number that BOARD_RECORDS also produces, so reverting the site left
it green. Caught by mutating each site separately rather than trusting the
suite total.
Mutation, per site: BOARD_RECORDS 3 red, release_bodies 2 red,
reconcile_issue_pass 2 red.
test/run.sh 28/28; issueflow 510/510; shellcheck 0.10.0 clean.
Refs #210
2026-08-05 14:37:37 +00:00
# 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.
check "a release issue on a forgejo-shaped board reaches the window gather" 0 "" \
grep -qE '#63: window flag' <<< " $fjb2_out "
check "...and the sweep still completes" 0 "" \
grep -qF 'issueflow: reconciled.' <<< " $fjb2_out "
# The THIRD site is reconcile_issue_pass's per-issue payload check. Same key,
# scalar rather than a list: null means issue, an object means PR.
2026-08-05 14:49:01 +00:00
# THE SCALAR SITE, ISOLATED. `BOARD_RECORDS` filters an object-valued row out
# of the LIST before the per-issue guard ever sees it, so a board fixture alone
# cannot prove the scalar stand-down (@codex-reviewer-andresmgsl, #210 review).
#
# The fixture that isolates it: the LIST row is null-valued, so the board
# gather admits it — and the INDIVIDUAL payload the sweep then fetches is
# object-valued. Only reconcile_issue_pass's own guard can stand that down.
test(issueflow): each site observable through the real path, not through the expression it contains (#210)
@codex-reviewer-andresmgsl's three, and items 1 and 2 were still open after
bada4ff — his review predates that push, but only item 3 (release bodies) was
actually answered by it.
TRAVERSAL, not a non-empty gather. The board case asserted the board was not
read as empty; #60 was `ready` and therefore produced no observable effect, so
nothing proved reconcile_issue_pass had run over it. #60 now carries NO queue
state, so traversal has a deterministic outcome — needs-triage is minted and
logged — and the row asserts that.
THE SCALAR SITE, through the real path. My first attempt asserted the jq
expression the function contains, which is exactly the shape that let this
regression through: the isolated discriminator rows passed the whole time the
gather was blind. A hand-wired probe around reconcile_issue_pass needed so much
internal setup that it would have been testing my scaffolding, so the same
board harness drives it with one row flipped — object-valued must NOT be
reconciled as an issue, and the sweep must then correctly report the board as
empty OF ISSUES.
Per-site mutation, all three now behavioural rather than pin-only:
revert BOARD_RECORDS -> 4 red
revert release_bodies -> 2 red
revert reconcile_issue_pass -> 3 red
test/run.sh 28/28; issueflow 510/510; shellcheck 0.10.0 clean.
Refs #210
2026-08-05 14:44:31 +00:00
printf '%s\n' \
2026-08-05 14:49:01 +00:00
'[{"number":65,"pull_request":null,"labels":[],"title":"list says issue, payload says PR"}]' \
test(issueflow): each site observable through the real path, not through the expression it contains (#210)
@codex-reviewer-andresmgsl's three, and items 1 and 2 were still open after
bada4ff — his review predates that push, but only item 3 (release bodies) was
actually answered by it.
TRAVERSAL, not a non-empty gather. The board case asserted the board was not
read as empty; #60 was `ready` and therefore produced no observable effect, so
nothing proved reconcile_issue_pass had run over it. #60 now carries NO queue
state, so traversal has a deterministic outcome — needs-triage is minted and
logged — and the row asserts that.
THE SCALAR SITE, through the real path. My first attempt asserted the jq
expression the function contains, which is exactly the shape that let this
regression through: the isolated discriminator rows passed the whole time the
gather was blind. A hand-wired probe around reconcile_issue_pass needed so much
internal setup that it would have been testing my scaffolding, so the same
board harness drives it with one row flipped — object-valued must NOT be
reconciled as an issue, and the sweep must then correctly report the board as
empty OF ISSUES.
Per-site mutation, all three now behavioural rather than pin-only:
revert BOARD_RECORDS -> 4 red
revert release_bodies -> 2 red
revert reconcile_issue_pass -> 3 red
test/run.sh 28/28; issueflow 510/510; shellcheck 0.10.0 clean.
Refs #210
2026-08-05 14:44:31 +00:00
>" $FORGEJO_BOARD /repos_owner_repo_issues_state_open.json "
jq -n --arg at " $( iso_at " $INOW " ) " \
2026-08-05 14:49:01 +00:00
' { number:65,user:{ login:"triage-one" } ,created_at:$at ,body:"" ,
test(issueflow): each site observable through the real path, not through the expression it contains (#210)
@codex-reviewer-andresmgsl's three, and items 1 and 2 were still open after
bada4ff — his review predates that push, but only item 3 (release bodies) was
actually answered by it.
TRAVERSAL, not a non-empty gather. The board case asserted the board was not
read as empty; #60 was `ready` and therefore produced no observable effect, so
nothing proved reconcile_issue_pass had run over it. #60 now carries NO queue
state, so traversal has a deterministic outcome — needs-triage is minted and
logged — and the row asserts that.
THE SCALAR SITE, through the real path. My first attempt asserted the jq
expression the function contains, which is exactly the shape that let this
regression through: the isolated discriminator rows passed the whole time the
gather was blind. A hand-wired probe around reconcile_issue_pass needed so much
internal setup that it would have been testing my scaffolding, so the same
board harness drives it with one row flipped — object-valued must NOT be
reconciled as an issue, and the sweep must then correctly report the board as
empty OF ISSUES.
Per-site mutation, all three now behavioural rather than pin-only:
revert BOARD_RECORDS -> 4 red
revert release_bodies -> 2 red
revert reconcile_issue_pass -> 3 red
test/run.sh 28/28; issueflow 510/510; shellcheck 0.10.0 clean.
Refs #210
2026-08-05 14:44:31 +00:00
pull_request:{ merged:false} ,labels:[ ] ,assignees:[ ] } ' \
2026-08-05 14:49:01 +00:00
>" $FORGEJO_BOARD /repos_owner_repo_issues_65.json "
printf '[]\n' >" $FORGEJO_BOARD /repos_owner_repo_issues_65_comments.json "
test(issueflow): each site observable through the real path, not through the expression it contains (#210)
@codex-reviewer-andresmgsl's three, and items 1 and 2 were still open after
bada4ff — his review predates that push, but only item 3 (release bodies) was
actually answered by it.
TRAVERSAL, not a non-empty gather. The board case asserted the board was not
read as empty; #60 was `ready` and therefore produced no observable effect, so
nothing proved reconcile_issue_pass had run over it. #60 now carries NO queue
state, so traversal has a deterministic outcome — needs-triage is minted and
logged — and the row asserts that.
THE SCALAR SITE, through the real path. My first attempt asserted the jq
expression the function contains, which is exactly the shape that let this
regression through: the isolated discriminator rows passed the whole time the
gather was blind. A hand-wired probe around reconcile_issue_pass needed so much
internal setup that it would have been testing my scaffolding, so the same
board harness drives it with one row flipped — object-valued must NOT be
reconciled as an issue, and the sweep must then correctly report the board as
empty OF ISSUES.
Per-site mutation, all three now behavioural rather than pin-only:
revert BOARD_RECORDS -> 4 red
revert release_bodies -> 2 red
revert reconcile_issue_pass -> 3 red
test/run.sh 28/28; issueflow 510/510; shellcheck 0.10.0 clean.
Refs #210
2026-08-05 14:44:31 +00:00
fjb3_out = " $( forgejo_board_run) "
2026-08-05 14:49:01 +00:00
check "the per-issue guard stands down an object-valued payload" 1 "" \
grep -qE '^issueflow: #65: needs-triage' <<< " $fjb3_out "
check "...and the sweep still completes" 0 "" \
grep -qF 'issueflow: reconciled.' <<< " $fjb3_out "
# The same fixture with a null-valued payload MUST reconcile — otherwise the row
# above would pass on a guard that stands everything down.
jq -n --arg at " $( iso_at " $INOW " ) " \
' { number:65,user:{ login:"triage-one" } ,created_at:$at ,body:"" ,
pull_request:null,labels:[ ] ,assignees:[ ] } ' \
>" $FORGEJO_BOARD /repos_owner_repo_issues_65.json "
fjb4_out = " $( forgejo_board_run) "
check "...while a null-valued payload at the same site reconciles" 0 "" \
grep -qE '^issueflow: #65: needs-triage' <<< " $fjb4_out "
# And the GitHub shape — key absent entirely — is still an issue.
jq -n --arg at " $( iso_at " $INOW " ) " \
'{number:65,user:{login:"triage-one"},created_at:$at,body:"",labels:[],assignees:[]}' \
>" $FORGEJO_BOARD /repos_owner_repo_issues_65.json "
fjb5_out = " $( forgejo_board_run) "
check "...and a github-shaped payload (key absent) reconciles too" 0 "" \
grep -qE '^issueflow: #65: needs-triage' <<< " $fjb5_out "
test(issueflow): each of the three sites is caught by behaviour, not only by the pin (#210)
@codex-reviewer-andresmgsl's two scope items, applied before the first review
round rather than after.
1. THE GUARD IS COMMENT-AWARE, WITH CONTROLS. It already stripped comments —
it has to, because the #188 warning that explains why has("pull_request") is
wrong contains the string. Without controls that was an untested property,
and the pressure it creates is real: a raw grep would push a builder into
deleting the very warning that prevents recurrence. Two fixtures now prove
it: the explanatory comment is allowed, an executable jq filter is rejected.
2. ALL THREE SITES ARE DRIVEN BY BEHAVIOUR. The pin makes any revert red, but a
pin proves a string is absent, not that each replacement means the intended
thing:
BOARD_RECORDS the forgejo-shaped board is not read as empty
release_bodies an open `release` issue whose gate holds an open
member makes a claimable NON-member draw a window
flag — empty carriers, no flag, so the row
discriminates the site instead of merely reaching it
reconcile_issue_pass the scalar payload: key-present-null is an issue,
object-valued is a PR, key-absent is still an issue
The release_bodies row did NOT discriminate on its first write — it asserted
an issue number that BOARD_RECORDS also produces, so reverting the site left
it green. Caught by mutating each site separately rather than trusting the
suite total.
Mutation, per site: BOARD_RECORDS 3 red, release_bodies 2 red,
reconcile_issue_pass 2 red.
test/run.sh 28/28; issueflow 510/510; shellcheck 0.10.0 clean.
Refs #210
2026-08-05 14:37:37 +00:00
fix(issueflow): the board discriminator is .pull_request == null, not has() (#210)
issueflow-reconcile has been blind on this forge since the 0.6.0 merge landed.
Run 368 — #198's own post-merge acceptance probe — printed:
issueflow: no open issues.
issueflow: reconciled.
over a board of nine.
Every Forgejo entry CARRIES the `pull_request` key, valued null on an issue, so
`select(has("pull_request") | not)` selects zero rows. Measured again today:
#209 (an issue) has the key valued null; #208 and #207 (PRs) have it valued as
objects.
This is mine. #188 fixed exactly this and the file's own comment at :1113
states the rule, with :1121 already using it correctly. Resolving hunk 4 of the
merge I took upstream's board block wholesale and carried the wrong
discriminator into three sites — the gather, the release-body gather, and
reconcile_issue_pass — in the PR whose stated purpose was to stop blind sweeps
reporting success.
Cost while it stood: no issue transitions, no claim reclaims, no nudges, no
board flags — and no `post-merge` transitions, which is why #192 and #198 both
still read `claimed` after their PRs merged, and why #198's own closure
criterion could not complete.
Two guards, because a comment did not hold:
* A GATHER-LEVEL CASE against a Forgejo-shaped fixture — every entry carrying
the key. The existing discriminator cases assert jq expressions in
isolation and passed throughout this regression; they never ran the gather
that uses them, which is precisely how it survived review.
* A SOURCE PIN forbidding has("pull_request") on this surface, so a future
sync cannot reintroduce it 40 lines below the comment forbidding it.
Reverting the board gather reds both. Reverting reconcile_issue_pass reds the
pin.
test/run.sh 28/28 under jq 1.7 and jq 1.6; issueflow 503/503; shellcheck 0.10.0
and actionlint clean.
Refs #210
2026-08-05 14:27:40 +00:00
# -- the rule is pinned at the source, because a comment did not hold --------
# `.pull_request == null` is stated in this file's own header AND at
# issueflow-reconcile.sh:1113 — and the merge put `has("pull_request")` back 40
# lines below that comment, in three places. Prose is not a guard (#210).
# In-process, not `bash -c`: a subshell cannot see this file's functions, and a
# pin that silently inspected nothing would be the same defect one level up.
no_has_pull_request( ) {
local hits
hits = " $( sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' \
" $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh " \
| grep -n 'has("pull_request")' ) "
[ -z " $hits " ] || { printf 'executable has("pull_request") at:\n%s\n' " $hits " >& 2; return 1; }
}
check "no executable has(\"pull_request\") survives on this surface" 0 "" \
no_has_pull_request
test(issueflow): each of the three sites is caught by behaviour, not only by the pin (#210)
@codex-reviewer-andresmgsl's two scope items, applied before the first review
round rather than after.
1. THE GUARD IS COMMENT-AWARE, WITH CONTROLS. It already stripped comments —
it has to, because the #188 warning that explains why has("pull_request") is
wrong contains the string. Without controls that was an untested property,
and the pressure it creates is real: a raw grep would push a builder into
deleting the very warning that prevents recurrence. Two fixtures now prove
it: the explanatory comment is allowed, an executable jq filter is rejected.
2. ALL THREE SITES ARE DRIVEN BY BEHAVIOUR. The pin makes any revert red, but a
pin proves a string is absent, not that each replacement means the intended
thing:
BOARD_RECORDS the forgejo-shaped board is not read as empty
release_bodies an open `release` issue whose gate holds an open
member makes a claimable NON-member draw a window
flag — empty carriers, no flag, so the row
discriminates the site instead of merely reaching it
reconcile_issue_pass the scalar payload: key-present-null is an issue,
object-valued is a PR, key-absent is still an issue
The release_bodies row did NOT discriminate on its first write — it asserted
an issue number that BOARD_RECORDS also produces, so reverting the site left
it green. Caught by mutating each site separately rather than trusting the
suite total.
Mutation, per site: BOARD_RECORDS 3 red, release_bodies 2 red,
reconcile_issue_pass 2 red.
test/run.sh 28/28; issueflow 510/510; shellcheck 0.10.0 clean.
Refs #210
2026-08-05 14:37:37 +00:00
# The controls, because this guard MUST tolerate the #188 comment that explains
# why the form is wrong — a raw grep would either fail forever or pressure a
# builder into deleting the very warning that prevents recurrence
# (@codex-reviewer-andresmgsl, #210 review).
DTMP = " $( mktemp -d) " ; trap 'rm -rf "$DTMP"' EXIT
strip_and_find( ) { # $1 = file -> 0 when an EXECUTABLE use survives
sed 's/[[:space:]]#.*$//; s/^[[:space:]]*#.*$//' " $1 " | grep -q 'has("pull_request")'
}
# shellcheck disable=SC2016 # fixture CONTENT: the literal text a scanned file would hold
printf '%s\n' '#!/usr/bin/env bash' \
'# `.pull_request == null`, NOT has("pull_request") | not (#188)' \
'jq -e ".pull_request == null" <<<"$J"' >" $DTMP /prose.sh "
check "the explanatory #188 comment is allowed" 1 "" strip_and_find " $DTMP /prose.sh "
# single-quoted so the fixture holds the LITERAL form the guard looks for
printf '%s\n' '#!/usr/bin/env bash' \
"jq -r '.[] | select(has(\"pull_request\") | not)' <<<\"\$J\"" >" $DTMP /exec.sh "
check "...while an executable jq filter is rejected" 0 "" strip_and_find " $DTMP /exec.sh "
fix(issueflow): the board discriminator is .pull_request == null, not has() (#210)
issueflow-reconcile has been blind on this forge since the 0.6.0 merge landed.
Run 368 — #198's own post-merge acceptance probe — printed:
issueflow: no open issues.
issueflow: reconciled.
over a board of nine.
Every Forgejo entry CARRIES the `pull_request` key, valued null on an issue, so
`select(has("pull_request") | not)` selects zero rows. Measured again today:
#209 (an issue) has the key valued null; #208 and #207 (PRs) have it valued as
objects.
This is mine. #188 fixed exactly this and the file's own comment at :1113
states the rule, with :1121 already using it correctly. Resolving hunk 4 of the
merge I took upstream's board block wholesale and carried the wrong
discriminator into three sites — the gather, the release-body gather, and
reconcile_issue_pass — in the PR whose stated purpose was to stop blind sweeps
reporting success.
Cost while it stood: no issue transitions, no claim reclaims, no nudges, no
board flags — and no `post-merge` transitions, which is why #192 and #198 both
still read `claimed` after their PRs merged, and why #198's own closure
criterion could not complete.
Two guards, because a comment did not hold:
* A GATHER-LEVEL CASE against a Forgejo-shaped fixture — every entry carrying
the key. The existing discriminator cases assert jq expressions in
isolation and passed throughout this regression; they never ran the gather
that uses them, which is precisely how it survived review.
* A SOURCE PIN forbidding has("pull_request") on this surface, so a future
sync cannot reintroduce it 40 lines below the comment forbidding it.
Reverting the board gather reds both. Reverting reconcile_issue_pass reds the
pin.
test/run.sh 28/28 under jq 1.7 and jq 1.6; issueflow 503/503; shellcheck 0.10.0
and actionlint clean.
Refs #210
2026-08-05 14:27:40 +00:00
2026-08-02 18:49:28 +00:00
# -- the OPEN-pull gather, at main() granularity ----------------------------
# The closed/merged half above proves one REST path; this proves the other,
# which is a DIFFERENT pipeline: `.body | @base64` -> base64 -d ->
# closes_references -> OPEN_PR_ISSUES. The 27 parser cases in
# test/closes_references.test.sh cannot reach it — they test the parser, not
# the encoding and wiring around it (#188).
#
# Both directions in ONE sweep, so neither assertion can pass vacuously:
# #50 IS closed by an open PR -> the claim is KEPT, no reclaim edit
# #51 is closed by nothing -> the claim is RECLAIMED
# A break anywhere in the pipeline reclaims #50 too, and the first check
# fails. A break that reclaims nothing fails the second.
#
# `Closes #50` sits on the THIRD line of the body on purpose. jq's @tsv
# escapes a newline to a literal backslash-n, so a line-oriented parser
# reading an @tsv-encoded body sees one line and drops everything after the
# first — with the declaration on line 3, that defect reclaims #50 and this
# case goes red. On line 1 it would pass either way, which is the definition
# of a vacuous test.
printf '%s\n' \
'[{"number":500,"body":"## Summary\nSome prose about the work.\nCloses #50\n","merged_at":null}]' \
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
>" $ARRIVAL /fixtures/repos_owner_repo_pulls_state_open.json "
printf '[]\n' >" $ARRIVAL /fixtures/repos_owner_repo_pulls_state_closed.json "
2026-08-02 18:49:28 +00:00
printf '[{"number":50},{"number":51}]\n' \
feat(forge): port every reconciler call site onto the shim
Term 1 completed. All 52 runtime gh call sites in the three reconcilers and
lib/ruling.sh now go through forge_* verbs; the three remaining matches in
labels-reconcile are prose in comments. lib/facts.sh is deliberately
untouched — it is the release door, and the ruling keeps release.yml out of
this issue.
The CEREMONY_FORGE_CLIENT:-gh wrappers die here, in the same commit as the
sites they described, so the tree is never in a state where the declaration
lies. main() now runs forge_preflight then forge_select "".
Two sites needed judgment rather than substitution:
- labels-scope's write is forge_labels_add, a genuine additive POST on
both backends, NOT forge_issue_edit --add-label. ceremony#128 turns on
that write not being a read-modify-PUT: the labeler action computed
(labels-at-job-start union derived) and PUT the whole set, silently
dropping a label applied while the job ran. Routing it through a generic
edit verb would have quietly reopened that.
- the human-review request is forge_request_reviewer. Contrary to my
earlier reading, POST /pulls/{n}/requested_reviewers DOES exist on
Forgejo — 422 naming the reviewer's access without it, 201 with it. The
earlier 404 was a GET, which the endpoint does not serve, plus a
username that did not exist.
Test churn, all of it the term-5 boundary move:
- the suites select the github backend, so their existing gh() stubs stay
the boundary and keep intercepting;
- stubs strip the paging the shim injects, so fixtures stay keyed on the
logical endpoint (inlined in the PATH stub, which is a standalone
executable and cannot see a shell function);
- fixtures renamed off the per_page suffix for the same reason;
- recorded-mutation assertions now match the verb, not the raw gh line;
- gh() stubs carry SC2317: they are reached through the backend now, so
shellcheck can no longer see the call path.
Refs #188
2026-08-02 19:43:58 +00:00
>" $ARRIVAL /fixtures/repos_owner_repo_issues_state_open.json "
2026-08-02 18:49:28 +00:00
# Both claims are two hours quiet against a ONE-hour stale bound, so the
# reclaim clock has genuinely expired for whichever of them no open PR
# rescues. The clock is injected rather than real: INOW is a fixed epoch in
# 2033, so without ISSUEFLOW_NOW the subprocess reads its own wall clock,
# dates these claims in the future, and both survive on a negative age —
# which is a green test proving nothing.
for n in 50 51; do
jq -n --arg at " $( iso_at $(( INOW - 7200 )) ) " --argjson n " $n " \
'{number:$n,user:{login:"triage-one"},created_at:$at,body:"- [x] built",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \
>" $ARRIVAL /fixtures/repos_owner_repo_issues_ $n .json "
printf '[]\n' >" $ARRIVAL /fixtures/repos_owner_repo_issues_ ${ n } _comments.json "
done
: >" $ARRIVAL /fixtures/edits "
open_pr_out = " $(
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
env PATH = " $ARRIVAL /stub: $PATH " CEREMONY_FORGE = github GH_FIXTURES = " $ARRIVAL /fixtures " \
2026-08-02 18:49:28 +00:00
CEREMONY_FORGE = github ISSUEFLOW_NOW = " $INOW " ISSUEFLOW_STALE_HOURS = 1 \
REPO = owner/repo LABELS_CONF = " $ARRIVAL /labels.conf " \
bash " $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh " 2>& 1
) "
check "the open-pull gather completes" 0 "" \
grep -qF 'issueflow: reconciled.' <<< " $open_pr_out "
check "a claim closed by an open PR survives the base64 round trip" 1 "" \
grep -qE 'issue edit 50 .*--remove-label claimed' " $ARRIVAL /fixtures/edits "
check "...while the claim no open PR closes is reclaimed in the same sweep" 0 "" \
grep -qE 'issue edit 51 .*--remove-label claimed --add-label ready' \
" $ARRIVAL /fixtures/edits "
2026-07-23 20:16:20 +00:00
# D2 preserved: only the deliberate stand-downs changed; a genuine failure on
# the arrival path still kills the run loudly.
: >" $ARRIVAL /fixtures/repos_owner_repo_issues_91.json.error "
err_out = " $( arrival_run 2>& 1) "
err_rc = $?
check "a dead API on the arrival path still fails the run (D2)" 0 "" \
test " $err_rc " -eq 1
check "...and the sweep does not run over a lying arrival" 1 "" \
grep -qF 'issueflow: reconciled.' <<< " $err_out "
2026-08-03 18:06:13 +00:00
# ---------------------------------------------------------------------------
# The whole sweep over an unreadable board (#247), executed. The sourced
# probes above drive one issue's pass; only this path exercises the loop, the
# counting and the tail — and only this path reproduces crew#329's log, which
# ended `issueflow: reconciled.` with rc=0 over a label it should never have
# written. Its own fixture directory: the arrival fixtures above are stateful
# across their cases.
# ---------------------------------------------------------------------------
SWEEP = " $TMP /sweep "
mkdir -p " $SWEEP "
printf '%s\n' \
'{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
>" $SWEEP /repos_owner_repo_pulls_state_open.json "
cp " $SWEEP /repos_owner_repo_pulls_state_open.json " " $SWEEP /repos_owner_repo_pulls_state_closed.json "
2026-08-03 18:06:13 +00:00
# 70: the 504 with a JSON error body on the per-issue read.
printf '%s\n' " $GH_STUB_ERROR_BODY " >" $SWEEP /repos_owner_repo_issues_70.json.http-error "
# 71: healthy, and carrying no queue label — so if the sweep reaches it, it
# writes needs-triage. That write is the evidence the loop continued.
printf '%s\n' \
'{"number":71,"user":{"login":"triage-one"},"labels":[{"name":"enhancement"}],"assignees":[]}' \
>" $SWEEP /repos_owner_repo_issues_71.json "
# 72: HTTP 200 whose body is `null` — exit 0, and the label set empties just
# as it does on the 504. The shape check is the only thing that catches it.
printf 'null\n' >" $SWEEP /repos_owner_repo_issues_72.json "
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
sweep_board( ) { printf '%s\n' " $1 " >" $SWEEP /repos_owner_repo_issues_state_open.json " ; }
2026-08-03 18:06:13 +00:00
sweep_run( ) {
: >" $SWEEP /edits "
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
env PATH = " $ARRIVAL /stub: $PATH " CEREMONY_FORGE = github GH_FIXTURES = " $SWEEP " ISSUEFLOW_NOW = " $INOW " \
2026-08-03 18:06:13 +00:00
REPO = owner/repo LABELS_CONF = " $ARRIVAL /labels.conf " \
bash " $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh " 2>& 1
}
2026-08-04 15:03:25 +00:00
# The board read is a precondition for the whole pass (#257). A failed read
# cannot be inferred from an empty result: its status is the only fact that
# separates an unreadable board from a clean one. Drive both through main(),
# including gh's partial-pagination shape where stdout is non-empty on error.
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
board_fixture = " $SWEEP /repos_owner_repo_issues_state_open.json "
2026-08-04 15:03:25 +00:00
printf '%s\n' " $GH_STUB_ERROR_BODY " >" $board_fixture .http-error "
board_504_out = " $( sweep_run) "
board_504_rc = $?
check "an issue-list 504 aborts the whole pass" 0 "" test " $board_504_rc " -eq 1
check "...names the board read's stderr" 0 \
" issueflow: could not read the issue board: $GH_STUB_STDERR " \
printf '%s\n' " $board_504_out "
check "...writes no issue edit or comment" 1 "" test -s " $SWEEP /edits "
check "...never reports the pass reconciled" 1 "" \
grep -qF 'issueflow: reconciled.' <<< " $board_504_out "
board_silent_out = " $( GH_STUB_STDERR = "" sweep_run) "
board_silent_rc = $?
check "a silent issue-list failure still aborts" 0 "" test " $board_silent_rc " -eq 1
check "...renders the empty stderr as a fact" 0 \
'issueflow: could not read the issue board: no error output' \
printf '%s\n' " $board_silent_out "
check "...also writes nothing" 1 "" test -s " $SWEEP /edits "
printf '[{"number":71}]\n' >" $board_fixture .http-error "
partial_board_out = " $( sweep_run) "
partial_board_rc = $?
check "partial pagination aborts the whole pass" 0 "" \
test " $partial_board_rc " -eq 1
check "...does not reconcile the returned first page" 1 "" \
grep -qF 'issue edit 71' " $SWEEP /edits "
check "...does not report the truncated pass reconciled" 1 "" \
grep -qF 'issueflow: reconciled.' <<< " $partial_board_out "
rm -f " $board_fixture .http-error "
sweep_board '[]'
empty_board_out = " $( sweep_run) "
empty_board_rc = $?
check "a successful empty board stays green" 0 "" test " $empty_board_rc " -eq 0
check "...writes nothing" 1 "" test -s " $SWEEP /edits "
check "...names the empty-board outcome" 0 'issueflow: no open issues.' \
printf '%s\n' " $empty_board_out "
check "...still ends with byte-identical reconciled." 0 \
'issueflow: reconciled.' printf '%s\n' " $( tail -n1 <<< " $empty_board_out " ) "
2026-08-03 18:06:13 +00:00
sweep_board '[{"number":70},{"number":71}]'
sweep_out = " $( sweep_run) "
sweep_rc = $?
check "an unreadable issue does not red the sweep (D7)" 0 "" test " $sweep_rc " -eq 0
check "the 504's JSON error body is skipped, with the reason named" 0 \
" issueflow: #70: skipped this pass — could not read the issue: $GH_STUB_STDERR " \
printf '%s\n' " $sweep_out "
check "...and crew#329's label is never written" 1 "" \
grep -qF '#70: needs-triage (no queue state)' <<< " $sweep_out "
check "...nor any edit at all on the unreadable issue" 1 "" \
grep -qF 'issue edit 70' " $SWEEP /edits "
check "...while the readable issue beside it is reconciled as before" 0 "" \
grep -qxF 'issue edit 71 -R owner/repo --add-label needs-triage' " $SWEEP /edits "
check "...and the partial pass names its count and its issue" 0 \
'issueflow: 1 issue skipped this pass on an unreadable fact: #70' \
printf '%s\n' " $sweep_out "
check "...after a byte-identical reconciled. line" 0 "" \
grep -qxF 'issueflow: reconciled.' <<< " $sweep_out "
sweep_board '[{"number":72}]'
null_out = " $( sweep_run) "
null_rc = $?
check "an HTTP 200 whose body is null exits 0 and writes nothing" 0 "" \
test " $null_rc " -eq 0
check "...because the shape check refuses it, on its own line" 0 \
'issueflow: #72: skipped this pass — the issue read answered a payload that is not issue #72 carrying a label array' \
printf '%s\n' " $null_out "
check "...so no label is derived from an empty label set" 1 "" \
grep -qF 'issue edit 72' " $SWEEP /edits "
check "...and the tail names it too" 0 \
'issueflow: 1 issue skipped this pass on an unreadable fact: #72' \
printf '%s\n' " $null_out "
sweep_board '[{"number":70},{"number":72}]'
both_out = " $( sweep_run) "
check "two skipped issues are both named, in the plural" 0 \
'issueflow: 2 issues skipped this pass on unreadable facts: #70 #72' \
printf '%s\n' " $both_out "
sweep_board '[{"number":71}]'
whole_out = " $( sweep_run) "
whole_rc = $?
check "a whole pass still exits 0" 0 "" test " $whole_rc " -eq 0
check "...ends on the byte-identical reconciled. line, with no tail after it" 0 \
"issueflow: reconciled." printf '%s\n' " $( tail -n1 <<< " $whole_out " ) "
check "...and says nothing about skipping" 1 "" \
grep -q 'skipped this pass' <<< " $whole_out "
fix(issueflow): a per-issue pass commits its whole effect, or none of it
The per-read guards closed the reported class — a failed read never reaches
a decision function — and left one layer standing. A pass could mutate and
only THEN reach a guarded read, fail it, and report the issue as skipped:
`stale` removed, or `needs-triage` minted, under a log line saying the
sweep had touched nothing. That is the same false report #247 exists to
close, told from the other end, and the panel reproduced it on four
separate compositions.
Fixed as the ordering invariant rather than per site. Inside
reconcile_issue_pass's subshell, run() and log() stage their effects, and
commit_staged_effects replays them in order once the pass has completed.
skip_issue emits its own line directly and exits, so the buffer dies with
the subshell. A skip therefore implies zero `gh issue edit`, zero
`gh issue comment`, and no log line about a mutation that never landed —
for compositions nobody has written yet, because reconcile_issue has no way
to mutate directly. Reads stay where they are: they may happen anywhere,
since nothing lands until the end.
Stated per site it would hold until the next composition. Two consequences
worth naming: reconcile_ruling is covered without touching lib/ruling.sh,
because it posts through the sourcing script's run()/log() — the PR surface
keeps its own and is unaffected; and a genuine crash mid-pass now also
lands nothing, where before it left the earlier mutations applied. D4's
handler string, D6's tail and D7's exit 0 are all unchanged, and the
healthy path is byte-identical: every staged write commits under the same
`>/dev/null` its call site already applied.
Refs #247
2026-08-03 18:55:52 +00:00
# ---------------------------------------------------------------------------
# The ordering invariant (#247 D1): a skip implies ZERO writes, wherever in
# the pass the failed read lives. Round 1 measured what the per-read guards
# alone left standing — a pass could remove `stale`, or mint `needs-triage`,
# and only then reach a guarded read, fail it, and report the issue as
# skipped. The sweep said it had touched nothing while a write had landed:
# the same false report #247 exists to close, one layer along.
#
# Every composition is driven TWICE against identical fixtures, differing
# only in whether the late read answers. The healthy run is the control — it
# proves the mutation is genuinely on this path, so the failing run's "no
# edit" is a fact about the guard and not about a branch that never fired.
# Executed through the sweep, because staging is a property of the pass.
# ---------------------------------------------------------------------------
ORDER = " $TMP /order "
mkdir -p " $ORDER "
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
cp " $SWEEP /repos_owner_repo_pulls_state_open.json " " $SWEEP /repos_owner_repo_pulls_state_closed.json " " $ORDER / "
order_board( ) { printf '%s\n' " $1 " >" $ORDER /repos_owner_repo_issues_state_open.json " ; }
fix(issueflow): a per-issue pass commits its whole effect, or none of it
The per-read guards closed the reported class — a failed read never reaches
a decision function — and left one layer standing. A pass could mutate and
only THEN reach a guarded read, fail it, and report the issue as skipped:
`stale` removed, or `needs-triage` minted, under a log line saying the
sweep had touched nothing. That is the same false report #247 exists to
close, told from the other end, and the panel reproduced it on four
separate compositions.
Fixed as the ordering invariant rather than per site. Inside
reconcile_issue_pass's subshell, run() and log() stage their effects, and
commit_staged_effects replays them in order once the pass has completed.
skip_issue emits its own line directly and exits, so the buffer dies with
the subshell. A skip therefore implies zero `gh issue edit`, zero
`gh issue comment`, and no log line about a mutation that never landed —
for compositions nobody has written yet, because reconcile_issue has no way
to mutate directly. Reads stay where they are: they may happen anywhere,
since nothing lands until the end.
Stated per site it would hold until the next composition. Two consequences
worth naming: reconcile_ruling is covered without touching lib/ruling.sh,
because it posts through the sourcing script's run()/log() — the PR surface
keeps its own and is unaffected; and a genuine crash mid-pass now also
lands nothing, where before it left the earlier mutations applied. D4's
handler string, D6's tail and D7's exit 0 are all unchanged, and the
healthy path is byte-identical: every staged write commits under the same
`>/dev/null` its call site already applied.
Refs #247
2026-08-03 18:55:52 +00:00
order_fixture( ) { # $1 issue, $2 labels JSON, $3 body
jq -n --argjson n " $1 " --argjson labels " $2 " --arg body " ${ 3 :- } " \
--arg at " $( iso_at $(( INOW - 10 * 86400 )) ) " \
' { number: $n , created_at: $at , user: { login: "triage-one" } ,
labels: $labels , assignees: [ ] , body: $body } ' \
>" $ORDER /repos_owner_repo_issues_ $1 .json "
}
order_run( ) {
: >" $ORDER /edits "
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
env PATH = " $ARRIVAL /stub: $PATH " CEREMONY_FORGE = github GH_FIXTURES = " $ORDER " ISSUEFLOW_NOW = " $INOW " \
fix(issueflow): a per-issue pass commits its whole effect, or none of it
The per-read guards closed the reported class — a failed read never reaches
a decision function — and left one layer standing. A pass could mutate and
only THEN reach a guarded read, fail it, and report the issue as skipped:
`stale` removed, or `needs-triage` minted, under a log line saying the
sweep had touched nothing. That is the same false report #247 exists to
close, told from the other end, and the panel reproduced it on four
separate compositions.
Fixed as the ordering invariant rather than per site. Inside
reconcile_issue_pass's subshell, run() and log() stage their effects, and
commit_staged_effects replays them in order once the pass has completed.
skip_issue emits its own line directly and exits, so the buffer dies with
the subshell. A skip therefore implies zero `gh issue edit`, zero
`gh issue comment`, and no log line about a mutation that never landed —
for compositions nobody has written yet, because reconcile_issue has no way
to mutate directly. Reads stay where they are: they may happen anywhere,
since nothing lands until the end.
Stated per site it would hold until the next composition. Two consequences
worth naming: reconcile_ruling is covered without touching lib/ruling.sh,
because it posts through the sourcing script's run()/log() — the PR surface
keeps its own and is unaffected; and a genuine crash mid-pass now also
lands nothing, where before it left the earlier mutations applied. D4's
handler string, D6's tail and D7's exit 0 are all unchanged, and the
healthy path is byte-identical: every staged write commits under the same
`>/dev/null` its call site already applied.
Refs #247
2026-08-03 18:55:52 +00:00
REPO = owner/repo LABELS_CONF = " $ARRIVAL /labels.conf " \
bash " $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh " 2>& 1
}
# The late read fails, or answers. `guarded_read` is what turns either into a
# skip, so which endpoint carries the sentinel is what picks the composition.
order_breaks( ) { printf '%s\n' " $GH_STUB_ERROR_BODY " >" $ORDER /repos_owner_repo_issues_ $1 _ $2 .json.http-error " ; }
order_heals( ) { rm -f " $ORDER /repos_owner_repo_issues_ $1 _ $2 .json.http-error " ; }
# A skip must leave no trace of the staged effect: not the write, and not the
# log line that would have announced it. Both halves, because a landed write
# under a "skipped" line and a "reconciled" line over no write are the same
# lie told from opposite ends.
order_wrote( ) { grep -qF " issue $2 $1 " " $ORDER /edits " ; }
# -- 1. unstale, then a failed activity read (the round's first composition) -
# `needs-ruling` heals an applied `stale` off before the tail reads the
# issue's activity. The read is two statements later; the write is already
# gone.
order_fixture 80 '[{"name":"ready"},{"name":"needs-ruling"},{"name":"stale"}]'
order_board '[{"number":80}]'
order_heals 80 comments
healthy_unstale = " $( order_run) "
check "the control: a healthy pass really does unstale a pending ruling" 0 "" \
order_wrote 80 edit
check "...and says so" 0 "issueflow: #80: unstale (a ruling is pending)" \
printf '%s\n' " $healthy_unstale "
order_breaks 80 comments
broken_unstale = " $( order_run) "
check "a failed activity read skips the unstale composition" 0 \
" issueflow: #80: skipped this pass — could not read its activity history: $GH_STUB_STDERR " \
printf '%s\n' " $broken_unstale "
check "...and the stale label is still on the issue" 1 "" order_wrote 80 edit
check "...and nothing claims it came off" 1 "" \
grep -qF 'unstale (a ruling is pending)' <<< " $broken_unstale "
# -- 2. ADD_NEEDS_TRIAGE, then a failed activity read (the second) -----------
# The mint falls through — unlike FLAG_CONFLICT, which returns — into the
# same tail. crew#329's own label, written and then disowned by the log.
order_fixture 81 '[{"name":"enhancement"},{"name":"needs-ruling"}]'
order_board '[{"number":81}]'
order_heals 81 comments
healthy_mint = " $( order_run) "
check "the control: a healthy pass really does mint needs-triage here" 0 "" \
order_wrote 81 edit
check "...and says so" 0 "issueflow: #81: needs-triage (no queue state)" \
printf '%s\n' " $healthy_mint "
order_breaks 81 comments
broken_mint = " $( order_run) "
check "a failed activity read skips the needs-triage composition" 0 \
" issueflow: #81: skipped this pass — could not read its activity history: $GH_STUB_STDERR " \
printf '%s\n' " $broken_mint "
check "...and crew#329's label is not written on the way out" 1 "" \
order_wrote 81 edit
check "...and nothing claims it was" 1 "" \
grep -qF '#81: needs-triage (no queue state)' <<< " $broken_mint "
2026-08-04 16:16:19 +00:00
# -- 3. the blockers->ready flip, then a failed COMMENTS read ----------------
# The read that guards this branch is the marker check ahead of the parse
# echo: a broken comments endpoint skips there, before the echo or the flip
# is staged. The timeline is no longer an input on this path at all (#284
# D1 — the ruling clock reads comments alone), so the unreadable-timeline
# case moved from "skips everything" to its own pin below.
fix(issueflow): a per-issue pass commits its whole effect, or none of it
The per-read guards closed the reported class — a failed read never reaches
a decision function — and left one layer standing. A pass could mutate and
only THEN reach a guarded read, fail it, and report the issue as skipped:
`stale` removed, or `needs-triage` minted, under a log line saying the
sweep had touched nothing. That is the same false report #247 exists to
close, told from the other end, and the panel reproduced it on four
separate compositions.
Fixed as the ordering invariant rather than per site. Inside
reconcile_issue_pass's subshell, run() and log() stage their effects, and
commit_staged_effects replays them in order once the pass has completed.
skip_issue emits its own line directly and exits, so the buffer dies with
the subshell. A skip therefore implies zero `gh issue edit`, zero
`gh issue comment`, and no log line about a mutation that never landed —
for compositions nobody has written yet, because reconcile_issue has no way
to mutate directly. Reads stay where they are: they may happen anywhere,
since nothing lands until the end.
Stated per site it would hold until the next composition. Two consequences
worth naming: reconcile_ruling is covered without touching lib/ruling.sh,
because it posts through the sourcing script's run()/log() — the PR surface
keeps its own and is unaffected; and a genuine crash mid-pass now also
lands nothing, where before it left the earlier mutations applied. D4's
handler string, D6's tail and D7's exit 0 are all unchanged, and the
healthy path is byte-identical: every staged write commits under the same
`>/dev/null` its call site already applied.
Refs #247
2026-08-03 18:55:52 +00:00
printf '%s\n' '{"number":82,"state":"closed"}' \
>" $ORDER /repos_owner_repo_issues_82.json "
order_fixture 83 '[{"name":"blocked"},{"name":"needs-ruling"}]' 'Blocked by #82.'
order_board '[{"number":83}]'
2026-08-04 16:16:19 +00:00
order_heals 83 comments
fix(issueflow): a per-issue pass commits its whole effect, or none of it
The per-read guards closed the reported class — a failed read never reaches
a decision function — and left one layer standing. A pass could mutate and
only THEN reach a guarded read, fail it, and report the issue as skipped:
`stale` removed, or `needs-triage` minted, under a log line saying the
sweep had touched nothing. That is the same false report #247 exists to
close, told from the other end, and the panel reproduced it on four
separate compositions.
Fixed as the ordering invariant rather than per site. Inside
reconcile_issue_pass's subshell, run() and log() stage their effects, and
commit_staged_effects replays them in order once the pass has completed.
skip_issue emits its own line directly and exits, so the buffer dies with
the subshell. A skip therefore implies zero `gh issue edit`, zero
`gh issue comment`, and no log line about a mutation that never landed —
for compositions nobody has written yet, because reconcile_issue has no way
to mutate directly. Reads stay where they are: they may happen anywhere,
since nothing lands until the end.
Stated per site it would hold until the next composition. Two consequences
worth naming: reconcile_ruling is covered without touching lib/ruling.sh,
because it posts through the sourcing script's run()/log() — the PR surface
keeps its own and is unaffected; and a genuine crash mid-pass now also
lands nothing, where before it left the earlier mutations applied. D4's
handler string, D6's tail and D7's exit 0 are all unchanged, and the
healthy path is byte-identical: every staged write commits under the same
`>/dev/null` its call site already applied.
Refs #247
2026-08-03 18:55:52 +00:00
order_heals 83 timeline
healthy_flip = " $( order_run) "
check "the control: a healthy pass really does flip cleared blockers to ready" 0 \
"issueflow: #83: blockers closed -> ready" printf '%s\n' " $healthy_flip "
check "...writing the label edit" 0 "" order_wrote 83 edit
check "...and posting the blockers-cleared comment" 0 "" order_wrote 83 comment
2026-08-04 16:16:19 +00:00
order_breaks 83 comments
fix(issueflow): a per-issue pass commits its whole effect, or none of it
The per-read guards closed the reported class — a failed read never reaches
a decision function — and left one layer standing. A pass could mutate and
only THEN reach a guarded read, fail it, and report the issue as skipped:
`stale` removed, or `needs-triage` minted, under a log line saying the
sweep had touched nothing. That is the same false report #247 exists to
close, told from the other end, and the panel reproduced it on four
separate compositions.
Fixed as the ordering invariant rather than per site. Inside
reconcile_issue_pass's subshell, run() and log() stage their effects, and
commit_staged_effects replays them in order once the pass has completed.
skip_issue emits its own line directly and exits, so the buffer dies with
the subshell. A skip therefore implies zero `gh issue edit`, zero
`gh issue comment`, and no log line about a mutation that never landed —
for compositions nobody has written yet, because reconcile_issue has no way
to mutate directly. Reads stay where they are: they may happen anywhere,
since nothing lands until the end.
Stated per site it would hold until the next composition. Two consequences
worth naming: reconcile_ruling is covered without touching lib/ruling.sh,
because it posts through the sourcing script's run()/log() — the PR surface
keeps its own and is unaffected; and a genuine crash mid-pass now also
lands nothing, where before it left the earlier mutations applied. D4's
handler string, D6's tail and D7's exit 0 are all unchanged, and the
healthy path is byte-identical: every staged write commits under the same
`>/dev/null` its call site already applied.
Refs #247
2026-08-03 18:55:52 +00:00
broken_flip = " $( order_run) "
2026-08-04 16:16:19 +00:00
check "a failed comments read skips the blockers->ready composition" 0 \
" issueflow: #83: skipped this pass — could not read its comments: $GH_STUB_STDERR " \
fix(issueflow): a per-issue pass commits its whole effect, or none of it
The per-read guards closed the reported class — a failed read never reaches
a decision function — and left one layer standing. A pass could mutate and
only THEN reach a guarded read, fail it, and report the issue as skipped:
`stale` removed, or `needs-triage` minted, under a log line saying the
sweep had touched nothing. That is the same false report #247 exists to
close, told from the other end, and the panel reproduced it on four
separate compositions.
Fixed as the ordering invariant rather than per site. Inside
reconcile_issue_pass's subshell, run() and log() stage their effects, and
commit_staged_effects replays them in order once the pass has completed.
skip_issue emits its own line directly and exits, so the buffer dies with
the subshell. A skip therefore implies zero `gh issue edit`, zero
`gh issue comment`, and no log line about a mutation that never landed —
for compositions nobody has written yet, because reconcile_issue has no way
to mutate directly. Reads stay where they are: they may happen anywhere,
since nothing lands until the end.
Stated per site it would hold until the next composition. Two consequences
worth naming: reconcile_ruling is covered without touching lib/ruling.sh,
because it posts through the sourcing script's run()/log() — the PR surface
keeps its own and is unaffected; and a genuine crash mid-pass now also
lands nothing, where before it left the earlier mutations applied. D4's
handler string, D6's tail and D7's exit 0 are all unchanged, and the
healthy path is byte-identical: every staged write commits under the same
`>/dev/null` its call site already applied.
Refs #247
2026-08-03 18:55:52 +00:00
printf '%s\n' " $broken_flip "
check "...leaving the issue blocked" 1 "" order_wrote 83 edit
check "...with no comment posted about it" 1 "" order_wrote 83 comment
check "...and nothing claiming the flip happened" 1 "" \
grep -qF 'blockers closed -> ready' <<< " $broken_flip "
2026-08-04 16:16:19 +00:00
# The read this path no longer takes cannot skip it (#284): with comments
# healthy and the timeline broken, the flip commits, and only the ruling
# ladder's own soft-failing read goes without — no verdict is invented, and
# no unrelated write is held hostage by an input the clocks stopped reading.
order_heals 83 comments
order_breaks 83 timeline
narrowed_flip = " $( order_run) "
check "a failed timeline read no longer skips the flip" 1 "" \
grep -qF 'skipped this pass' <<< " $narrowed_flip "
check "...the flip commits" 0 "" order_wrote 83 edit
check "...and the ruling ladder says what it could not read" 0 \
"issueflow: #83: ruling timeline unreadable — no verdict invented this pass" \
printf '%s\n' " $narrowed_flip "
# -- 4. a posted nudge, then a failed COMMENTS read -------------------------
# The comment-only half of the class: the epic nudge's own marker check is
# the read that fails, so the nudge is never staged and the skip reports the
# truth. A comment is as much a mutation as a label — it is the thing
# markers exist to make idempotent.
fix(issueflow): a per-issue pass commits its whole effect, or none of it
The per-read guards closed the reported class — a failed read never reaches
a decision function — and left one layer standing. A pass could mutate and
only THEN reach a guarded read, fail it, and report the issue as skipped:
`stale` removed, or `needs-triage` minted, under a log line saying the
sweep had touched nothing. That is the same false report #247 exists to
close, told from the other end, and the panel reproduced it on four
separate compositions.
Fixed as the ordering invariant rather than per site. Inside
reconcile_issue_pass's subshell, run() and log() stage their effects, and
commit_staged_effects replays them in order once the pass has completed.
skip_issue emits its own line directly and exits, so the buffer dies with
the subshell. A skip therefore implies zero `gh issue edit`, zero
`gh issue comment`, and no log line about a mutation that never landed —
for compositions nobody has written yet, because reconcile_issue has no way
to mutate directly. Reads stay where they are: they may happen anywhere,
since nothing lands until the end.
Stated per site it would hold until the next composition. Two consequences
worth naming: reconcile_ruling is covered without touching lib/ruling.sh,
because it posts through the sourcing script's run()/log() — the PR surface
keeps its own and is unaffected; and a genuine crash mid-pass now also
lands nothing, where before it left the earlier mutations applied. D4's
handler string, D6's tail and D7's exit 0 are all unchanged, and the
healthy path is byte-identical: every staged write commits under the same
`>/dev/null` its call site already applied.
Refs #247
2026-08-03 18:55:52 +00:00
order_fixture 84 '[{"name":"epic"},{"name":"needs-ruling"}]' \
' ## Task list
- [ x] #82'
order_board '[{"number":84}]'
2026-08-04 16:16:19 +00:00
order_heals 84 comments
fix(issueflow): a per-issue pass commits its whole effect, or none of it
The per-read guards closed the reported class — a failed read never reaches
a decision function — and left one layer standing. A pass could mutate and
only THEN reach a guarded read, fail it, and report the issue as skipped:
`stale` removed, or `needs-triage` minted, under a log line saying the
sweep had touched nothing. That is the same false report #247 exists to
close, told from the other end, and the panel reproduced it on four
separate compositions.
Fixed as the ordering invariant rather than per site. Inside
reconcile_issue_pass's subshell, run() and log() stage their effects, and
commit_staged_effects replays them in order once the pass has completed.
skip_issue emits its own line directly and exits, so the buffer dies with
the subshell. A skip therefore implies zero `gh issue edit`, zero
`gh issue comment`, and no log line about a mutation that never landed —
for compositions nobody has written yet, because reconcile_issue has no way
to mutate directly. Reads stay where they are: they may happen anywhere,
since nothing lands until the end.
Stated per site it would hold until the next composition. Two consequences
worth naming: reconcile_ruling is covered without touching lib/ruling.sh,
because it posts through the sourcing script's run()/log() — the PR surface
keeps its own and is unaffected; and a genuine crash mid-pass now also
lands nothing, where before it left the earlier mutations applied. D4's
handler string, D6's tail and D7's exit 0 are all unchanged, and the
healthy path is byte-identical: every staged write commits under the same
`>/dev/null` its call site already applied.
Refs #247
2026-08-03 18:55:52 +00:00
order_heals 84 timeline
healthy_nudge = " $( order_run) "
check "the control: a healthy pass really does nudge a completed epic" 0 \
"issueflow: #84: completed epic nudged" printf '%s\n' " $healthy_nudge "
check "...by posting a comment" 0 "" order_wrote 84 comment
2026-08-04 16:16:19 +00:00
order_breaks 84 comments
fix(issueflow): a per-issue pass commits its whole effect, or none of it
The per-read guards closed the reported class — a failed read never reaches
a decision function — and left one layer standing. A pass could mutate and
only THEN reach a guarded read, fail it, and report the issue as skipped:
`stale` removed, or `needs-triage` minted, under a log line saying the
sweep had touched nothing. That is the same false report #247 exists to
close, told from the other end, and the panel reproduced it on four
separate compositions.
Fixed as the ordering invariant rather than per site. Inside
reconcile_issue_pass's subshell, run() and log() stage their effects, and
commit_staged_effects replays them in order once the pass has completed.
skip_issue emits its own line directly and exits, so the buffer dies with
the subshell. A skip therefore implies zero `gh issue edit`, zero
`gh issue comment`, and no log line about a mutation that never landed —
for compositions nobody has written yet, because reconcile_issue has no way
to mutate directly. Reads stay where they are: they may happen anywhere,
since nothing lands until the end.
Stated per site it would hold until the next composition. Two consequences
worth naming: reconcile_ruling is covered without touching lib/ruling.sh,
because it posts through the sourcing script's run()/log() — the PR surface
keeps its own and is unaffected; and a genuine crash mid-pass now also
lands nothing, where before it left the earlier mutations applied. D4's
handler string, D6's tail and D7's exit 0 are all unchanged, and the
healthy path is byte-identical: every staged write commits under the same
`>/dev/null` its call site already applied.
Refs #247
2026-08-03 18:55:52 +00:00
broken_nudge = " $( order_run) "
2026-08-04 16:16:19 +00:00
check "a failed comments read skips the epic-nudge composition" 0 \
" issueflow: #84: skipped this pass — could not read its comments: $GH_STUB_STDERR " \
fix(issueflow): a per-issue pass commits its whole effect, or none of it
The per-read guards closed the reported class — a failed read never reaches
a decision function — and left one layer standing. A pass could mutate and
only THEN reach a guarded read, fail it, and report the issue as skipped:
`stale` removed, or `needs-triage` minted, under a log line saying the
sweep had touched nothing. That is the same false report #247 exists to
close, told from the other end, and the panel reproduced it on four
separate compositions.
Fixed as the ordering invariant rather than per site. Inside
reconcile_issue_pass's subshell, run() and log() stage their effects, and
commit_staged_effects replays them in order once the pass has completed.
skip_issue emits its own line directly and exits, so the buffer dies with
the subshell. A skip therefore implies zero `gh issue edit`, zero
`gh issue comment`, and no log line about a mutation that never landed —
for compositions nobody has written yet, because reconcile_issue has no way
to mutate directly. Reads stay where they are: they may happen anywhere,
since nothing lands until the end.
Stated per site it would hold until the next composition. Two consequences
worth naming: reconcile_ruling is covered without touching lib/ruling.sh,
because it posts through the sourcing script's run()/log() — the PR surface
keeps its own and is unaffected; and a genuine crash mid-pass now also
lands nothing, where before it left the earlier mutations applied. D4's
handler string, D6's tail and D7's exit 0 are all unchanged, and the
healthy path is byte-identical: every staged write commits under the same
`>/dev/null` its call site already applied.
Refs #247
2026-08-03 18:55:52 +00:00
printf '%s\n' " $broken_nudge "
check "...and the nudge comment is never posted" 1 "" order_wrote 84 comment
check "...and nothing claims it was" 1 "" \
grep -qF 'completed epic nudged' <<< " $broken_nudge "
2026-08-04 16:16:19 +00:00
# The narrowed surface again (#284): a broken timeline neither skips nor
# suppresses the nudge; the ruling ladder alone goes without a verdict.
order_heals 84 comments
order_breaks 84 timeline
narrowed_nudge = " $( order_run) "
check "a failed timeline read no longer skips the epic nudge" 1 "" \
grep -qF 'skipped this pass' <<< " $narrowed_nudge "
check "...the nudge commits" 0 "" order_wrote 84 comment
fix(issueflow): a per-issue pass commits its whole effect, or none of it
The per-read guards closed the reported class — a failed read never reaches
a decision function — and left one layer standing. A pass could mutate and
only THEN reach a guarded read, fail it, and report the issue as skipped:
`stale` removed, or `needs-triage` minted, under a log line saying the
sweep had touched nothing. That is the same false report #247 exists to
close, told from the other end, and the panel reproduced it on four
separate compositions.
Fixed as the ordering invariant rather than per site. Inside
reconcile_issue_pass's subshell, run() and log() stage their effects, and
commit_staged_effects replays them in order once the pass has completed.
skip_issue emits its own line directly and exits, so the buffer dies with
the subshell. A skip therefore implies zero `gh issue edit`, zero
`gh issue comment`, and no log line about a mutation that never landed —
for compositions nobody has written yet, because reconcile_issue has no way
to mutate directly. Reads stay where they are: they may happen anywhere,
since nothing lands until the end.
Stated per site it would hold until the next composition. Two consequences
worth naming: reconcile_ruling is covered without touching lib/ruling.sh,
because it posts through the sourcing script's run()/log() — the PR surface
keeps its own and is unaffected; and a genuine crash mid-pass now also
lands nothing, where before it left the earlier mutations applied. D4's
handler string, D6's tail and D7's exit 0 are all unchanged, and the
healthy path is byte-identical: every staged write commits under the same
`>/dev/null` its call site already applied.
Refs #247
2026-08-03 18:55:52 +00:00
# -- the skip is still just a skip: counted, tailed, and green (D4, D6, D7) --
check "a mutation-bearing composition that skips is still not a crash" 1 "" \
grep -qF 'reconcile failed' <<< " $broken_flip "
check "...is still counted in the D6 tail" 0 \
'issueflow: 1 issue skipped this pass on an unreadable fact: #83' \
printf '%s\n' " $broken_flip "
order_board '[{"number":83}]'
order_run >/dev/null
check "...and still leaves the job green (D7)" 0 "" test $? -eq 0
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
# -- the two board flags (#293): the deliverable key, normalized ------------
# The 2026-08-04 miss spelled one deliverable two ways, so exact-prefix
# matching is specified away (D2). These pin the normalization itself.
2026-08-04 18:58:21 +00:00
count_lines( ) { deliverable_keys | grep -c .; }
keys_of( ) { # title on stdin -> its keys, each bracketed so `check` matches exactly
# ANCHORED, because `check` compares its expectation as a substring: a bare
# `issueflow-reconcile` expectation is satisfied by `issueflow-reconcile.test`
# too, so the multi-extension row below stayed green under a normalization
# stripping only the last extension — it asserted nothing it was named for.
# Bracketing each key makes every row here fail for its own reason.
deliverable_keys | sed 's/.*/[&]/'
}
check "the em-dash prefix is the key" 0 "[issueflow-reconcile]" \
keys_of <<< "issueflow-reconcile — the ruling clock counts assigned"
check "a leading actions/ segment comes off" 0 "[issueflow-reconcile]" \
keys_of <<< "actions/issueflow-reconcile — a failed board read"
check "...and so does .github/" 0 "[labeler]" \
keys_of <<< ".github/labeler.yml — one wrong answer left by D4"
check "...and lib/" 0 "[attention]" keys_of <<< "lib/attention.sh — the target"
check "...and bin/" 0 "[decide]" keys_of <<< "bin/decide.sh — the door"
check "every extension comes off, not just the last" 0 "[issueflow-reconcile]" \
keys_of <<< "issueflow-reconcile.test.sh — the pre-read is unpinned"
check "the key folds case" 0 "[triage]" keys_of <<< "TRIAGE.md — the bullet"
check "a + title carries both segments" 0 $'[triage]\n[releases]' \
keys_of <<< "TRIAGE.md + RELEASES.md — a standing window is a graph"
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
# A path segment the rule does not name stays part of the key: the strip list
# is closed on purpose (D2), so `test/issueflow-reconcile.test.sh` is its own
# deliverable and not the action it exercises.
2026-08-04 18:58:21 +00:00
check "an unlisted path segment stays in the key" 0 "[test/issueflow-reconcile]" \
keys_of <<< "test/issueflow-reconcile.test.sh — the pre-read"
# One issue answers a SET. Normalization is many-to-one by design, so a `+`
# title can spell one deliverable twice — a deliverable and its test named
# together is the ordinary shape here, not an exotic one — and a repeated key
# makes the chain scan find the issue adjacent to ITSELF.
check "a + title whose segments normalize to one key answers that key once" 0 \
"[issueflow-reconcile]" \
keys_of <<< "issueflow-reconcile.sh + issueflow-reconcile.test.sh — one deliverable"
check "...and answers it exactly once, not twice" 0 "1" \
count_lines <<< "issueflow-reconcile.sh + issueflow-reconcile.test.sh — one deliverable"
check "...and the path prefix folds onto the bare spelling the same way" 0 "1" \
count_lines <<< "actions/issueflow-reconcile + issueflow-reconcile.sh — still one"
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
# No em dash, no key. Inventing one out of prose is the guessing this sweep
2026-08-04 18:58:21 +00:00
# never does; the malformed title is triage's own contract to enforce. The
# emptiness is asserted through grep's exit, since `check` cannot assert an
# empty expectation.
check "a title with no em dash names no deliverable" 1 "" \
grep -q . < <( deliverable_keys <<< "a title that names nothing" )
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
# -- the collision decision: a chain, never a fan (#288 D3) ------------------
# Sourced helpers, not `bash -c`: a subshell started with -c has none of these
# functions, and a pipeline ending in grep would then answer "no match" from a
# command-not-found and pass a negative case for the wrong reason.
collision_chain( ) { collision_key_index | collision_flags; }
collision_flags_issue( ) { collision_chain | grep -q " ^ $1 " ; }
window_flags_issue( ) { # $1 issue, $2 gate, $3 carriers; records on stdin
window_flags " $2 " " $3 " | grep -qx " $1 "
}
collision_board = $'253\tclaimed\tissueflow-reconcile — release-init\n257\tclaimed\tactions/issueflow-reconcile — a failed board read\n284\tready\tissueflow-reconcile — the ruling clock'
check "three issues on one deliverable chain, each naming the newest below it" 0 \
$'257\tissueflow-reconcile=253\n284\tissueflow-reconcile=257' \
collision_chain <<< " $collision_board "
check "...so the oldest carrier is never itself flagged" 1 "" \
collision_flags_issue 253 <<< " $collision_board "
check "a lone carrier draws nothing" 0 "" \
collision_chain <<< $'284\tready\tissueflow-reconcile — alone'
# `blocked` is the GOAL state of #288's rule; flagging it reports the fix as
# the defect. Both legs of the test plan, on one board.
check "two blocked twins are the declared chain, not a collision" 0 "" \
collision_chain \
<<< $'264\tblocked\tTRIAGE.md — one\n266\tblocked\tTRIAGE.md — two'
check "a blocked twin does not carry a ready one's edge either" 0 "" \
collision_chain \
<<< $'264\tblocked\tTRIAGE.md — one\n266\tready\tTRIAGE.md — two'
check "an epic carrying the key is outside the claimable set (#288 D6)" 0 "" \
collision_chain \
<<< $'264\tepic\tTRIAGE.md — one\n266\tready\tTRIAGE.md — two'
check "a post-merge carrier is outside it too" 0 "" \
collision_chain \
<<< $'264\tpost-merge\tTRIAGE.md — one\n266\tready\tTRIAGE.md — two'
# The #284 shape, stated as its own case (test plan): a `claimed` issue whose
# PR is already in flight is the STRONGEST collision on the board, not a
# weaker one, and the flag reads the queue label rather than the PR link.
check "a claimed carrier with a PR in flight still carries the collision" 0 \
$'284\tissueflow-reconcile=253' \
collision_chain \
<<< $'253\tclaimed,scope:labels\tissueflow-reconcile — release-init\n284\tready\tissueflow-reconcile — the ruling clock'
# One issue, two colliding deliverables: ONE offending state, one comment (D4).
check "a multi-file title folds its collisions into one state" 0 \
$'295\treleases=292,triage=264' \
collision_chain \
<<< $'264\tready\tTRIAGE.md — one\n292\tready\tRELEASES.md — two\n295\tready\tTRIAGE.md + RELEASES.md — three'
2026-08-04 18:58:21 +00:00
# ...and an issue can never be its own carrier. A `+` title whose segments
# normalize to one key contributed that key twice, and the chain scan, which
# reads adjacent rows within a key, then found the issue beside itself: the
# comment asked #402 to declare `Blocked by #402`.
check "a self-folding + title never chains an issue to its own number" 0 "" \
collision_chain \
<<< $'402\tready\tissueflow-reconcile.sh + issueflow-reconcile.test.sh — one deliverable'
check "...and two such carriers chain once, to each other" 0 \
$'284\tissueflow-reconcile=257' \
collision_chain \
<<< $'257\tready\tissueflow-reconcile.sh + issueflow-reconcile.test.sh — one\n284\tready\tactions/issueflow-reconcile — two'
check "...with the older carrier still asked for nothing" 1 "" \
collision_flags_issue 257 \
<<< $'257\tready\tissueflow-reconcile.sh + issueflow-reconcile.test.sh — one\n284\tready\tactions/issueflow-reconcile — two'
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
2026-08-23 01:01:17 +00:00
# -- 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 sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
# -- 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'
check "a ready non-member is flagged during a standing window" 0 "264" \
window_flags "253" "249" <<< " $window_board "
check "...and a gate member is not" 1 "" \
window_flags_issue 264 $'253\n264' 249 <<< " $window_board "
check "...nor an epic (#292 D1 exempts it by name)" 1 "" \
window_flags_issue 270 253 249 <<< " $window_board "
check "...nor a post-merge issue" 1 "" \
window_flags_issue 271 253 249 <<< " $window_board "
check "...nor a blocked issue, which is already placed behind something" 1 "" \
window_flags_issue 272 253 249 <<< " $window_board "
# The release issue is the graph's SINK (#292 D2), so it can never be its own
# non-member — even when its own labels would otherwise admit it.
check "the window carrier is never flagged as its own non-member" 1 "" \
window_flags_issue 249 253 249 \
<<< $'249\tready,release\tRelease 0.6.0 — the board empties'
check "no standing window means no flag at all" 0 "" \
window_flags "" "" <<< " $window_board "
check "two standing windows render as one state" 0 "#249, #250" window_state $'249\n250\n'
2026-08-04 18:58:21 +00:00
# ONE reading of `unblocked` across both flags. D2 as corrected glosses the
# word as "carrying `ready` or `claimed`" and D3b says D3 uses that gloss and
# names the domain as the claimable set, so an issue that is `needs-triage` or
# carries no queue label at all is outside BOTH flags. Excluding only
# `blocked`/`epic`/`post-merge` admitted them, and the second case is the one
# that showed: the same pass adds `needs-triage` to an unlabeled issue and
# then tells it about a membership call made at mint time.
scope_board = $'249\tblocked,release\tRelease 0.6.0 — the board empties\n253\tclaimed\tissueflow-reconcile — a member\n400\tneeds-triage\tTRIAGE.md — not through the door yet\n401\t\tTRIAGE.md — no queue label at all\n402\tclaimed\tREVIEWER.md — claimable, and a non-member'
check "a needs-triage issue is not in the window flag's domain" 1 "" \
window_flags_issue 400 253 249 <<< " $scope_board "
check "...nor is an issue carrying no queue label at all" 1 "" \
window_flags_issue 401 253 249 <<< " $scope_board "
check "...while the claimable non-member beside them still flags" 0 "402" \
window_flags "253" "249" <<< " $scope_board "
# The same word, asserted through the other flag, so the two can never drift
# apart again without a red.
check "the collision flag reads that word identically" 0 "" \
collision_chain <<< $'400\tneeds-triage\tTRIAGE.md — one\n401\t\tTRIAGE.md — two'
check "...and both flags answer one shared predicate" 1 "" \
unblocked_claimable "needs-triage"
check "...which admits ready and claimed, and nothing else" 0 "" \
unblocked_claimable "claimed,scope:labels"
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
# -- the 2026-08-04 board, replayed whole (D5) ------------------------------
# The corpus the operator ruled on. Both flags are decided over the WHOLE
# board, so a sourced decision probe cannot exercise the gather — these run
# the script as a subprocess behind the PATH-stubbed gh, #91's lesson applied
# to a board-wide check.
BOARD = " $TMP /board "
mkdir -p " $BOARD "
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
cp " $ARRIVAL /fixtures/repos_owner_repo_pulls_state_open.json " " $BOARD /repos_owner_repo_pulls_state_open.json "
cp " $ARRIVAL /fixtures/repos_owner_repo_pulls_state_closed.json " " $BOARD /repos_owner_repo_pulls_state_closed.json "
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
board_issue( ) { # $1 number, $2 labels(csv), $3 title, $4 body, $5 assignee count
local labels_json
labels_json = " $( printf '%s' " $2 " | tr ',' '\n' \
| jq -R . | jq -sc 'map(select(. != "") | {name: .})' ) "
jq -n --argjson n " $1 " --argjson labels " $labels_json " --arg t " $3 " \
--arg b " ${ 4 :- } " --argjson a " ${ 5 :- 0 } " --arg at " $( iso_at " $INOW " ) " \
' { number: $n , state: "open" , title: $t , body: $b , labels: $labels ,
created_at: $at , user: { login: "triage-one" } ,
assignees: ( if $a > 0 then [ { login: "builder-bot" } ] else [ ] end) } ' \
>" $BOARD /repos_owner_repo_issues_ $1 .json "
}
board_assemble( ) { # numbers… -> the open-issue list, with fresh comment threads
local n
for n in " $@ " ; do printf '[]\n' >" $BOARD /repos_owner_repo_issues_ ${ n } _comments.json " ; done
# shellcheck disable=SC2016 # the filename expansion belongs to the loop below
for n in " $@ " ; do cat " $BOARD /repos_owner_repo_issues_ $n .json " ; done \
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
| jq -sc . >" $BOARD /repos_owner_repo_issues_state_open.json "
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
}
flag_count( ) { # $1 = collision|window, $2 = a sweep's output
grep -c " : $1 flag — " <<< " $2 "
}
board_run( ) {
: >" $BOARD /edits "
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
env PATH = " $ARRIVAL /stub: $PATH " CEREMONY_FORGE = github GH_FIXTURES = " $BOARD " ISSUEFLOW_NOW = " $INOW " \
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
REPO = owner/repo LABELS_CONF = " $ARRIVAL /labels.conf " \
bash " $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh " 2>& 1
}
fixtures take the corpus correction and the three ruled cases
Triage ruled all three questions (18:11Z) and corrected the corpus: at the
10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed`
with NO open PR — #285 was not created until 10:49:16Z. The morning board
takes that shape, which also makes the six `ready` non-members come out as
six, and puts the blocked twin on the board rather than only in a decision
probe.
D2's parenthetical is struck: unblocked = open and not blocked, both flags,
one definition. D3b is corrected in the same direction — a non-member
`claimed` WITH an open PR is flagged too, because a non-member holding a
builder and a review round is #292's competition realized, not a mild case
of it. Nothing in the implementation moves: neither flag ever consulted PR
liveness.
Three cases added, so the fixtures pin the rulings and not the prose:
- both carriers `claimed` with their own PRs open -> still flags. The
ninety-three minutes from #285's creation to its merge are exactly when
the struck parenthetical went silent on a live collision, which made the
flag's firing a property of someone's workflow rather than of the board.
- a fifteen-member declaration with every member closed -> D3 dormant, and
the release issue never flagged as its own non-member. A gate declaration
never empties; the precondition is its OPEN members, read off the board.
- flagged -> resolved -> recreated unchanged -> silent, asserted as D4's
stated boundary rather than left accidental.
Plus today's board — the `blocked` sink, a `claimed` gate member, two
`blocked` issues — which draws nothing. Verified live as well as in
fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real
board draws zero flags of either kind.
2026-08-04 18:21:56 +00:00
# The morning shape, as the board actually stood at the 10:28:54Z mint:
# #253 `claimed` with no open PR (#285 was not created until 10:49:16Z),
# #257 `ready` since the evening before, #284 minted `ready` into both of
# them — six `ready` non-members against a standing gate, and one deliverable
# carried three times in two spellings.
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \
2026-08-23 01:06:54 +00:00
" $( printf '%s\n' 'Blocked by #253.' '' '## Members' '- #253' ) "
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1
fixtures take the corpus correction and the three ruled cases
Triage ruled all three questions (18:11Z) and corrected the corpus: at the
10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed`
with NO open PR — #285 was not created until 10:49:16Z. The morning board
takes that shape, which also makes the six `ready` non-members come out as
six, and puts the blocked twin on the board rather than only in a decision
probe.
D2's parenthetical is struck: unblocked = open and not blocked, both flags,
one definition. D3b is corrected in the same direction — a non-member
`claimed` WITH an open PR is flagged too, because a non-member holding a
builder and a review round is #292's competition realized, not a mild case
of it. Nothing in the implementation moves: neither flag ever consulted PR
liveness.
Three cases added, so the fixtures pin the rulings and not the prose:
- both carriers `claimed` with their own PRs open -> still flags. The
ninety-three minutes from #285's creation to its merge are exactly when
the struck parenthetical went silent on a live collision, which made the
flag's firing a property of someone's workflow rather than of the board.
- a fifteen-member declaration with every member closed -> D3 dormant, and
the release issue never flagged as its own non-member. A gate declaration
never empties; the precondition is its OPEN members, read off the board.
- flagged -> resolved -> recreated unchanged -> silent, asserted as D4's
stated boundary rather than left accidental.
Plus today's board — the `blocked` sink, a `claimed` gate member, two
`blocked` issues — which draws nothing. Verified live as well as in
fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real
board draws zero flags of either kind.
2026-08-04 18:21:56 +00:00
board_issue 257 ready 'actions/issueflow-reconcile — a failed board read sweeps an empty board'
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
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
board_issue 266 ready 'TRIAGE.md — the epic task-list heading is literally `## Task list`'
board_issue 276 ready 'REVIEWER.md — the green-check precondition'
board_issue 281 ready 'LABELS.md — the attention row'
fixtures take the corpus correction and the three ruled cases
Triage ruled all three questions (18:11Z) and corrected the corpus: at the
10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed`
with NO open PR — #285 was not created until 10:49:16Z. The morning board
takes that shape, which also makes the six `ready` non-members come out as
six, and puts the blocked twin on the board rather than only in a decision
probe.
D2's parenthetical is struck: unblocked = open and not blocked, both flags,
one definition. D3b is corrected in the same direction — a non-member
`claimed` WITH an open PR is flagged too, because a non-member holding a
builder and a review round is #292's competition realized, not a mild case
of it. Nothing in the implementation moves: neither flag ever consulted PR
liveness.
Three cases added, so the fixtures pin the rulings and not the prose:
- both carriers `claimed` with their own PRs open -> still flags. The
ninety-three minutes from #285's creation to its merge are exactly when
the struck parenthetical went silent on a live collision, which made the
flag's firing a property of someone's workflow rather than of the board.
- a fifteen-member declaration with every member closed -> D3 dormant, and
the release issue never flagged as its own non-member. A gate declaration
never empties; the precondition is its OPEN members, read off the board.
- flagged -> resolved -> recreated unchanged -> silent, asserted as D4's
stated boundary rather than left accidental.
Plus today's board — the `blocked` sink, a `claimed` gate member, two
`blocked` issues — which draws nothing. Verified live as well as in
fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real
board draws zero flags of either kind.
2026-08-04 18:21:56 +00:00
# The blocked twin on the same key, on the board rather than in a decision
# probe: it is neither a collision flag nor one of the six.
board_issue 282 blocked 'TRIAGE.md — the two comment links come out' 'Blocked by #266.'
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown
board_issue 284 ready 'issueflow-reconcile — the issue-side ruling clock counts `assigned`'
board_assemble 249 253 257 264 266 276 281 282 284
morning_out = " $( board_run) "
morning_rc = $?
check "the morning board replays green" 0 "" test " $morning_rc " -eq 0
# D5's named pair, and the whole reason the key is normalized: #257 spells the
# deliverable `actions/issueflow-reconcile`, #284 spells it bare.
check "#284 draws the collision flag, naming #257 across the spelling variance" 0 \
'issueflow: #284: collision flag — issueflow-reconcile=257' \
printf '%s\n' " $morning_out "
check "...and #257 names #253, so the flag asks for a chain and not a fan" 0 \
'issueflow: #257: collision flag — issueflow-reconcile=253' \
printf '%s\n' " $morning_out "
check "...while #253, the oldest carrier, is asked for nothing" 1 "" \
grep -qF 'issueflow: #253: collision flag' <<< " $morning_out "
fixtures take the corpus correction and the three ruled cases
Triage ruled all three questions (18:11Z) and corrected the corpus: at the
10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed`
with NO open PR — #285 was not created until 10:49:16Z. The morning board
takes that shape, which also makes the six `ready` non-members come out as
six, and puts the blocked twin on the board rather than only in a decision
probe.
D2's parenthetical is struck: unblocked = open and not blocked, both flags,
one definition. D3b is corrected in the same direction — a non-member
`claimed` WITH an open PR is flagged too, because a non-member holding a
builder and a review round is #292's competition realized, not a mild case
of it. Nothing in the implementation moves: neither flag ever consulted PR
liveness.
Three cases added, so the fixtures pin the rulings and not the prose:
- both carriers `claimed` with their own PRs open -> still flags. The
ninety-three minutes from #285's creation to its merge are exactly when
the struck parenthetical went silent on a live collision, which made the
flag's firing a property of someone's workflow rather than of the board.
- a fifteen-member declaration with every member closed -> D3 dormant, and
the release issue never flagged as its own non-member. A gate declaration
never empties; the precondition is its OPEN members, read off the board.
- flagged -> resolved -> recreated unchanged -> silent, asserted as D4's
stated boundary rather than left accidental.
Plus today's board — the `blocked` sink, a `claimed` gate member, two
`blocked` issues — which draws nothing. Verified live as well as in
fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real
board draws zero flags of either kind.
2026-08-04 18:21:56 +00:00
check "the TRIAGE.md pair chains the same way" 0 \
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
'issueflow: #266: collision flag — triage=264' printf '%s\n' " $morning_out "
fixtures take the corpus correction and the three ruled cases
Triage ruled all three questions (18:11Z) and corrected the corpus: at the
10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed`
with NO open PR — #285 was not created until 10:49:16Z. The morning board
takes that shape, which also makes the six `ready` non-members come out as
six, and puts the blocked twin on the board rather than only in a decision
probe.
D2's parenthetical is struck: unblocked = open and not blocked, both flags,
one definition. D3b is corrected in the same direction — a non-member
`claimed` WITH an open PR is flagged too, because a non-member holding a
builder and a review round is #292's competition realized, not a mild case
of it. Nothing in the implementation moves: neither flag ever consulted PR
liveness.
Three cases added, so the fixtures pin the rulings and not the prose:
- both carriers `claimed` with their own PRs open -> still flags. The
ninety-three minutes from #285's creation to its merge are exactly when
the struck parenthetical went silent on a live collision, which made the
flag's firing a property of someone's workflow rather than of the board.
- a fifteen-member declaration with every member closed -> D3 dormant, and
the release issue never flagged as its own non-member. A gate declaration
never empties; the precondition is its OPEN members, read off the board.
- flagged -> resolved -> recreated unchanged -> silent, asserted as D4's
stated boundary rather than left accidental.
Plus today's board — the `blocked` sink, a `claimed` gate member, two
`blocked` issues — which draws nothing. Verified live as well as in
fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real
board draws zero flags of either kind.
2026-08-04 18:21:56 +00:00
check "...while the blocked twin beside them is the goal state, not a flag" 1 "" \
grep -qF 'issueflow: #282: collision flag' <<< " $morning_out "
check "the morning board draws exactly three collision flags" 0 "3" \
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
flag_count collision " $morning_out "
# D3's corpus: the six `ready` non-members that raced the emptying gate.
fixtures take the corpus correction and the three ruled cases
Triage ruled all three questions (18:11Z) and corrected the corpus: at the
10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed`
with NO open PR — #285 was not created until 10:49:16Z. The morning board
takes that shape, which also makes the six `ready` non-members come out as
six, and puts the blocked twin on the board rather than only in a decision
probe.
D2's parenthetical is struck: unblocked = open and not blocked, both flags,
one definition. D3b is corrected in the same direction — a non-member
`claimed` WITH an open PR is flagged too, because a non-member holding a
builder and a review round is #292's competition realized, not a mild case
of it. Nothing in the implementation moves: neither flag ever consulted PR
liveness.
Three cases added, so the fixtures pin the rulings and not the prose:
- both carriers `claimed` with their own PRs open -> still flags. The
ninety-three minutes from #285's creation to its merge are exactly when
the struck parenthetical went silent on a live collision, which made the
flag's firing a property of someone's workflow rather than of the board.
- a fifteen-member declaration with every member closed -> D3 dormant, and
the release issue never flagged as its own non-member. A gate declaration
never empties; the precondition is its OPEN members, read off the board.
- flagged -> resolved -> recreated unchanged -> silent, asserted as D4's
stated boundary rather than left accidental.
Plus today's board — the `blocked` sink, a `claimed` gate member, two
`blocked` issues — which draws nothing. Verified live as well as in
fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real
board draws zero flags of either kind.
2026-08-04 18:21:56 +00:00
for nonmember in 257 264 266 276 281 284; do
2026-08-04 18:54:52 +00:00
check " # $nonmember is flagged as an unblocked non-member under #249 " 0 \
" issueflow: # $nonmember : window flag — an unblocked non-member under #249 " \
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
printf '%s\n' " $morning_out "
done
check "the morning board draws exactly six window flags" 0 "6" \
flag_count window " $morning_out "
fixtures take the corpus correction and the three ruled cases
Triage ruled all three questions (18:11Z) and corrected the corpus: at the
10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed`
with NO open PR — #285 was not created until 10:49:16Z. The morning board
takes that shape, which also makes the six `ready` non-members come out as
six, and puts the blocked twin on the board rather than only in a decision
probe.
D2's parenthetical is struck: unblocked = open and not blocked, both flags,
one definition. D3b is corrected in the same direction — a non-member
`claimed` WITH an open PR is flagged too, because a non-member holding a
builder and a review round is #292's competition realized, not a mild case
of it. Nothing in the implementation moves: neither flag ever consulted PR
liveness.
Three cases added, so the fixtures pin the rulings and not the prose:
- both carriers `claimed` with their own PRs open -> still flags. The
ninety-three minutes from #285's creation to its merge are exactly when
the struck parenthetical went silent on a live collision, which made the
flag's firing a property of someone's workflow rather than of the board.
- a fifteen-member declaration with every member closed -> D3 dormant, and
the release issue never flagged as its own non-member. A gate declaration
never empties; the precondition is its OPEN members, read off the board.
- flagged -> resolved -> recreated unchanged -> silent, asserted as D4's
stated boundary rather than left accidental.
Plus today's board — the `blocked` sink, a `claimed` gate member, two
`blocked` issues — which draws nothing. Verified live as well as in
fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real
board draws zero flags of either kind.
2026-08-04 18:21:56 +00:00
check "...and never flags the gate member holding the window open" 1 "" \
grep -qF 'issueflow: #253: window flag' <<< " $morning_out "
check "...nor the blocked issue already placed behind something" 1 "" \
grep -qF 'issueflow: #282: window flag' <<< " $morning_out "
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
check "...nor the release issue that carries the window" 1 "" \
grep -qF 'issueflow: #249: window flag' <<< " $morning_out "
# D1: comments only. Not "no unexpected edit" — no edit at all.
check "the whole replay writes no label and no state (D1)" 1 "" \
grep -qF 'issue edit' " $BOARD /edits "
check "...and no new label is ever proposed" 1 "" \
grep -qE 'add-label (collision|window)' " $BOARD /edits "
check "the collision comment cites the rule it is asking for" 0 "" \
grep -qF 'collision edge' " $BOARD /edits "
check "...and names #288 as its authority" 0 "" grep -qF '#288 makes it unconditional' " $BOARD /edits "
check "the window comment names #292's invariant" 0 "" \
grep -qF "#292's invariant" " $BOARD /edits "
# shellcheck disable=SC2016 # backticks are the comment body's own Markdown
check "...and states the subset rule with its exemptions" 0 "" \
2026-08-23 01:06:54 +00:00
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 "
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
check "both comments carry idempotency markers (D4)" 0 "" \
grep -qF '<!-- issueflow:collision-' " $BOARD /edits "
check "...the window one too" 0 "" grep -qF '<!-- issueflow:window-nonmember-' " $BOARD /edits "
# D4: a state that still stands is silent on the next sweep. The thread is
# seeded with the marker the first sweep wrote, which is exactly what the
# real API answers an hour later.
jq -n --arg b " <!-- issueflow: $( state_marker collision 'issueflow-reconcile=257' ) -->
said already" '[{" user": {" login": " sweep-bot"}, " body" : $b }]' \
>" $BOARD /repos_owner_repo_issues_284_comments.json "
jq -n --arg b " <!-- issueflow: $( state_marker window-nonmember '#249' ) -->
said already" '[{" user": {" login": " sweep-bot"}, " body" : $b }]' \
>" $BOARD /repos_owner_repo_issues_276_comments.json "
resweep_out = " $( board_run) "
check "a standing collision is silent on the next sweep (D4)" 1 "" \
grep -qF 'issueflow: #284: collision flag' <<< " $resweep_out "
check "a standing window non-membership is silent too" 1 "" \
grep -qF 'issueflow: #276: window flag' <<< " $resweep_out "
fixtures take the corpus correction and the three ruled cases
Triage ruled all three questions (18:11Z) and corrected the corpus: at the
10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed`
with NO open PR — #285 was not created until 10:49:16Z. The morning board
takes that shape, which also makes the six `ready` non-members come out as
six, and puts the blocked twin on the board rather than only in a decision
probe.
D2's parenthetical is struck: unblocked = open and not blocked, both flags,
one definition. D3b is corrected in the same direction — a non-member
`claimed` WITH an open PR is flagged too, because a non-member holding a
builder and a review round is #292's competition realized, not a mild case
of it. Nothing in the implementation moves: neither flag ever consulted PR
liveness.
Three cases added, so the fixtures pin the rulings and not the prose:
- both carriers `claimed` with their own PRs open -> still flags. The
ninety-three minutes from #285's creation to its merge are exactly when
the struck parenthetical went silent on a live collision, which made the
flag's firing a property of someone's workflow rather than of the board.
- a fifteen-member declaration with every member closed -> D3 dormant, and
the release issue never flagged as its own non-member. A gate declaration
never empties; the precondition is its OPEN members, read off the board.
- flagged -> resolved -> recreated unchanged -> silent, asserted as D4's
stated boundary rather than left accidental.
Plus today's board — the `blocked` sink, a `claimed` gate member, two
`blocked` issues — which draws nothing. Verified live as well as in
fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real
board draws zero flags of either kind.
2026-08-04 18:21:56 +00:00
check "...while every other flag on the board still speaks" 0 "2" \
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
flag_count collision " $resweep_out "
check "...and the window flags with it" 0 "5" \
flag_count window " $resweep_out "
# The value-keyed marker's whole point: a state that CHANGED speaks, even
# though this family has already had its say on the thread (#252's A -> B -> A).
jq -n --arg b " <!-- issueflow: $( state_marker collision 'issueflow-reconcile=253' ) -->
an older, different state" '[{" user": {" login": " sweep-bot"}, " body" : $b }]' \
>" $BOARD /repos_owner_repo_issues_284_comments.json "
changed_out = " $( board_run) "
check "a changed collision state speaks over this family's last word" 0 \
'issueflow: #284: collision flag — issueflow-reconcile=257' \
printf '%s\n' " $changed_out "
# And a family only ever silences itself: the blocked-parse echo's marker
# lives on many of these threads and must not read as either flag's.
jq -n --arg b " <!-- issueflow:blockers-parsed-none-abc123def456 -->
a different family entirely" '[{" user": {" login": " sweep-bot"}, " body" : $b }]' \
>" $BOARD /repos_owner_repo_issues_284_comments.json "
foreign_out = " $( board_run) "
check "another family's marker never silences the collision flag" 0 \
'issueflow: #284: collision flag — issueflow-reconcile=257' \
printf '%s\n' " $foreign_out "
2026-08-04 19:00:48 +00:00
# The direction that is actually load-bearing, and that the case above cannot
# reach: a foreign family's marker landing AFTER this flag's own must not make
# the flag speak again. Family-blind, "the last marker on the thread" is the
# blocked-parse echo's, which is not this state's marker, and the flag
# re-posts a comment that already stands — the noise D4's dedup exists to
# stop, on the one thread where three families all have something to say.
jq -n --arg b " <!-- issueflow: $( state_marker collision 'issueflow-reconcile=257' ) -->
this flag' s own last word" \
--arg c " <!-- issueflow:blockers-parsed-none-abc123def456 -->
a different family, later on the thread" \
' [ { "user" : { "login" : "sweep-bot" } , "body" : $b } ,
{ "user" : { "login" : "sweep-bot" } , "body" : $c } ] ' \
>" $BOARD /repos_owner_repo_issues_284_comments.json "
later_foreign_out = " $( board_run) "
check "a foreign family's LATER marker never makes the flag re-post" 1 "" \
grep -qF 'issueflow: #284: collision flag' <<< " $later_foreign_out "
check "...while every other collision on the board still speaks" 0 "2" \
flag_count collision " $later_foreign_out "
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
# -- the post-ruling board draws nothing (D5's must-not-flag leg) -----------
# The same issues after triage placed them: the TRIAGE.md triple chained
# 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.
board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \
2026-08-23 01:06:54 +00:00
" $( printf '%s\n' 'Blocked by #253.' '' '## Members' \
'- #253' '- #257' '- #264' '- #266' '- #276' '- #281' '- #282' '- #284' ) "
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
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' \
'Blocked by #253.'
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
board_issue 266 blocked 'TRIAGE.md — the epic task-list heading is literally `## Task list`' \
'Blocked by #264.'
board_issue 276 ready 'REVIEWER.md — the green-check precondition'
board_issue 281 ready 'LABELS.md — the attention row'
board_issue 282 blocked 'TRIAGE.md — the two comment links come out' 'Blocked by #266.'
# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown
board_issue 284 blocked 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' \
'Blocked by #257.'
board_assemble 249 253 257 264 266 276 281 282 284
ruled_out = " $( board_run) "
check "the post-ruling board replays green" 0 "" test $? -eq 0
check "...and draws no collision flag at all" 1 "" \
grep -qF ': collision flag' <<< " $ruled_out "
check "...and no window flag either" 1 "" grep -qF ': window flag' <<< " $ruled_out "
check "...and still reports a whole pass" 0 'issueflow: reconciled.' \
printf '%s\n' " $ruled_out "
2026-08-23 01:06:54 +00:00
# -- an emptied window leaves the flag dormant (test plan) ------------------
# A membership RECORD never empties: #249 names fifteen members and still
# names fifteen after all fifteen close. So the precondition is the record's OPEN
fixtures take the corpus correction and the three ruled cases
Triage ruled all three questions (18:11Z) and corrected the corpus: at the
10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed`
with NO open PR — #285 was not created until 10:49:16Z. The morning board
takes that shape, which also makes the six `ready` non-members come out as
six, and puts the blocked twin on the board rather than only in a decision
probe.
D2's parenthetical is struck: unblocked = open and not blocked, both flags,
one definition. D3b is corrected in the same direction — a non-member
`claimed` WITH an open PR is flagged too, because a non-member holding a
builder and a review round is #292's competition realized, not a mild case
of it. Nothing in the implementation moves: neither flag ever consulted PR
liveness.
Three cases added, so the fixtures pin the rulings and not the prose:
- both carriers `claimed` with their own PRs open -> still flags. The
ninety-three minutes from #285's creation to its merge are exactly when
the struck parenthetical went silent on a live collision, which made the
flag's firing a property of someone's workflow rather than of the board.
- a fifteen-member declaration with every member closed -> D3 dormant, and
the release issue never flagged as its own non-member. A gate declaration
never empties; the precondition is its OPEN members, read off the board.
- flagged -> resolved -> recreated unchanged -> silent, asserted as D4's
stated boundary rather than left accidental.
Plus today's board — the `blocked` sink, a `claimed` gate member, two
`blocked` issues — which draws nothing. Verified live as well as in
fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real
board draws zero flags of either kind.
2026-08-04 18:21:56 +00:00
# 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
# itself an open unblocked non-`epic` non-member, and D3 would flag the sink
# at the exact moment the window ends.
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
board_issue 249 ready,release 'Release 0.6.0 — the board empties into the tag' \
2026-08-23 01:06:54 +00:00
" $( printf '%s\n' 'Blocked by #217.' '' '## Members' \
'- #218' '- #230' '- #232' '- #236' '- #237' '- #238' '- #241' '- #242' \
'- #247' '- #248' '- #251' '- #252' '- #253' '- #254' '- #257' ) "
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
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
board_issue 266 ready 'TRIAGE.md — the epic task-list heading is literally `## Task list`'
board_assemble 249 264 266
empty_gate_out = " $( board_run) "
2026-08-23 01:06:54 +00:00
check "a fifteen-member record with every member closed leaves D3 dormant" 1 "" \
fixtures take the corpus correction and the three ruled cases
Triage ruled all three questions (18:11Z) and corrected the corpus: at the
10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed`
with NO open PR — #285 was not created until 10:49:16Z. The morning board
takes that shape, which also makes the six `ready` non-members come out as
six, and puts the blocked twin on the board rather than only in a decision
probe.
D2's parenthetical is struck: unblocked = open and not blocked, both flags,
one definition. D3b is corrected in the same direction — a non-member
`claimed` WITH an open PR is flagged too, because a non-member holding a
builder and a review round is #292's competition realized, not a mild case
of it. Nothing in the implementation moves: neither flag ever consulted PR
liveness.
Three cases added, so the fixtures pin the rulings and not the prose:
- both carriers `claimed` with their own PRs open -> still flags. The
ninety-three minutes from #285's creation to its merge are exactly when
the struck parenthetical went silent on a live collision, which made the
flag's firing a property of someone's workflow rather than of the board.
- a fifteen-member declaration with every member closed -> D3 dormant, and
the release issue never flagged as its own non-member. A gate declaration
never empties; the precondition is its OPEN members, read off the board.
- flagged -> resolved -> recreated unchanged -> silent, asserted as D4's
stated boundary rather than left accidental.
Plus today's board — the `blocked` sink, a `claimed` gate member, two
`blocked` issues — which draws nothing. Verified live as well as in
fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real
board draws zero flags of either kind.
2026-08-04 18:21:56 +00:00
grep -qF ': window flag' <<< " $empty_gate_out "
check "...and the release issue is never flagged as its own non-member" 1 "" \
grep -qF 'issueflow: #249' <<< " $empty_gate_out "
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
check "...while the collision flag beside it is unaffected" 0 \
'issueflow: #266: collision flag — triage=264' printf '%s\n' " $empty_gate_out "
fixtures take the corpus correction and the three ruled cases
Triage ruled all three questions (18:11Z) and corrected the corpus: at the
10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed`
with NO open PR — #285 was not created until 10:49:16Z. The morning board
takes that shape, which also makes the six `ready` non-members come out as
six, and puts the blocked twin on the board rather than only in a decision
probe.
D2's parenthetical is struck: unblocked = open and not blocked, both flags,
one definition. D3b is corrected in the same direction — a non-member
`claimed` WITH an open PR is flagged too, because a non-member holding a
builder and a review round is #292's competition realized, not a mild case
of it. Nothing in the implementation moves: neither flag ever consulted PR
liveness.
Three cases added, so the fixtures pin the rulings and not the prose:
- both carriers `claimed` with their own PRs open -> still flags. The
ninety-three minutes from #285's creation to its merge are exactly when
the struck parenthetical went silent on a live collision, which made the
flag's firing a property of someone's workflow rather than of the board.
- a fifteen-member declaration with every member closed -> D3 dormant, and
the release issue never flagged as its own non-member. A gate declaration
never empties; the precondition is its OPEN members, read off the board.
- flagged -> resolved -> recreated unchanged -> silent, asserted as D4's
stated boundary rather than left accidental.
Plus today's board — the `blocked` sink, a `claimed` gate member, two
`blocked` issues — which draws nothing. Verified live as well as in
fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real
board draws zero flags of either kind.
2026-08-04 18:21:56 +00:00
# -- both carriers claimed, both with their own PRs open (test plan) --------
# The ninety-three minutes from #285's creation to its merge: under D2's
# struck parenthetical the live collision went silent for all of them, so
# whether the flag ever fired depended on where the sweep tick fell relative
# to a builder opening a PR. It fires on the board, and only on the board.
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
printf '%s\n' \
fixtures take the corpus correction and the three ruled cases
Triage ruled all three questions (18:11Z) and corrected the corpus: at the
10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed`
with NO open PR — #285 was not created until 10:49:16Z. The morning board
takes that shape, which also makes the six `ready` non-members come out as
six, and puts the blocked twin on the board rather than only in a decision
probe.
D2's parenthetical is struck: unblocked = open and not blocked, both flags,
one definition. D3b is corrected in the same direction — a non-member
`claimed` WITH an open PR is flagged too, because a non-member holding a
builder and a review round is #292's competition realized, not a mild case
of it. Nothing in the implementation moves: neither flag ever consulted PR
liveness.
Three cases added, so the fixtures pin the rulings and not the prose:
- both carriers `claimed` with their own PRs open -> still flags. The
ninety-three minutes from #285's creation to its merge are exactly when
the struck parenthetical went silent on a live collision, which made the
flag's firing a property of someone's workflow rather than of the board.
- a fifteen-member declaration with every member closed -> D3 dormant, and
the release issue never flagged as its own non-member. A gate declaration
never empties; the precondition is its OPEN members, read off the board.
- flagged -> resolved -> recreated unchanged -> silent, asserted as D4's
stated boundary rather than left accidental.
Plus today's board — the `blocked` sink, a `claimed` gate member, two
`blocked` issues — which draws nothing. Verified live as well as in
fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real
board draws zero flags of either kind.
2026-08-04 18:21:56 +00:00
'{"data":{"repository":{"pullRequests":{"nodes":[{"number":285,"body":"","closingIssuesReferences":{"nodes":[{"number":253}]}},{"number":286,"body":"","closingIssuesReferences":{"nodes":[{"number":284}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
>" $BOARD /repos_owner_repo_pulls_state_open.json "
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1
# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown
fixtures take the corpus correction and the three ruled cases
Triage ruled all three questions (18:11Z) and corrected the corpus: at the
10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed`
with NO open PR — #285 was not created until 10:49:16Z. The morning board
takes that shape, which also makes the six `ready` non-members come out as
six, and puts the blocked twin on the board rather than only in a decision
probe.
D2's parenthetical is struck: unblocked = open and not blocked, both flags,
one definition. D3b is corrected in the same direction — a non-member
`claimed` WITH an open PR is flagged too, because a non-member holding a
builder and a review round is #292's competition realized, not a mild case
of it. Nothing in the implementation moves: neither flag ever consulted PR
liveness.
Three cases added, so the fixtures pin the rulings and not the prose:
- both carriers `claimed` with their own PRs open -> still flags. The
ninety-three minutes from #285's creation to its merge are exactly when
the struck parenthetical went silent on a live collision, which made the
flag's firing a property of someone's workflow rather than of the board.
- a fifteen-member declaration with every member closed -> D3 dormant, and
the release issue never flagged as its own non-member. A gate declaration
never empties; the precondition is its OPEN members, read off the board.
- flagged -> resolved -> recreated unchanged -> silent, asserted as D4's
stated boundary rather than left accidental.
Plus today's board — the `blocked` sink, a `claimed` gate member, two
`blocked` issues — which draws nothing. Verified live as well as in
fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real
board draws zero flags of either kind.
2026-08-04 18:21:56 +00:00
board_issue 284 claimed 'issueflow-reconcile — the issue-side ruling clock counts `assigned`' '' 1
board_assemble 253 284
both_claimed_out = " $( board_run) "
check "two claimed carriers, both with PRs in flight, still draw the newer's flag" 0 \
'issueflow: #284: collision flag — issueflow-reconcile=253' \
printf '%s\n' " $both_claimed_out "
check "...and both live claims are left exactly as they were" 1 "" \
grep -qF 'issue edit' " $BOARD /edits "
# The same board with the newer side `ready`, so the queue label is visibly
# the only input the flag has.
# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
board_issue 284 ready 'issueflow-reconcile — the issue-side ruling clock counts `assigned`'
board_assemble 253 284
in_flight_out = " $( board_run) "
fixtures take the corpus correction and the three ruled cases
Triage ruled all three questions (18:11Z) and corrected the corpus: at the
10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed`
with NO open PR — #285 was not created until 10:49:16Z. The morning board
takes that shape, which also makes the six `ready` non-members come out as
six, and puts the blocked twin on the board rather than only in a decision
probe.
D2's parenthetical is struck: unblocked = open and not blocked, both flags,
one definition. D3b is corrected in the same direction — a non-member
`claimed` WITH an open PR is flagged too, because a non-member holding a
builder and a review round is #292's competition realized, not a mild case
of it. Nothing in the implementation moves: neither flag ever consulted PR
liveness.
Three cases added, so the fixtures pin the rulings and not the prose:
- both carriers `claimed` with their own PRs open -> still flags. The
ninety-three minutes from #285's creation to its merge are exactly when
the struck parenthetical went silent on a live collision, which made the
flag's firing a property of someone's workflow rather than of the board.
- a fifteen-member declaration with every member closed -> D3 dormant, and
the release issue never flagged as its own non-member. A gate declaration
never empties; the precondition is its OPEN members, read off the board.
- flagged -> resolved -> recreated unchanged -> silent, asserted as D4's
stated boundary rather than left accidental.
Plus today's board — the `blocked` sink, a `claimed` gate member, two
`blocked` issues — which draws nothing. Verified live as well as in
fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real
board draws zero flags of either kind.
2026-08-04 18:21:56 +00:00
check "a claimed carrier with an open PR draws the ready issue's flag too" 0 \
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
'issueflow: #284: collision flag — issueflow-reconcile=253' \
printf '%s\n' " $in_flight_out "
fixtures take the corpus correction and the three ruled cases
Triage ruled all three questions (18:11Z) and corrected the corpus: at the
10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed`
with NO open PR — #285 was not created until 10:49:16Z. The morning board
takes that shape, which also makes the six `ready` non-members come out as
six, and puts the blocked twin on the board rather than only in a decision
probe.
D2's parenthetical is struck: unblocked = open and not blocked, both flags,
one definition. D3b is corrected in the same direction — a non-member
`claimed` WITH an open PR is flagged too, because a non-member holding a
builder and a review round is #292's competition realized, not a mild case
of it. Nothing in the implementation moves: neither flag ever consulted PR
liveness.
Three cases added, so the fixtures pin the rulings and not the prose:
- both carriers `claimed` with their own PRs open -> still flags. The
ninety-three minutes from #285's creation to its merge are exactly when
the struck parenthetical went silent on a live collision, which made the
flag's firing a property of someone's workflow rather than of the board.
- a fifteen-member declaration with every member closed -> D3 dormant, and
the release issue never flagged as its own non-member. A gate declaration
never empties; the precondition is its OPEN members, read off the board.
- flagged -> resolved -> recreated unchanged -> silent, asserted as D4's
stated boundary rather than left accidental.
Plus today's board — the `blocked` sink, a `claimed` gate member, two
`blocked` issues — which draws nothing. Verified live as well as in
fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real
board draws zero flags of either kind.
2026-08-04 18:21:56 +00:00
2026-08-04 19:00:48 +00:00
# -- D3b's headline case, on the WINDOW side (acceptance criterion) ----------
# The criterion says a `claimed` non-member is flagged whether or not it has
# an open PR, and it is the line the 18:11Z ruling turned on — triage had
# excluded the open-PR case at 18:06Z and corrected it five minutes later.
# The collision fixtures above cover the PR-liveness question for their flag;
# this covers it for the other one. #292's charge against the third state is
# that a non-member competes with gate members for builders, and a non-member
# holding a builder AND a review round is that competition realized.
printf '%s\n' \
'{"data":{"repository":{"pullRequests":{"nodes":[{"number":403,"body":"","closingIssuesReferences":{"nodes":[{"number":402}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
>" $BOARD /repos_owner_repo_pulls_state_open.json "
2026-08-04 19:00:48 +00:00
board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \
2026-08-23 01:06:54 +00:00
" $( printf '%s\n' 'Blocked by #253.' '' '## Members' '- #253' ) "
2026-08-04 19:00:48 +00:00
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_assemble 249 253 402
nonmember_pr_out = " $( board_run) "
check "a claimed non-member with an open PR still draws the window flag" 0 \
'issueflow: #402: window flag — an unblocked non-member under #249' \
printf '%s\n' " $nonmember_pr_out "
check "...and the gate member beside it, also claimed with a PR, is not" 1 "" \
grep -qF 'issueflow: #253: window flag' <<< " $nonmember_pr_out "
check "...and the live claim is left exactly as it was" 1 "" \
grep -qF 'issue edit' " $BOARD /edits "
check "...one window flag on the board, and only one" 0 "1" \
flag_count window " $nonmember_pr_out "
fixtures take the corpus correction and the three ruled cases
Triage ruled all three questions (18:11Z) and corrected the corpus: at the
10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed`
with NO open PR — #285 was not created until 10:49:16Z. The morning board
takes that shape, which also makes the six `ready` non-members come out as
six, and puts the blocked twin on the board rather than only in a decision
probe.
D2's parenthetical is struck: unblocked = open and not blocked, both flags,
one definition. D3b is corrected in the same direction — a non-member
`claimed` WITH an open PR is flagged too, because a non-member holding a
builder and a review round is #292's competition realized, not a mild case
of it. Nothing in the implementation moves: neither flag ever consulted PR
liveness.
Three cases added, so the fixtures pin the rulings and not the prose:
- both carriers `claimed` with their own PRs open -> still flags. The
ninety-three minutes from #285's creation to its merge are exactly when
the struck parenthetical went silent on a live collision, which made the
flag's firing a property of someone's workflow rather than of the board.
- a fifteen-member declaration with every member closed -> D3 dormant, and
the release issue never flagged as its own non-member. A gate declaration
never empties; the precondition is its OPEN members, read off the board.
- flagged -> resolved -> recreated unchanged -> silent, asserted as D4's
stated boundary rather than left accidental.
Plus today's board — the `blocked` sink, a `claimed` gate member, two
`blocked` issues — which draws nothing. Verified live as well as in
fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real
board draws zero flags of either kind.
2026-08-04 18:21:56 +00:00
# -- flagged, resolved, recreated unchanged: silent, and specified ----------
# D4's boundary, asserted rather than left accidental. Nothing is posted at
# the resolution — D1 admits no comment there — so the thread's last word is
# still the state itself and an identical return says nothing new. #292 D2b
# owns the recurrence: a board state violating the window invariants is
# triage's to repair in the tick it is seen, and triage has already been told
# about this one.
jq -n --arg b " <!-- issueflow: $( state_marker collision 'issueflow-reconcile=253' ) -->
flagged once" '[{" user": {" login": " sweep-bot"}, " body" : $b }]' \
>" $BOARD /repos_owner_repo_issues_284_comments.json "
board_assemble_keep( ) { # board_assemble without wiping the seeded threads
local n
for n in " $@ " ; do cat " $BOARD /repos_owner_repo_issues_ $n .json " ; done \
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
| jq -sc . >" $BOARD /repos_owner_repo_issues_state_open.json "
fixtures take the corpus correction and the three ruled cases
Triage ruled all three questions (18:11Z) and corrected the corpus: at the
10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed`
with NO open PR — #285 was not created until 10:49:16Z. The morning board
takes that shape, which also makes the six `ready` non-members come out as
six, and puts the blocked twin on the board rather than only in a decision
probe.
D2's parenthetical is struck: unblocked = open and not blocked, both flags,
one definition. D3b is corrected in the same direction — a non-member
`claimed` WITH an open PR is flagged too, because a non-member holding a
builder and a review round is #292's competition realized, not a mild case
of it. Nothing in the implementation moves: neither flag ever consulted PR
liveness.
Three cases added, so the fixtures pin the rulings and not the prose:
- both carriers `claimed` with their own PRs open -> still flags. The
ninety-three minutes from #285's creation to its merge are exactly when
the struck parenthetical went silent on a live collision, which made the
flag's firing a property of someone's workflow rather than of the board.
- a fifteen-member declaration with every member closed -> D3 dormant, and
the release issue never flagged as its own non-member. A gate declaration
never empties; the precondition is its OPEN members, read off the board.
- flagged -> resolved -> recreated unchanged -> silent, asserted as D4's
stated boundary rather than left accidental.
Plus today's board — the `blocked` sink, a `claimed` gate member, two
`blocked` issues — which draws nothing. Verified live as well as in
fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real
board draws zero flags of either kind.
2026-08-04 18:21:56 +00:00
}
board_assemble_keep 284
resolved_out = " $( board_run) "
check "the collision resolves when its carrier leaves the board" 1 "" \
grep -qF ': collision flag' <<< " $resolved_out "
check "...and the resolution itself writes nothing at all" 1 "" test -s " $BOARD /edits "
board_assemble_keep 253 284
recreated_out = " $( board_run) "
check "an unchanged state recreated is silent — D4's stated boundary" 1 "" \
grep -qF 'issueflow: #284: collision flag' <<< " $recreated_out "
# -- today's board draws nothing (the post-ruling shape, live) --------------
# #249 the `blocked` sink, this issue `claimed` with no open PR and a gate
# member, #307 and #311 `blocked`. The `claimed` member is the case D3b would
# flag if membership were read wrong, which is why it is here.
printf '%s\n' \
'{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
>" $BOARD /repos_owner_repo_pulls_state_open.json "
fixtures take the corpus correction and the three ruled cases
Triage ruled all three questions (18:11Z) and corrected the corpus: at the
10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed`
with NO open PR — #285 was not created until 10:49:16Z. The morning board
takes that shape, which also makes the six `ready` non-members come out as
six, and puts the blocked twin on the board rather than only in a decision
probe.
D2's parenthetical is struck: unblocked = open and not blocked, both flags,
one definition. D3b is corrected in the same direction — a non-member
`claimed` WITH an open PR is flagged too, because a non-member holding a
builder and a review round is #292's competition realized, not a mild case
of it. Nothing in the implementation moves: neither flag ever consulted PR
liveness.
Three cases added, so the fixtures pin the rulings and not the prose:
- both carriers `claimed` with their own PRs open -> still flags. The
ninety-three minutes from #285's creation to its merge are exactly when
the struck parenthetical went silent on a live collision, which made the
flag's firing a property of someone's workflow rather than of the board.
- a fifteen-member declaration with every member closed -> D3 dormant, and
the release issue never flagged as its own non-member. A gate declaration
never empties; the precondition is its OPEN members, read off the board.
- flagged -> resolved -> recreated unchanged -> silent, asserted as D4's
stated boundary rather than left accidental.
Plus today's board — the `blocked` sink, a `claimed` gate member, two
`blocked` issues — which draws nothing. Verified live as well as in
fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real
board draws zero flags of either kind.
2026-08-04 18:21:56 +00:00
board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \
2026-08-23 01:06:54 +00:00
" $( printf '%s\n' 'Blocked by #307.' '' '## Members' '- #293' '- #307' ) "
fixtures take the corpus correction and the three ruled cases
Triage ruled all three questions (18:11Z) and corrected the corpus: at the
10:28:54Z mint #257 was `ready`, not `claimed`, and #253 was `claimed`
with NO open PR — #285 was not created until 10:49:16Z. The morning board
takes that shape, which also makes the six `ready` non-members come out as
six, and puts the blocked twin on the board rather than only in a decision
probe.
D2's parenthetical is struck: unblocked = open and not blocked, both flags,
one definition. D3b is corrected in the same direction — a non-member
`claimed` WITH an open PR is flagged too, because a non-member holding a
builder and a review round is #292's competition realized, not a mild case
of it. Nothing in the implementation moves: neither flag ever consulted PR
liveness.
Three cases added, so the fixtures pin the rulings and not the prose:
- both carriers `claimed` with their own PRs open -> still flags. The
ninety-three minutes from #285's creation to its merge are exactly when
the struck parenthetical went silent on a live collision, which made the
flag's firing a property of someone's workflow rather than of the board.
- a fifteen-member declaration with every member closed -> D3 dormant, and
the release issue never flagged as its own non-member. A gate declaration
never empties; the precondition is its OPEN members, read off the board.
- flagged -> resolved -> recreated unchanged -> silent, asserted as D4's
stated boundary rather than left accidental.
Plus today's board — the `blocked` sink, a `claimed` gate member, two
`blocked` issues — which draws nothing. Verified live as well as in
fixtures: a DRY_RUN sweep of this branch against heavy-duty/ceremony's real
board draws zero flags of either kind.
2026-08-04 18:21:56 +00:00
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' \
'Blocked by #293.'
board_issue 311 blocked 'docs/CONSUMERS.md — a deliberate non-member' 'Blocked by #249.'
board_assemble 249 293 307 311
today_out = " $( board_run) "
check "today's board draws no collision flag" 1 "" grep -qF ': collision flag' <<< " $today_out "
check "...and no window flag: the claimed member is a member" 1 "" \
grep -qF ': window flag' <<< " $today_out "
check "...and still reports a whole pass" 0 'issueflow: reconciled.' \
printf '%s\n' " $today_out "
the sweep flags what the window and collision rules forbid
Two advisory flags on the issue-flow sweep, the mechanical backstop for
#288's collision rule and #292's window rule. Both are prose today, and
both failed silently on the same morning: #284 was minted `ready` into a
file another issue held claimed with a PR in flight, and six `ready`
non-members raced an emptying gate. #262 measured the pattern — the same
class of rule, once in a guard, produced zero misses.
Comments only (D1): no label write, no state change, no new label. The
sweep never guesses intent; it states the board fact and triage resolves.
The collision key is the title's em-dash prefix NORMALIZED, because the
2026-08-04 miss spelled one deliverable two ways — `actions/issueflow-reconcile`
against bare `issueflow-reconcile` — so exact-prefix matching would have
missed the pair it was written for. One leading path segment comes off,
then every extension; a `+`-joined title matches on any segment.
The flag asks for a CHAIN, not a fan (#288 D3): within one key each issue
names the newest open carrier below it, so the declaration it asks for
releases exactly one successor per close.
A standing window is a release issue whose gate still holds an OPEN member.
The board read IS the open set, so membership decides openness with no
extra call, and an all-closed gate is the emptied gate the release's own
blocked -> ready promotion answers — which is why a `ready` release leaves
the flag dormant instead of flagging the whole board.
Dedup is the declaration echo's, extracted into state_marker /
state_echo_needed and scoped per family (D4): the marker is keyed to the
offending state's value and compared against that family's last word on
the thread, so a state that changes always speaks.
Fixtures replay the 2026-08-04 morning board whole and the post-ruling
board beside it: the first draws exactly four collision flags and six
window flags and writes not one label; the second draws none.
Closes #293.
2026-08-04 18:15:00 +00:00
2026-08-23 01:06:54 +00:00
# -- 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 "
fix(issueflow): a per-issue pass commits its whole effect, or none of it
The per-read guards closed the reported class — a failed read never reaches
a decision function — and left one layer standing. A pass could mutate and
only THEN reach a guarded read, fail it, and report the issue as skipped:
`stale` removed, or `needs-triage` minted, under a log line saying the
sweep had touched nothing. That is the same false report #247 exists to
close, told from the other end, and the panel reproduced it on four
separate compositions.
Fixed as the ordering invariant rather than per site. Inside
reconcile_issue_pass's subshell, run() and log() stage their effects, and
commit_staged_effects replays them in order once the pass has completed.
skip_issue emits its own line directly and exits, so the buffer dies with
the subshell. A skip therefore implies zero `gh issue edit`, zero
`gh issue comment`, and no log line about a mutation that never landed —
for compositions nobody has written yet, because reconcile_issue has no way
to mutate directly. Reads stay where they are: they may happen anywhere,
since nothing lands until the end.
Stated per site it would hold until the next composition. Two consequences
worth naming: reconcile_ruling is covered without touching lib/ruling.sh,
because it posts through the sourcing script's run()/log() — the PR surface
keeps its own and is unaffected; and a genuine crash mid-pass now also
lands nothing, where before it left the earlier mutations applied. D4's
handler string, D6's tail and D7's exit 0 are all unchanged, and the
healthy path is byte-identical: every staged write commits under the same
`>/dev/null` its call site already applied.
Refs #247
2026-08-03 18:55:52 +00:00
# -- the invariant is enforced at the source, not remembered ----------------
# 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
# here rather than left to review — the shape lib/ruling.sh already uses for
# #50 D9. reconcile_opened_issue is deliberately exempt: it runs outside the
# per-issue subshell, under live errexit, and stages nothing (#247 D8).
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
# The verbs are the shim's now, not gh's (#188, and every remaining call site
# ported by #198) — pinning `gh issue` here would pin a string this surface no
# longer contains and pass vacuously forever.
fix(issueflow): a per-issue pass commits its whole effect, or none of it
The per-read guards closed the reported class — a failed read never reaches
a decision function — and left one layer standing. A pass could mutate and
only THEN reach a guarded read, fail it, and report the issue as skipped:
`stale` removed, or `needs-triage` minted, under a log line saying the
sweep had touched nothing. That is the same false report #247 exists to
close, told from the other end, and the panel reproduced it on four
separate compositions.
Fixed as the ordering invariant rather than per site. Inside
reconcile_issue_pass's subshell, run() and log() stage their effects, and
commit_staged_effects replays them in order once the pass has completed.
skip_issue emits its own line directly and exits, so the buffer dies with
the subshell. A skip therefore implies zero `gh issue edit`, zero
`gh issue comment`, and no log line about a mutation that never landed —
for compositions nobody has written yet, because reconcile_issue has no way
to mutate directly. Reads stay where they are: they may happen anywhere,
since nothing lands until the end.
Stated per site it would hold until the next composition. Two consequences
worth naming: reconcile_ruling is covered without touching lib/ruling.sh,
because it posts through the sourcing script's run()/log() — the PR surface
keeps its own and is unaffected; and a genuine crash mid-pass now also
lands nothing, where before it left the earlier mutations applied. D4's
handler string, D6's tail and D7's exit 0 are all unchanged, and the
healthy path is byte-identical: every staged write commits under the same
`>/dev/null` its call site already applied.
Refs #247
2026-08-03 18:55:52 +00:00
mutation_calls( ) {
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
grep -nE '(^|[^_[:alnum:]])forge_issue_(edit|comment)' \
fix(issueflow): a per-issue pass commits its whole effect, or none of it
The per-read guards closed the reported class — a failed read never reaches
a decision function — and left one layer standing. A pass could mutate and
only THEN reach a guarded read, fail it, and report the issue as skipped:
`stale` removed, or `needs-triage` minted, under a log line saying the
sweep had touched nothing. That is the same false report #247 exists to
close, told from the other end, and the panel reproduced it on four
separate compositions.
Fixed as the ordering invariant rather than per site. Inside
reconcile_issue_pass's subshell, run() and log() stage their effects, and
commit_staged_effects replays them in order once the pass has completed.
skip_issue emits its own line directly and exits, so the buffer dies with
the subshell. A skip therefore implies zero `gh issue edit`, zero
`gh issue comment`, and no log line about a mutation that never landed —
for compositions nobody has written yet, because reconcile_issue has no way
to mutate directly. Reads stay where they are: they may happen anywhere,
since nothing lands until the end.
Stated per site it would hold until the next composition. Two consequences
worth naming: reconcile_ruling is covered without touching lib/ruling.sh,
because it posts through the sourcing script's run()/log() — the PR surface
keeps its own and is unaffected; and a genuine crash mid-pass now also
lands nothing, where before it left the earlier mutations applied. D4's
handler string, D6's tail and D7's exit 0 are all unchanged, and the
healthy path is byte-identical: every staged write commits under the same
`>/dev/null` its call site already applied.
Refs #247
2026-08-03 18:55:52 +00:00
" $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh " " $ROOT /lib/ruling.sh " \
| grep -vE '^\S+:[0-9]+: *#' || true
}
# shellcheck disable=SC2016 # positional parameters belong to bash -c
check "every issue mutation on this surface goes through run()" 0 "" \
bash -c ' while IFS = read -r line; do
[ -n " $line " ] || continue
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
case " $line " in *"run forge_issue_" *) ; ; *) printf "unstaged mutation: %s\n" " $line " ; exit 1 ; ; esac
fix(issueflow): a per-issue pass commits its whole effect, or none of it
The per-read guards closed the reported class — a failed read never reaches
a decision function — and left one layer standing. A pass could mutate and
only THEN reach a guarded read, fail it, and report the issue as skipped:
`stale` removed, or `needs-triage` minted, under a log line saying the
sweep had touched nothing. That is the same false report #247 exists to
close, told from the other end, and the panel reproduced it on four
separate compositions.
Fixed as the ordering invariant rather than per site. Inside
reconcile_issue_pass's subshell, run() and log() stage their effects, and
commit_staged_effects replays them in order once the pass has completed.
skip_issue emits its own line directly and exits, so the buffer dies with
the subshell. A skip therefore implies zero `gh issue edit`, zero
`gh issue comment`, and no log line about a mutation that never landed —
for compositions nobody has written yet, because reconcile_issue has no way
to mutate directly. Reads stay where they are: they may happen anywhere,
since nothing lands until the end.
Stated per site it would hold until the next composition. Two consequences
worth naming: reconcile_ruling is covered without touching lib/ruling.sh,
because it posts through the sourcing script's run()/log() — the PR surface
keeps its own and is unaffected; and a genuine crash mid-pass now also
lands nothing, where before it left the earlier mutations applied. D4's
handler string, D6's tail and D7's exit 0 are all unchanged, and the
healthy path is byte-identical: every staged write commits under the same
`>/dev/null` its call site already applied.
Refs #247
2026-08-03 18:55:52 +00:00
done <<< " $1 " ' _ " $( mutation_calls) "
check "...and the pin sees the call sites it is guarding" 0 "" \
test " $( mutation_calls | wc -l) " -ge 8
merge upstream 0.6.0 onto the forge tree, and port every gh call site it brought (#198)
`git merge` of upstream `8c3a4d1` onto `dad99dd`, common ancestor `84bb1a4`.
18 hunks in 10 files; `lib/forge.sh`, `lib/forge-github.sh` and
`lib/forge-forgejo.sh` conflict in none and come out byte-identical.
The resolutions the issue decided: VERSION and both CEREMONY_SELF_REF
carriers take upstream's numbers; `.github/labels.conf` and `drills/0.4.1.md`
keep this forge's; CHANGELOG keeps both sides and names the upstream commit
this tree carries.
The part the hunks did not contain. Upstream's 0.5.0/0.6.0 work added whole
functions to files this tree already owned, so `git merge` took its side
without raising a conflict — and with them, EIGHT runtime `gh` call sites
that #188 had removed. Seven are ported onto the shim: two reads and four
comment writes in issueflow-reconcile, and labels-reconcile's HEAD_COMMIT_AT
read. The eighth is `gh workflow run` in labels.yml, which a workflow cannot
declare a client for and whose Forgejo equivalent this instance answers with
500 rather than a 4xx — named with its reason rather than ported on a guess.
test/no-runtime-gh.test.sh makes the rule mechanical, because reviewing the
diff could not: four reviewers reading it each found a different subset, and
the contract suite stubs `gh`, so a reintroduced call site passes it.
Three seams the resolution decides are silent when resolved wrongly, and each
now has a case that fails on the wrong one: the merged record's `merged_at`
third column (without it every sort key ties and the highest PR number comes
back), the open gather's one-BODY-row-per-line feed (a whole decoded body as
one record loses every declaration including the first), and the whole-board
read whose COLLISION_FLAGS/WINDOW_FLAGS consumers auto-merged.
The open gather carries CLOSING rows as well as BODY rows. `Refs` alone would
drop every `Closes #N` link on the open side and reclaim a claim the PR was
holding — the existing base64 round-trip case is red without it.
actions/refs-not-closing declares CEREMONY_FORGE_CLIENT=gh: its only gather
is GraphQL, which Forgejo does not serve at all. #199 ports it.
test/run.sh: 28 test files, 0 failed. shellcheck and actionlint clean.
Refs #198
2026-08-05 11:56:23 +00:00
# -- the gather->consumer seam, at main() granularity (#198) -----------------
# Every MERGED_REF_PR_RECORDS fixture above assigns the variable DIRECTLY, so
# they prove post_merge_pr_for_issue given three columns and pass unchanged if
# the gather emits two. The seam the 0.6.0 resolution actually decides is the
# one none of them span, so it is driven here through the real REST gather.
# 1. The out-of-order pair. crew#176's shape: the HIGHER PR number merged
# EARLIER, so number order and merge order disagree. A two-column emit
# leaves $3 empty, every sort key ties, and the tie-break silently answers
# the highest number — the #242 fix undone with no error and no red.
printf '%s\n' \
'[{"number":184,"body":"Refs #42","merged_at":"2026-07-30T19:05:16Z"},{"number":182,"body":"Refs #42","merged_at":"2026-07-30T19:05:18Z"}]' \
>" $ARRIVAL /fixtures/repos_owner_repo_pulls_state_closed.json "
printf '[]\n' >" $ARRIVAL /fixtures/repos_owner_repo_pulls_state_open.json "
printf '[{"number":42}]\n' \
>" $ARRIVAL /fixtures/repos_owner_repo_issues_state_open.json "
jq -n --arg at " $( iso_at " $INOW " ) " \
'{number:42,user:{login:"triage-one"},created_at:$at,body:"- [ ] verify after merge",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \
>" $ARRIVAL /fixtures/repos_owner_repo_issues_42.json "
printf '[]\n' >" $ARRIVAL /fixtures/repos_owner_repo_issues_42_comments.json "
: >" $ARRIVAL /fixtures/edits "
order_out = " $(
env PATH = " $ARRIVAL /stub: $PATH " CEREMONY_FORGE = github GH_FIXTURES = " $ARRIVAL /fixtures " \
ISSUEFLOW_NOW = " $INOW " REPO = owner/repo LABELS_CONF = " $ARRIVAL /labels.conf " \
bash " $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh " 2>& 1
) "
check "the out-of-order gather still transitions the issue" 0 "" \
grep -qF '#42: merged Refs PR -> post-merge; claim released' <<< " $order_out "
# The marker carries the deliverable's number, so it is where the answer is
# observable from outside. 182 merged LAST; 184 is the higher number.
check "...naming the PR that merged last, not the highest-numbered one" 0 "" \
grep -qF 'post-merge-transition-pr-182' " $ARRIVAL /fixtures/edits "
check "...and never the higher number that merged earlier" 1 "" \
grep -qF 'post-merge-transition-pr-184' " $ARRIVAL /fixtures/edits "
# 2. The multi-line open body. open_pr_issues is line-oriented, so the decoded
# body must arrive one BODY row per PHYSICAL line. Handed over as a single
# record it loses every declaration including the first, and the claim is
# reclaimed under a live PR. `Refs #43` sits on line 3 for that reason: on
# line 1 the case would pass either way, which is a vacuous test.
printf '%s\n' \
'[{"number":430,"body":"## Summary\nSome prose about the work.\nRefs #43\nMore prose after it.","merged_at":null}]' \
>" $ARRIVAL /fixtures/repos_owner_repo_pulls_state_open.json "
printf '[]\n' >" $ARRIVAL /fixtures/repos_owner_repo_pulls_state_closed.json "
printf '[{"number":43}]\n' \
>" $ARRIVAL /fixtures/repos_owner_repo_issues_state_open.json "
jq -n --arg at " $( iso_at $(( INOW - 7200 )) ) " \
'{number:43,user:{login:"triage-one"},created_at:$at,body:"- [ ] build",labels:[{name:"claimed"}],assignees:[{login:"builder"}]}' \
>" $ARRIVAL /fixtures/repos_owner_repo_issues_43.json "
printf '[]\n' >" $ARRIVAL /fixtures/repos_owner_repo_issues_43_comments.json "
printf '[]\n' >" $ARRIVAL /fixtures/repos_owner_repo_issues_43_timeline.json "
: >" $ARRIVAL /fixtures/edits "
multiline_out = " $(
env PATH = " $ARRIVAL /stub: $PATH " CEREMONY_FORGE = github GH_FIXTURES = " $ARRIVAL /fixtures " \
ISSUEFLOW_NOW = " $INOW " ISSUEFLOW_STALE_HOURS = 1 \
REPO = owner/repo LABELS_CONF = " $ARRIVAL /labels.conf " \
bash " $ROOT /actions/issueflow-reconcile/issueflow-reconcile.sh " 2>& 1
) "
check "the multi-line gather completes" 0 "" \
grep -qF 'issueflow: reconciled.' <<< " $multiline_out "
check "a Refs off the first line of an open body still rescues the claim" 1 "" \
grep -qE 'issue edit 43 .*--remove-label claimed' " $ARRIVAL /fixtures/edits "
2026-07-22 19:21:01 +00:00
summary