feat: record release window membership separately #239

Merged
andres merged 4 commits from codex-bot-andresmgsl/ceremony:build/230-membership-record into main 2026-08-23 16:58:12 +00:00
5 changed files with 518 additions and 63 deletions

View file

@ -40,6 +40,59 @@ decision during release-init. The double gate on
out-of-chain track on [heavy-duty/crew#348](https://github.com/heavy-duty/crew/issues/348)
are worked examples of exceptions declared where they apply.
## The membership record
A release issue's `Blocked by` line answers the predecessor gate above and
nothing else. Which issues are *in* the release is a separate record on the
same issue, and the sweep reads it by heading (#343):
- the heading is literally `## Members`, matched case-insensitively, tolerant
of any run of whitespace between the `##` and the word and of trailing
whitespace after it, and the record runs to the next heading — the same
shape `## Task list` already has;
- one member per list row, under any Markdown list marker and only those:
`-`, `*`, `+`, and 1 to 9 digits followed by `.` or `)` all open a row,
because a row is whatever a reader sees as one — and a tenth digit opens
nothing, CommonMark's ordered marker being at most nine digits, so
`1234567890. #412` is narration and enrols no member. Indentation is bounded
the same way: up to three spaces still open a row, four or more open nothing,
a leading tab counting as four. The record is **flat** — one member per
top-level row — and past that bound a line is not one: standing alone it is
an indented code block, and under a row it is a sub-bullet annotating that
member, and neither is a member itself. Below the bound it enrols, an
indented row being the same bytes as a top-level one. The member is the
row's first token after the list marker and an optional checkbox, and it is
a bare local `#<number>`: `- #253` and `- [ ] #253` both enrol #253.
Everything after that token is prose and contributes nothing, so a row is
free to cite the PR that closed it, a sibling repository, or an issue it
names as explicitly *not* a member;
- a row whose first token is anything else — a qualified `repo#N`, a number
with punctuation attached, or ordinary prose — contributes no member. The
parse stays silent rather than guessing;
- a qualified reference is never a member: a window is one repository's DAG,
decided against one board read;
- a row naming the release issue itself contributes no member. The sink is
never one of its own members;
- **there is no fallback to the gate.** A release issue with no members
section enumerates no membership, is not a standing window, and draws no
window flag. A repository whose epics predate this record gets silence,
never a false flag, until its next release-init writes one.
Why a heading and not a marker phrase: the `Blocked by` parse unions every
occurrence of its marker and runs each clause to a sentence terminator, which
is the right error direction for a `blocked` issue and the wrong one for a
release body that is mostly narration *about* its members. Why the first token
and not every reference in the row: a real member row cites merged PRs, other
repositories and explicit non-members, and reading the whole row enrols all of
them.
The cost is named rather than hidden: a version epic maintains two lists — the
`## Members` record and the `## Task list` progress view — and triage writes
both in the same flip. The purchase is that the progress view stays a progress
view, prose-rich and free to carry several issues in one row or to omit a
member that is not in the build queue, while membership is a machine record
with exactly one shape.
## Release-init
The predecessor closing and clearing the next epic's declared gate is the
@ -54,7 +107,9 @@ steps:
written. Each member initially declares `Blocked by <the epic>`.
2. Graph hard `Blocked by` edges and same-file clusters on the epic.
3. Write the waves into the epic body as checklists in claim order, with a
separate verification lane and the progress view under `## Task list`.
separate verification lane and the progress view under `## Task list`, and
write the window's membership under `## Members` — release-init is where
that record is first written, and until it exists no window stands.
4. Ask the operator to bless the order, then have triage open the first wave
by applying the flip mechanics below. The operator's blessing is the one
step this chain never automates.
@ -74,16 +129,17 @@ interleaving unrelated windows blurs both the release story and the evidence
behind it. Gates open windows; they do not silently admit members, so builders
still see one deliberately ordered queue.
While a window stands — an open release-labeled issue with a non-empty
enumerated gate — its members form a DAG whose sink is the release issue.
Every member reaches that sink. Members declare only their immediate
predecessors; ordering edges live on members, while the sink records membership
only; and the `ready` set is exactly the graph's current sources. Every close
While a window stands — an open release-labeled issue whose membership record
holds at least one open member — its members form a DAG whose sink is the
release issue. Every member reaches that sink. Members declare only their
immediate predecessors; ordering edges live on members, while the sink records
membership only, in the record above and nowhere else; and the `ready` set is
exactly the graph's current sources. Every close
releases exactly its declared successors, and that whole set is concurrently
claimable: a member may have multiple successors, while the collision rule
already orders any that share a deliverable. Insertion re-points downstream
edges rather than merely appending membership at the sink. It follows that
every `ready` issue is a gate member. `epic` and `post-merge` issues are exempt
every `ready` issue is a member. `epic` and `post-merge` issues are exempt
because neither is claimable (#292).
A member that lands `post-merge` releases nothing: that exemption is about
@ -135,6 +191,13 @@ says they no longer apply. Preserve the history only after rewriting the
marker into non-parseable prose, then verify that the parser returns an empty
set for the release gate.
**The same flip adds the member's row to the release issue's membership
record.** That write is not bookkeeping to catch up on later: the record is
the only thing that makes the window stand, so a member flipped `ready`
without a row is, to the sweep, an unblocked non-member — the exact state the
window flag exists to report. Verify the flip by reading the record back and
finding the new member's row in it (#343).
Release membership is a decision, never a sweep default. Triage performs each
flip only after the operator blesses the wave; the issue-flow sweep may resolve
ordinary issue dependencies, but it does not choose a release's contents.

View file

@ -106,9 +106,11 @@ Every issue you mint carries, in this order:
new issue, dropping any predecessor the new issue now reaches (inserting X
into A → B makes A → X → B, so B drops A); a member that must land after the
new issue but already reaches it through another member declares nothing
new; and the release issue adds the new issue to its gate, recording
membership only. Collision and window edges are independent, so write both
when both apply (#292).
new; and the release issue adds a row for the new issue to its membership
record, which records membership only and is the only place the sweep reads
it — a release issue's `Blocked by` line answers its predecessor gate and
never its membership (#292, #343). Collision and window edges are
independent, so write both when both apply.
- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and
exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)).

View file

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

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

@ -0,0 +1,3 @@
### Changed
- Release windows now read membership from a dedicated `## Members` record, with CommonMark-bounded rows and no fallback to predecessor gates (#230).

View file

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