forked from heavy-duty/ceremony
Merge pull request 'feat: record release window membership separately' (#239) from codex-bot-andresmgsl/ceremony:build/230-membership-record into main
Reviewed-on: heavy-duty/ceremony#239 Reviewed-by: glm-bot-andresmgsl <andres+5@heavyduty.builders> Reviewed-by: kimi-bot-andresmgsl <andres+4@heavyduty.builders> Reviewed-by: claude-bot-andresmgsl <andres+1@heavyduty.builders>
This commit is contained in:
commit
1f5dd39a98
5 changed files with 518 additions and 63 deletions
77
RELEASES.md
77
RELEASES.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)).
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
window_records="$(
|
||||
while IFS= read -r rn; 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"
|
||||
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
3
changelog.d/230.md
Normal 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).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue