Merge pull request #63 from codex-bot-andresmgsl/build/61-cross-repo-reference-guards

fix: guard cross-repo issue references
This commit is contained in:
Daniel Marin 2026-07-23 13:24:21 +01:00 committed by GitHub
commit 9c943f5f5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 74 additions and 11 deletions

View file

@ -6,6 +6,7 @@ so entries say what changed, cite the issue, and stop.
## Unreleased ## Unreleased
- `issueflow-reconcile` — keep cross-repo references out of local dependency decisions and require triage to resolve cross-repo blockers by hand (#61).
- `needs-ruling` — the cross-cutting flag for a pending human decision, excluded from `state:needs-human` and from the staleness sweep (#51). - `needs-ruling` — the cross-cutting flag for a pending human decision, excluded from `state:needs-human` and from the staleness sweep (#51).
- Cross-repo doctrine: the panel is the PR's repo's roster, a review request is authorization but not panel membership, and `Part of <repo>#N` replaces the `Closes #N` that cannot cross repos (#57). - Cross-repo doctrine: the panel is the PR's repo's roster, a review request is authorization but not panel membership, and `Part of <repo>#N` replaces the `Closes #N` that cannot cross repos (#57).

View file

@ -65,7 +65,9 @@ Every issue you mint carries, in this order:
the builder's definition of done and the reviewer's review spec, verbatim. the builder's definition of done and the reviewer's review spec, verbatim.
- **Test plan**: what proves it, including the cases that must fail. - **Test plan**: what proves it, including the cases that must fail.
- **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an - **Dependencies**: `Blocked by #N` / `Blocks #N`, and `Part of #E` when an
epic organizes it. epic organizes it. Name a cross-repo dependency the same way with its
repository qualified (`Blocked by repo#N` or `owner/repo#N`); the sweep
cannot resolve it, so triage verifies it and flips the issue by hand.
- **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and - **Labels**: type (`bug`/`enhancement`/`documentation`), `scope:*`, and
exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)). exactly one of `ready` / `blocked` (see [LABELS.md](LABELS.md)).

View file

@ -105,7 +105,17 @@ claim_reclaim_marker() { # $1 = last activity epoch
printf 'claim-reclaimed-%s\n' "$1" printf 'claim-reclaimed-%s\n' "$1"
} }
blocked_references() { # body on stdin -> issue numbers, one per line issue_references() { # text on stdin -> LOCAL/CROSS<TAB>reference
# A qualified reference belongs to another repository. Classify the whole
# token before extracting numbers so rig#112 can never become local #112.
{ grep -Eo '([[:alnum:]_.-]+/)?[[:alnum:]_.-]+#[0-9]+|#[0-9]+' || true; } \
| awk '
index($0, "#") == 1 { print "LOCAL\t" substr($0, 2); next }
{ print "CROSS\t" $0 }
'
}
blocked_reference_records() { # body on stdin -> classified reference records
# Dependency declarations sometimes soft-wrap after a comma. Continue # Dependency declarations sometimes soft-wrap after a comma. Continue
# through the first sentence terminator; if prose omits one, conservatively # through the first sentence terminator; if prose omits one, conservatively
# retain later references so ambiguity can keep an issue blocked, never # retain later references so ambiguity can keep an issue blocked, never
@ -128,13 +138,21 @@ blocked_references() { # body on stdin -> issue numbers, one per line
} }
print line print line
} }
' \ ' | issue_references
| { grep -Eo '#[0-9]+' || true; } | tr -d '#' | sort -nu
} }
blocked_decision() { # $1 refs, $2 OPEN/CLOSED states blocked_references() { # body on stdin -> local issue numbers, one per line
local refs="$1" states="$2" blocked_reference_records | awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
if [ -z "$refs" ]; then echo FLAG_UNPARSEABLE }
blocked_cross_references() { # body on stdin -> qualified refs, one per line
blocked_reference_records | awk -F '\t' '$1 == "CROSS" { print $2 }' | sort -u
}
blocked_decision() { # $1 local refs, $2 OPEN/CLOSED states, $3 cross-repo refs
local refs="$1" states="$2" cross_refs="${3:-}"
if [ -n "$cross_refs" ]; then echo FLAG_CROSS_REPO
elif [ -z "$refs" ]; then echo FLAG_UNPARSEABLE
elif grep -qxF OPEN <<<"$states"; then echo KEEP elif grep -qxF OPEN <<<"$states"; then echo KEEP
elif grep -qxF UNKNOWN <<<"$states"; then echo FLAG_UNPARSEABLE elif grep -qxF UNKNOWN <<<"$states"; then echo FLAG_UNPARSEABLE
else echo READY else echo READY
@ -146,8 +164,8 @@ epic_references() { # markdown task-list issue references from body on stdin
tolower($0) ~ /^##[[:space:]]+task list[[:space:]]*$/ { in_list = 1; next } tolower($0) ~ /^##[[:space:]]+task list[[:space:]]*$/ { in_list = 1; next }
in_list && /^#/ { exit } in_list && /^#/ { exit }
in_list && /^[[:space:]]*[-*][[:space:]]+\[[ xX]\]/ { print } in_list && /^[[:space:]]*[-*][[:space:]]+\[[ xX]\]/ { print }
' \ ' | issue_references \
| { grep -Eo '#[0-9]+' || true; } | tr -d '#' | sort -nu | awk -F '\t' '$1 == "LOCAL" { print $2 }' | sort -nu
} }
epic_decision() { # $1 refs, $2 states epic_decision() { # $1 refs, $2 states
@ -190,7 +208,7 @@ last_issue_activity() {
} }
reconcile_issue() { reconcile_issue() {
local n="$1" decision refs states age assignees open_pr=false label owners local n="$1" decision refs cross_refs states age assignees open_pr=false label owners
decision="$(queue_decision <<<"$ISSUE_LABELS")" decision="$(queue_decision <<<"$ISSUE_LABELS")"
case "$decision" in case "$decision" in
ADD_NEEDS_TRIAGE) ADD_NEEDS_TRIAGE)
@ -229,9 +247,13 @@ reconcile_issue() {
esac esac
elif has_issue_label blocked; then elif has_issue_label blocked; then
refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")" refs="$(blocked_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
cross_refs="$(blocked_cross_references <<<"$(jq -r '.body // ""' <<<"$ISSUE_JSON")")"
states="$(reference_states <<<"$refs")" states="$(reference_states <<<"$refs")"
decision="$(blocked_decision "$refs" "$states")" decision="$(blocked_decision "$refs" "$states" "$cross_refs")"
case "$decision" in case "$decision" in
FLAG_CROSS_REPO)
ensure_comment "$n" blocked-cross-repo \
"This issue's \`Blocked by\` declaration names cross-repo dependencies that the sweep cannot resolve: $(tr '\n' ' ' <<<"$cross_refs" | sed 's/[[:space:]]*$//'). Triage must verify those dependencies and flip this issue to \`ready\` by hand." ;;
FLAG_UNPARSEABLE) FLAG_UNPARSEABLE)
ensure_comment "$n" blocked-unparseable \ ensure_comment "$n" blocked-unparseable \
'This issue is `blocked`, but its body has no parseable `Blocked by #N` declaration. The sweep will not guess the dependency.' ;; 'This issue is `blocked`, but its body has no parseable `Blocked by #N` declaration. The sweep will not guess the dependency.' ;;

View file

@ -90,10 +90,43 @@ check "real issue 15 inline blocker parses" 0 "" test \
body="Part of #1. Blocked by #11, #12 (needs a released ceremony + the bootstrap guide); benefits from #13's lessons but does not need #14/#15." body="Part of #1. Blocked by #11, #12 (needs a released ceremony + the bootstrap guide); benefits from #13's lessons but does not need #14/#15."
check "real issue 16 inline blockers parse" 0 "" test \ check "real issue 16 inline blockers parse" 0 "" test \
"$(blocked_references <<<"$body")" = $'11\n12' "$(blocked_references <<<"$body")" = $'11\n12'
check "qualified short repository reference drops" 0 "" test \
-z "$(blocked_references <<<"Blocked by rig#112.")"
check "qualified owner/repository reference drops" 0 "" test \
-z "$(blocked_references <<<"Blocked by heavy-duty/box#9.")"
check "parenthesized local reference survives" 0 "13" blocked_references <<<"Blocked by (#13)."
check "slash-adjacent local references survive" 0 $'14\n15' \
blocked_references <<<"Blocked by #14/#15."
check "comma-adjacent local references survive" 0 $'11\n12' \
blocked_references <<<"Blocked by #11, #12."
check "open blocker keeps issue blocked" 0 "KEEP" blocked_decision "$refs" $'CLOSED\nOPEN' check "open blocker keeps issue blocked" 0 "KEEP" blocked_decision "$refs" $'CLOSED\nOPEN'
check "all closed blockers release issue" 0 "READY" blocked_decision "$refs" $'CLOSED\nCLOSED' check "all closed blockers release issue" 0 "READY" blocked_decision "$refs" $'CLOSED\nCLOSED'
check "missing blocked declaration is flagged" 0 "FLAG_UNPARSEABLE" blocked_decision "" "" check "missing blocked declaration is flagged" 0 "FLAG_UNPARSEABLE" blocked_decision "" ""
check "unreadable blocker is flagged" 0 "FLAG_UNPARSEABLE" blocked_decision "12" "UNKNOWN" check "unreadable blocker is flagged" 0 "FLAG_UNPARSEABLE" blocked_decision "12" "UNKNOWN"
check "cross-repo-only blocker is flagged distinctly" 0 "FLAG_CROSS_REPO" \
blocked_decision "" "" "rig#112"
check "cross-repo blocker prevents false promotion when locals close" 0 "FLAG_CROSS_REPO" \
blocked_decision "9" "CLOSED" "rig#9"
# shellcheck disable=SC2016 # expansions belong to the generated fake gh
printf '%s\n' \
'#!/usr/bin/env bash' \
'if [ "$1" = api ]; then [ ! -f "$GH_COMMENTS" ] || cat "$GH_COMMENTS"; exit; fi' \
'if [ "$1 $2" = "issue comment" ]; then' \
' while [ "$#" -gt 0 ]; do' \
' if [ "$1" = --body ]; then shift; printf "%s\n" "$1" >>"$GH_COMMENTS"; exit; fi' \
' shift' \
' done' \
'fi' >"$TMP/gh"
chmod +x "$TMP/gh"
: >"$TMP/comments"
# shellcheck disable=SC2016 # expansions belong to the isolated bash -c process
check "cross-repo warning is idempotent across two sweeps" 0 "" \
env PATH="$TMP:$PATH" GH_COMMENTS="$TMP/comments" bash -c \
'source "$1"; REPO=heavy-duty/ceremony
ensure_comment 99 blocked-cross-repo "cross-repo warning"
ensure_comment 99 blocked-cross-repo "cross-repo warning"
test "$(grep -cF "<!-- issueflow:blocked-cross-repo -->" "$GH_COMMENTS")" -eq 1' \
_ "$ROOT/actions/issueflow-reconcile/issueflow-reconcile.sh"
# Invariant 4: only configured triage actors mint directly into the queue. # Invariant 4: only configured triage actors mint directly into the queue.
check "triage-authored ready issue is accepted" 0 "KEEP" author_decision true <<<"ready" check "triage-authored ready issue is accepted" 0 "KEEP" author_decision true <<<"ready"
@ -107,6 +140,11 @@ check "epic parser reads task-list refs only" 0 $'2\n3' printf '%s\n' "$epic_ref
body=$'## Task list\n- [x] #2 done\n- [x] #3 done\n\n## Definition of done\n- [ ] open issue #99 must not suppress the nudge' body=$'## Task list\n- [x] #2 done\n- [x] #3 done\n\n## Definition of done\n- [ ] open issue #99 must not suppress the nudge'
check "epic parser stops before later checkbox sections" 0 "" test \ check "epic parser stops before later checkbox sections" 0 "" test \
"$(epic_references <<<"$body")" = $'2\n3' "$(epic_references <<<"$body")" = $'2\n3'
# shellcheck disable=SC2016 # backticks and ${{ }}-shaped prose are fixture literals
body=$'## Task list\n\n- [x] #2 Scaffold: layout, test harness, shellcheck + actionlint CI\n- [x] #3 `lib/version.sh` — one version abstraction, two backends\n- [x] #4 `lib/changelog.sh` — the one canonical section extractor\n- [x] #5 `actions/changelog-armed` — the version-keyed arming guard\n- [x] #6 `actions/changelog-monotonic` — shipped release headings are append-only\n- [x] #7 `actions/drill-recorded` — a release carries its evidence\n- [x] #8 `lib/decide.sh` — the merge door'\''s decision, pure\n- [x] #9 The reusable release workflow — both doors, one implementation\n- [x] #10 Labels machinery: reusable workflow + core/scope split\n- [x] #11 Dogfood: ceremony releases itself (0.1.0) — **shipped: tag `0.1.0`, release, `drills/0.1.0.md`; main re-armed at `0.1.1-dev`**\n- [x] #12 `docs/CONSUMERS.md` + README doctrine\n- [ ] #13 Convert rig (pilot) — **PR [rig#112](https://github.com/heavy-duty/rig/pull/112) is approved by the whole panel on head `3c72c1b` and sits at `state:needs-human` since 2026-07-23 10:47Z; the merge is the human'\''s. #14/#15 unblock when it lands**\n- [ ] #14 Convert box\n- [ ] #15 Convert cast (artifact hook debut)\n- [ ] #16 Adopt in incubator (greenfield consumer)\n\nAdjacent, same repo, separable from the release chain: the **agent team flow** (discussion → triage → issue → build → review → human merge) landed as doctrine in PR #17 (CONTRIBUTING.md, LABELS.md, TRIAGE.md, BUILDER.md, REVIEWER.md); #10'\''s bootstrap carries its labels, #12'\''s guide carries its adoption checklist. Consumption is split by what has a runtime: **machinery by reference** (GitHub materializes pinned workflows/actions at run time), **doctrine as a machine-verified mirror** (`.ceremony/` in each consumer, byte-identical to the pin, CI-guarded — agents read rules from the checkout, never cross-repo):\n\n- [x] #18 Issue-flow reconciliation — the work-queue sweep — **shipped 2026-07-23** in #32 (`66f1c08`)\n- [ ] #61 issueflow-reconcile — cross-repo references must not be read as local issue numbers (found in triage hygiene against the live corpus after #18 shipped; it is why this epic cannot currently be nudged complete)\n- [x] #19 actions/docs-sync — the vendored-doctrine mirror + guard\n- [x] #24 Entry templates — the pipeline'\''s doors made mechanical (from discussion #23)\n- [ ] #50 `needs-ruling` — the pending-human-decision flag (its own epic; from discussion #30)\n- [ ] #56 Fleet scope and cross-repo discovery — the two guards, the runner hole, the roster question (its own epic; from discussion #55, filed at @danmt'\''s request). Children: #57 (BUILDER/REVIEWER/FLEET discovery guards), #58 (`actions/runner-isolated`). Added to this list by triage 2026-07-23 — it is agent-team-flow work like #50, so a scan of this epic must see it.'
check "real epic 1 task list drops rig PR and retains local references" 0 \
$'2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n18\n19\n23\n24\n30\n32\n50\n55\n56\n57\n58\n61' \
epic_references <<<"$body"
check "completed epic is nudged" 0 "NUDGE" epic_decision "$epic_refs" $'CLOSED\nCLOSED' check "completed epic is nudged" 0 "NUDGE" epic_decision "$epic_refs" $'CLOSED\nCLOSED'
check "open epic child suppresses nudge" 0 "KEEP" epic_decision "$epic_refs" $'CLOSED\nOPEN' check "open epic child suppresses nudge" 0 "KEEP" epic_decision "$epic_refs" $'CLOSED\nOPEN'
check "epic without parseable children is stable" 0 "KEEP" epic_decision "" "" check "epic without parseable children is stable" 0 "KEEP" epic_decision "" ""