feat: derive release windows from membership records

This commit is contained in:
codex-bot-andresmgsl 2026-08-23 01:06:54 +00:00
parent b7a2b31f84
commit 9a37db4b18
2 changed files with 124 additions and 48 deletions

View file

@ -830,7 +830,7 @@ last_issue_comment_activity() { # $1 issue, $2 created_at → epoch; non-zero on
issue_activity_at "$1" "$2" comments-only issue_activity_at "$1" "$2" comments-only
} }
reconcile_board_flags() { # $1 = issue — the collision and window flags (#293) reconcile_board_flags() { # $1 issue, $2 concluded queue state — board flags (#293)
# Dedup is the declaration echo's, per family (#293 D4): the marker is # Dedup is the declaration echo's, per family (#293 D4): the marker is
# keyed to the offending state's VALUE and compared against this family's # keyed to the offending state's VALUE and compared against this family's
# last word on the thread, so a state that changes speaks and a state that # last word on the thread, so a state that changes speaks and a state that
@ -845,6 +845,7 @@ reconcile_board_flags() { # $1 = issue — the collision and window flags (#293)
# board fact that is true right now, and a board where the fact never # board fact that is true right now, and a board where the fact never
# changed has nothing new to say. # changed has nothing new to say.
local n="$1" state marker rendered local n="$1" state marker rendered
board_flags_in_scope "$2" || return 0
state="$(flag_for_issue "$n" "${COLLISION_FLAGS:-}")" state="$(flag_for_issue "$n" "${COLLISION_FLAGS:-}")"
if [ -n "$state" ]; then if [ -n "$state" ]; then
marker="$(state_marker collision "$state")" marker="$(state_marker collision "$state")"
@ -882,20 +883,22 @@ marker carries the collision itself, so an unchanged one never re-posts.*" >/dev
if state_echo_needed "$n" window-nonmember "$marker"; then if state_echo_needed "$n" window-nonmember "$marker"; then
run forge_issue_comment "$n" "<!-- issueflow:$marker --> run forge_issue_comment "$n" "<!-- issueflow:$marker -->
A release window is standing ($state) and this issue is neither one of its A release window is standing ($state) and this issue is neither one of its
gate members nor an \`epic\` or \`post-merge\` issue. members nor an \`epic\` or \`post-merge\` issue.
#292's invariant: during a standing window — an open \`release\`-labeled issue #292's invariant: during a standing window — an open \`release\`-labeled issue
with a non-empty gate — the \`ready\` set is a subset of the gate, \`epic\` and with a non-empty membership record — the \`ready\` set is a subset of that
\`post-merge\` exempt. Every mint during a window is a membership call, binary, record, \`epic\` and \`post-merge\` exempt. Every mint during a window is a
made at mint time: **behind the gate**, this issue's own Dependencies declare membership call, binary, made at mint time: **behind the gate**, this issue's
the release issue as a blocker and the sweep releases it when the release own Dependencies declare the release issue as a blocker and the sweep releases
closes; or **into the graph**, three writes in one tick — this issue declares it when the release closes; or **into the graph**, three writes in one tick —
its immediate predecessors, every member whose immediate predecessor it this issue declares its immediate predecessors, every member whose immediate
becomes re-points to it, and the release issue gains \`Blocked by #N\`, which predecessor it becomes re-points to it, and the release issue gains a row for
records membership and nothing else. Silence is not a state. this issue in its membership record. Silence is not a state.
The gate is read from the release issue's own \`Blocked by\` declarations — the Membership is read from the release issue's own \`## Members\` record: the rows
same parse every \`blocked\` issue is gated on, echoed on that issue. under that heading, one member each, the row's first token a bare \`#N\` and
everything after it prose. A \`Blocked by\` declaration on a release issue
answers its predecessor gate and never its membership (#343).
*Comment only: nothing on this path writes a label or changes a state. The *Comment only: nothing on this path writes a label or changes a state. The
marker carries the window itself, so an unchanged one never re-posts.*" >/dev/null marker carries the window itself, so an unchanged one never re-posts.*" >/dev/null
@ -913,9 +916,14 @@ reconcile_issue() {
local merged_ref_pr="" transition_marker="" transition_handled=false parsed_set="" parse_marker="" local merged_ref_pr="" transition_marker="" transition_handled=false parsed_set="" parse_marker=""
local unchecked="" remove_claimed=claimed local unchecked="" remove_claimed=claimed
local attention_active=true attention_suppression="" local attention_active=true attention_suppression=""
local concluded_queue_state=""
for label in needs-triage epic "${QUEUE_LABELS[@]}"; do
has_issue_label "$label" && concluded_queue_state="$label"
done
decision="$(queue_decision <<<"$ISSUE_LABELS")" decision="$(queue_decision <<<"$ISSUE_LABELS")"
case "$decision" in case "$decision" in
ADD_NEEDS_TRIAGE) ADD_NEEDS_TRIAGE)
concluded_queue_state=needs-triage
run forge_issue_edit "$n" --add-label needs-triage >/dev/null run forge_issue_edit "$n" --add-label needs-triage >/dev/null
log "#$n: needs-triage (no queue state)" ;; log "#$n: needs-triage (no queue state)" ;;
FLAG_CONFLICT) FLAG_CONFLICT)
@ -969,6 +977,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
--remove-label "$remove_claimed" --add-label post-merge >/dev/null --remove-label "$remove_claimed" --add-label post-merge >/dev/null
fi fi
log "#$n: merged Refs PR -> post-merge; claim released" log "#$n: merged Refs PR -> post-merge; claim released"
concluded_queue_state=post-merge
attention_active=false attention_active=false
else else
created="$(jq -r '.created_at' <<<"$ISSUE_JSON")" created="$(jq -r '.created_at' <<<"$ISSUE_JSON")"
@ -999,6 +1008,7 @@ The merge releases the claim; no builder owes a draft. Triage owes completion in
else else
run forge_issue_edit "$n" --remove-label claimed --add-label ready >/dev/null run forge_issue_edit "$n" --remove-label claimed --add-label ready >/dev/null
fi fi
concluded_queue_state=ready
log "#$n: stale claim reclaimed -> ready" ;; log "#$n: stale claim reclaimed -> ready" ;;
esac esac
[ "$decision" != FLAG_UNASSIGNED ] || attention_suppression=claimed-unassigned [ "$decision" != FLAG_UNASSIGNED ] || attention_suppression=claimed-unassigned
@ -1122,6 +1132,7 @@ itself, so a parse unchanged since the last echo never re-posts.*" >/dev/null
ensure_comment "$n" blockers-cleared \ ensure_comment "$n" blockers-cleared \
'Every issue named by `Blocked by` is closed. The sweep is moving this issue to `ready`.' 'Every issue named by `Blocked by` is closed. The sweep is moving this issue to `ready`.'
run forge_issue_edit "$n" --remove-label blocked --add-label ready >/dev/null run forge_issue_edit "$n" --remove-label blocked --add-label ready >/dev/null
concluded_queue_state=ready
log "#$n: blockers closed -> ready" ;; log "#$n: blockers closed -> ready" ;;
esac esac
elif has_issue_label epic; then elif has_issue_label epic; then
@ -1138,7 +1149,7 @@ itself, so a parse unchanged since the last echo never re-posts.*" >/dev/null
1. Mint the window's members. 1. Mint the window's members.
2. Graph hard dependencies and same-file clusters. 2. Graph hard dependencies and same-file clusters.
3. Write ordered waves and the progress task list. 3. Write ordered waves, the \`## Members\` record, and the progress task list.
4. Ask the operator to bless the order, then open the first wave. 4. Ask the operator to bless the order, then open the first wave.
5. Ship the release, close this epic, and trigger the next window. 5. Ship the release, close this epic, and trigger the next window.
@ -1168,7 +1179,7 @@ See \`$release_doctrine_path\`. The operator blessing the order is the one step
# compose with every queue state, and FLAG_CONFLICT's early return still # compose with every queue state, and FLAG_CONFLICT's early return still
# short-circuits them, because a board lying about its queue state is # short-circuits them, because a board lying about its queue state is
# repaired before anything is derived from it. # repaired before anything is derived from it.
reconcile_board_flags "$n" reconcile_board_flags "$n" "$concluded_queue_state"
# ---- the ruling invariants (#52), on any queue state ---- # ---- the ruling invariants (#52), on any queue state ----
# The flag composes with the queue labels (#50 D8), so this runs after the # The flag composes with the queue labels (#50 D8), so this runs after the
@ -1351,7 +1362,7 @@ main() {
done < <(printf '%s' "$b64" | base64 -d | refs_references) done < <(printf '%s' "$b64" | base64 -d | refs_references)
done)" done)"
local n tail_line issue_numbers board_json release_bodies rn rbody gate body local n tail_line issue_numbers board_json release_numbers rn window_records body
local window_rendered="" local window_rendered=""
SKIPPED_COUNT=0 SKIPPED_COUNT=0
SKIPPED_ISSUES="" SKIPPED_ISSUES=""
@ -1376,31 +1387,39 @@ main() {
| @tsv' \ | @tsv' \
<<<"$board_json")" <<<"$board_json")"
issue_numbers="$(cut -f1 <<<"$BOARD_RECORDS")" issue_numbers="$(cut -f1 <<<"$BOARD_RECORDS")"
# A standing window is an open `release`-labeled issue whose gate still # A standing window is an open `release`-labeled issue whose MEMBERSHIP
# holds an OPEN member (#292 D1). The board read IS the open set, so # RECORD still holds an OPEN member (#292 D1 as #343 D3 re-reads it). The
# membership decides openness with no extra call — and an all-closed gate # board read IS the open set, so membership decides openness with no extra
# is exactly the emptied gate the release's own `blocked` -> `ready` # call — and an all-closed record is exactly the emptied window the
# promotion answers, which is why a `ready` release leaves the flag # release's own `blocked` -> `ready` promotion answers, which is why a
# dormant rather than flagging the whole board. # `ready` release leaves the flag dormant rather than flagging the board.
release_bodies="$(jq -r '.[] | select(.pull_request == null) #
# The record is read by heading, so each body must reach the parse with its
# LINE STRUCTURE INTACT. Taking it from the board payload by issue number
# preserves that structure without fetching a second, disagreeing board.
release_numbers="$(jq -r '.[] | select(.pull_request == null)
| select((.labels // []) | map(.name) | index("release")) | select((.labels // []) | map(.name) | index("release"))
| [(.number | tostring), ((.body // "") | gsub("[\t\r\n]"; " "))] | @tsv' \ | .number' \
<<<"$board_json")" <<<"$board_json")"
WINDOW_CARRIERS="" WINDOW_CARRIERS=""
WINDOW_GATE="" WINDOW_MEMBERS=""
if [ -n "$issue_numbers" ]; then if [ -n "$issue_numbers" ]; then
while IFS=$'\t' read -r rn rbody; do window_records="$(
[ -n "$rn" ] || continue while IFS= read -r rn; do
gate="$(blocked_references <<<"$rbody")" [ -n "$rn" ] || continue
[ -n "$gate" ] || continue jq -r --argjson n "$rn" '.[] | select(.pull_request == null)
grep -qxF -f <(printf '%s\n' "$issue_numbers") <<<"$gate" || continue | select(.number == $n) | .body // ""' <<<"$board_json" \
WINDOW_CARRIERS="${WINDOW_CARRIERS}${rn}"$'\n' | release_window_members "$rn" "$issue_numbers"
WINDOW_GATE="${WINDOW_GATE}${gate}"$'\n' done <<<"$release_numbers"
done <<<"$release_bodies" )"
# One record per parsed non-self member keeps the carrier decision and
# its WINDOW_MEMBERS contribution coupled to the extracted function.
WINDOW_CARRIERS="$(cut -f1 <<<"$window_records" | awk 'NF' | sort -nu)"
WINDOW_MEMBERS="$(cut -f2 <<<"$window_records" | awk 'NF' | sort -nu)"
fi fi
[ -z "$WINDOW_CARRIERS" ] || window_rendered="$(window_state "$WINDOW_CARRIERS")" [ -z "$WINDOW_CARRIERS" ] || window_rendered="$(window_state "$WINDOW_CARRIERS")"
COLLISION_FLAGS="$(collision_key_index <<<"$BOARD_RECORDS" | collision_flags)" COLLISION_FLAGS="$(collision_key_index <<<"$BOARD_RECORDS" | collision_flags)"
WINDOW_FLAGS="$(window_flags "$WINDOW_GATE" "$WINDOW_CARRIERS" <<<"$BOARD_RECORDS" \ WINDOW_FLAGS="$(window_flags "$WINDOW_MEMBERS" "$WINDOW_CARRIERS" <<<"$BOARD_RECORDS" \
| awk -v state="$window_rendered" 'NF { print $1 "\t" state }')" | awk -v state="$window_rendered" 'NF { print $1 "\t" state }')"
if [ -z "$issue_numbers" ]; then if [ -z "$issue_numbers" ]; then
log "no open issues." log "no open issues."

View file

@ -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" grep -qF '<!-- issueflow:release-init-due -->' "$TMP/posted-53"
check "the init announce names all five steps" 0 "5" \ check "the init announce names all five steps" 0 "5" \
grep -cE '^[1-5]\. ' "$TMP/posted-53" grep -cE '^[1-5]\. ' "$TMP/posted-53"
# Release-init is where the membership record is first written, so step 3
# names it beside the waves and the progress task list.
# shellcheck disable=SC2016 # backticks are the comment body's own Markdown
check "step 3 names the membership record it first writes" 0 "" \
grep -qF '3. Write ordered waves, the `## Members` record' "$TMP/posted-53"
# shellcheck disable=SC2016 # backticks are the literal portable doctrine citation # shellcheck disable=SC2016 # backticks are the literal portable doctrine citation
check "the init announce cites the portable vendored doctrine path" 0 "" \ check "the init announce cites the portable vendored doctrine path" 0 "" \
grep -qF 'See `.ceremony/RELEASES.md`.' "$TMP/posted-53" grep -qF 'See `.ceremony/RELEASES.md`.' "$TMP/posted-53"
@ -714,9 +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 ------------ # -- merged Refs work releases the claim before the reclaim clock ------------
printf '[]\n' >"$(cfix 35)" printf '[]\n' >"$(cfix 35)"
COLLISION_FLAGS=$'35\tissueflow-reconcile=34'
WINDOW_FLAGS=$'35\t#50'
transition="$(issue_probe 35 claimed 1 false 350 $'- [x] built\n- [ ] verify dispatch\n * [ ] confirm warning clears')" transition="$(issue_probe 35 claimed 1 false 350 $'- [x] built\n- [ ] verify dispatch\n * [ ] confirm warning clears')"
unset COLLISION_FLAGS WINDOW_FLAGS
check "merged Refs + unchecked criteria transitions in the sweep body" 0 "" \ check "merged Refs + unchecked criteria transitions in the sweep body" 0 "" \
grep -q 'merged Refs PR -> post-merge; claim released' <<<"$transition" grep -q 'merged Refs PR -> post-merge; claim released' <<<"$transition"
check "a pass concluding post-merge draws no precomputed collision flag" 1 "" \
grep -q 'collision flag' <<<"$transition"
check "...and no precomputed window flag" 1 "" \
grep -q 'window flag' <<<"$transition"
# shellcheck disable=SC2016 # positional parameters belong to bash -c # shellcheck disable=SC2016 # positional parameters belong to bash -c
check "...names every remaining criterion verbatim in the comment" 0 "" \ check "...names every remaining criterion verbatim in the comment" 0 "" \
bash -c 'grep -qF -- "- [ ] verify dispatch" "$1" && bash -c 'grep -qF -- "- [ ] verify dispatch" "$1" &&
@ -1821,12 +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 "" \ check "...and the object-valued PR row is not reconciled as an issue" 1 "" \
grep -qE '^issueflow: #61' <<<"$fjb_out" grep -qE '^issueflow: #61' <<<"$fjb_out"
# release_bodies is the THIRD producer and has its own has() site. A `release` # release_numbers is the THIRD producer and has its own issue-shape filter. A `release`
# issue on a forgejo-shaped board must reach the window gather, or the #292 # issue on a forgejo-shaped board must reach the window gather, or the #292
# flags are decided over an empty set (@codex-reviewer-andresmgsl, #210). # flags are decided over an empty set (@codex-reviewer-andresmgsl, #210).
printf '%s\n' \ printf '%s\n' \
'[{"number":60,"pull_request":null,"labels":[{"name":"ready"}],"title":"an issue"}, '[{"number":60,"pull_request":null,"labels":[{"name":"ready"}],"title":"an issue"},
{"number":62,"pull_request":null,"labels":[{"name":"release"}],"title":"Release 9.9.9","body":"Blocked by #60."}, {"number":62,"pull_request":null,"labels":[{"name":"release"}],"title":"Release 9.9.9","body":"Blocked by #59.\n\n## Members\n- #60"},
{"number":63,"pull_request":null,"labels":[{"name":"ready"}],"title":"a claimable non-member"}, {"number":63,"pull_request":null,"labels":[{"name":"ready"}],"title":"a claimable non-member"},
{"number":61,"pull_request":{"merged":false},"labels":[],"title":"a pull request"}]' \ {"number":61,"pull_request":{"merged":false},"labels":[],"title":"a pull request"}]' \
>"$FORGEJO_BOARD/repos_owner_repo_issues_state_open.json" >"$FORGEJO_BOARD/repos_owner_repo_issues_state_open.json"
@ -1836,13 +1848,13 @@ jq -n --arg at "$(iso_at "$INOW")" \
>"$FORGEJO_BOARD/repos_owner_repo_issues_63.json" >"$FORGEJO_BOARD/repos_owner_repo_issues_63.json"
printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_issues_63_comments.json" printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_issues_63_comments.json"
jq -n --arg at "$(iso_at "$INOW")" \ jq -n --arg at "$(iso_at "$INOW")" \
'{number:62,user:{login:"triage-one"},created_at:$at,body:"Blocked by #60.",pull_request:null, '{number:62,user:{login:"triage-one"},created_at:$at,body:"Blocked by #59.\n\n## Members\n- #60",pull_request:null,
labels:[{name:"release"}],assignees:[]}' \ labels:[{name:"release"}],assignees:[]}' \
>"$FORGEJO_BOARD/repos_owner_repo_issues_62.json" >"$FORGEJO_BOARD/repos_owner_repo_issues_62.json"
printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_issues_62_comments.json" printf '[]\n' >"$FORGEJO_BOARD/repos_owner_repo_issues_62_comments.json"
fjb2_out="$(forgejo_board_run)" fjb2_out="$(forgejo_board_run)"
# The observable effect of release_bodies being NON-empty: an open `release` # The observable effect of release_numbers being NON-empty: an open `release`
# issue whose gate still holds an open member makes every claimable non-member # issue whose membership record still holds an open member makes every claimable non-member
# draw a window flag. With that gather empty there are no carriers and no flag, # draw a window flag. With that gather empty there are no carriers and no flag,
# so this row discriminates the site rather than merely reaching it. # so this row discriminates the site rather than merely reaching it.
check "a release issue on a forgejo-shaped board reaches the window gather" 0 "" \ check "a release issue on a forgejo-shaped board reaches the window gather" 0 "" \
@ -2683,7 +2695,7 @@ board_run() {
# them — six `ready` non-members against a standing gate, and one deliverable # them — six `ready` non-members against a standing gate, and one deliverable
# carried three times in two spellings. # carried three times in two spellings.
board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \
'Blocked by #253.' "$(printf '%s\n' 'Blocked by #253.' '' '## Members' '- #253')"
board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1 board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1
board_issue 257 ready 'actions/issueflow-reconcile — a failed board read sweeps an empty board' board_issue 257 ready 'actions/issueflow-reconcile — a failed board read sweeps an empty board'
board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag' board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag'
@ -2743,7 +2755,18 @@ check "the window comment names #292's invariant" 0 "" \
grep -qF "#292's invariant" "$BOARD/edits" grep -qF "#292's invariant" "$BOARD/edits"
# shellcheck disable=SC2016 # backticks are the comment body's own Markdown # shellcheck disable=SC2016 # backticks are the comment body's own Markdown
check "...and states the subset rule with its exemptions" 0 "" \ check "...and states the subset rule with its exemptions" 0 "" \
grep -qF 'the `ready` set is a subset of the gate' "$BOARD/edits" grep -qF 'the `ready` set is a subset of that' "$BOARD/edits"
# shellcheck disable=SC2016 # backticks are the comment body's own Markdown
check "...and tells triage where membership is actually read from" 0 "" \
grep -qF 'Membership is read from the release issue'"'"'s own `## Members` record' \
"$BOARD/edits"
# shellcheck disable=SC2016 # backticks are the comment body's own Markdown
check "...and says what a release issue's Blocked by line does answer" 0 "" \
grep -qF 'answers its predecessor gate and never its membership' "$BOARD/edits"
check "...and asks the third write for a row, not a declaration" 0 "" \
grep -qF 'the release issue gains a row for' "$BOARD/edits"
check "no window comment sends triage to a Blocked by declaration" 1 "" \
grep -qF 'The gate is read from the release issue' "$BOARD/edits"
check "both comments carry idempotency markers (D4)" 0 "" \ check "both comments carry idempotency markers (D4)" 0 "" \
grep -qF '<!-- issueflow:collision-' "$BOARD/edits" grep -qF '<!-- issueflow:collision-' "$BOARD/edits"
check "...the window one too" 0 "" grep -qF '<!-- issueflow:window-nonmember-' "$BOARD/edits" check "...the window one too" 0 "" grep -qF '<!-- issueflow:window-nonmember-' "$BOARD/edits"
@ -2808,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 # oldest-first, the reconciler chain chained, and every one of them a gate
# member. Every flag above must go quiet, or the flag is reporting the fix. # member. Every flag above must go quiet, or the flag is reporting the fix.
board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \
'Blocked by #253, #257, #264, #266, #276, #281, #282, #284.' "$(printf '%s\n' 'Blocked by #253.' '' '## Members' \
'- #253' '- #257' '- #264' '- #266' '- #276' '- #281' '- #282' '- #284')"
board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1 board_issue 253 claimed 'issueflow-reconcile — a release epic announces its own release-init' '' 1
board_issue 257 blocked 'actions/issueflow-reconcile — a failed board read sweeps an empty board' \ board_issue 257 blocked 'actions/issueflow-reconcile — a failed board read sweeps an empty board' \
'Blocked by #253.' 'Blocked by #253.'
@ -2831,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.' \ check "...and still reports a whole pass" 0 'issueflow: reconciled.' \
printf '%s\n' "$ruled_out" printf '%s\n' "$ruled_out"
# -- an emptied gate leaves the window flag dormant (test plan) ------------- # -- an emptied window leaves the flag dormant (test plan) ------------------
# A gate DECLARATION never empties: #249 names fifteen members and still names # A membership RECORD never empties: #249 names fifteen members and still
# fifteen after all fifteen close. So the precondition is the gate's OPEN # names fifteen after all fifteen close. So the precondition is the record's OPEN
# members, not its parse — read straight off the board, which already is the # members, not its parse — read straight off the board, which already is the
# open set. Under the declaration reading the release issue, now `ready`, is # open set. Under the declaration reading the release issue, now `ready`, is
# itself an open unblocked non-`epic` non-member, and D3 would flag the sink # itself an open unblocked non-`epic` non-member, and D3 would flag the sink
# at the exact moment the window ends. # at the exact moment the window ends.
board_issue 249 ready,release 'Release 0.6.0 — the board empties into the tag' \ board_issue 249 ready,release 'Release 0.6.0 — the board empties into the tag' \
'Blocked by #218, #230, #232, #236, #237, #238, #241, #242, #247, #248, #251, #252, #253, #254, #257.' "$(printf '%s\n' 'Blocked by #217.' '' '## Members' \
'- #218' '- #230' '- #232' '- #236' '- #237' '- #238' '- #241' '- #242' \
'- #247' '- #248' '- #251' '- #252' '- #253' '- #254' '- #257')"
board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag' board_issue 264 ready 'TRIAGE.md — the no-assignee clause scopes to the flag'
# shellcheck disable=SC2016 # the backticks are the real issue title's Markdown # shellcheck disable=SC2016 # the backticks are the real issue title's Markdown
board_issue 266 ready 'TRIAGE.md — the epic task-list heading is literally `## Task list`' board_issue 266 ready 'TRIAGE.md — the epic task-list heading is literally `## Task list`'
board_assemble 249 264 266 board_assemble 249 264 266
empty_gate_out="$(board_run)" empty_gate_out="$(board_run)"
check "a fifteen-member declaration with every member closed leaves D3 dormant" 1 "" \ check "a fifteen-member record with every member closed leaves D3 dormant" 1 "" \
grep -qF ': window flag' <<<"$empty_gate_out" grep -qF ': window flag' <<<"$empty_gate_out"
check "...and the release issue is never flagged as its own non-member" 1 "" \ check "...and the release issue is never flagged as its own non-member" 1 "" \
grep -qF 'issueflow: #249' <<<"$empty_gate_out" grep -qF 'issueflow: #249' <<<"$empty_gate_out"
@ -2892,7 +2918,7 @@ printf '%s\n' \
'{"data":{"repository":{"pullRequests":{"nodes":[{"number":403,"body":"","closingIssuesReferences":{"nodes":[{"number":402}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ '{"data":{"repository":{"pullRequests":{"nodes":[{"number":403,"body":"","closingIssuesReferences":{"nodes":[{"number":402}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
>"$BOARD/repos_owner_repo_pulls_state_open.json" >"$BOARD/repos_owner_repo_pulls_state_open.json"
board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \
'Blocked by #253.' "$(printf '%s\n' 'Blocked by #253.' '' '## Members' '- #253')"
board_issue 253 claimed 'issueflow-reconcile — a member holding the window open' '' 1 board_issue 253 claimed 'issueflow-reconcile — a member holding the window open' '' 1
board_issue 402 claimed 'REVIEWER.md — a non-member with a builder and a round' '' 1 board_issue 402 claimed 'REVIEWER.md — a non-member with a builder and a round' '' 1
board_assemble 249 253 402 board_assemble 249 253 402
@ -2940,7 +2966,7 @@ printf '%s\n' \
'{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \ '{"data":{"repository":{"pullRequests":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' \
>"$BOARD/repos_owner_repo_pulls_state_open.json" >"$BOARD/repos_owner_repo_pulls_state_open.json"
board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \ board_issue 249 blocked,release 'Release 0.6.0 — the board empties into the tag' \
'Blocked by #293, #307.' "$(printf '%s\n' 'Blocked by #307.' '' '## Members' '- #293' '- #307')"
board_issue 293 claimed 'issueflow-reconcile — the sweep flags what the window and collision rules forbid' '' 1 board_issue 293 claimed 'issueflow-reconcile — the sweep flags what the window and collision rules forbid' '' 1
board_issue 307 blocked 'test/issueflow-reconcile.test.sh — the ruling pre-read is unpinned' \ board_issue 307 blocked 'test/issueflow-reconcile.test.sh — the ruling pre-read is unpinned' \
'Blocked by #293.' 'Blocked by #293.'
@ -2953,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.' \ check "...and still reports a whole pass" 0 'issueflow: reconciled.' \
printf '%s\n' "$today_out" printf '%s\n' "$today_out"
# -- a predecessor gate is not a membership record (#343) ------------------
board_issue 317 epic,release '0.7.0 — rc becomes native' 'Blocked by #249.'
board_issue 249 epic,release 'Release 0.6.0 — the predecessor, still open' ''
board_issue 343 ready 'RELEASES.md + TRIAGE.md — membership gets its own record'
board_issue 345 ready 'actions/issueflow-reconcile — a failed dependency read'
board_assemble 317 249 343 345
shut_window_out="$(board_run)"
check "the shut window's board replays green" 0 "" test $? -eq 0
check "an epic declaring an open predecessor stands no window" 1 "" \
grep -qF ': window flag' <<<"$shut_window_out"
check "...so the ready issues it would have accused are left alone" 1 "" \
grep -qE '#(343|345): window flag' <<<"$shut_window_out"
check "...and no window state naming it is ever rendered" 1 "" \
grep -qF 'under #317' <<<"$shut_window_out"
check "...and the board is still swept whole" 0 'issueflow: reconciled.' \
printf '%s\n' "$shut_window_out"
# Over-correction control: once the record enumerates an open member, the
# window stands and still flags the unblocked non-member.
board_issue 317 epic,release '0.7.0 — rc becomes native' \
"$(printf '%s\n' 'Blocked by #249.' '' '## Members' '- #343 — the first member')"
board_assemble 317 249 343 345
opened_window_out="$(board_run)"
check "the same epic enumerating an open member does stand a window" 0 \
'issueflow: #345: window flag — an unblocked non-member under #317' \
printf '%s\n' "$opened_window_out"
check "...and its enumerated member is not flagged" 1 "" \
grep -qF 'issueflow: #343: window flag' <<<"$opened_window_out"
check "...one window flag on that board, and only one" 0 "1" \
flag_count window "$opened_window_out"
# -- the invariant is enforced at the source, not remembered ---------------- # -- the invariant is enforced at the source, not remembered ----------------
# Staging only holds while every mutation goes through run(). A future call # Staging only holds while every mutation goes through run(). A future call
# site reaching gh directly would reopen this hole silently, so it is pinned # site reaching gh directly would reopen this hole silently, so it is pinned