From 7d496c48ae47c1c23a83c6b11e7cf49430021ad2 Mon Sep 17 00:00:00 2001 From: don-petry <{}+don-petry@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:15:12 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20implement=20issue=20#2086=20?= =?UTF-8?q?=E2=80=94=20dev-lead-retry:=20resolve=20the=20dev-lead=20channe?= =?UTF-8?q?l=20pin=20per=20target=20repo,=20not=20once=20for=20the=20whole?= =?UTF-8?q?=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-lead-retry.yml | 5 + scripts/dev-lead-retry.sh | 30 +- scripts/lib/dispatch-channel-gate.sh | 119 +++++++ .../unit/test_dispatch_channel_gate.bats | 294 ++++++++++++++++++ 4 files changed, 447 insertions(+), 1 deletion(-) create mode 100644 scripts/lib/dispatch-channel-gate.sh create mode 100644 tests/dev-lead/unit/test_dispatch_channel_gate.bats diff --git a/.github/workflows/dev-lead-retry.yml b/.github/workflows/dev-lead-retry.yml index bbbc30788..833fd19aa 100644 --- a/.github/workflows/dev-lead-retry.yml +++ b/.github/workflows/dev-lead-retry.yml @@ -31,6 +31,11 @@ name: Dev-Lead Retry (rate-limited) # whose registered reviewer-bot issue comment has no covering disposition (and no # info_status_pattern match) gets one deduplicated fix-bot-comment retry per scan, # addressed by the comment's node id (scan_pr_for_undispositioned_bot_comments). +# +# Each target repo runs its harness at its OWN dev-lead.yml pin, so the sweep +# skips (with a ::warning::) any repo pinned to an older channel than the sweep's. +# Such a repo could be sent a client_payload field it cannot read yet (#2086, +# scripts/lib/dispatch-channel-gate.sh). on: schedule: diff --git a/scripts/dev-lead-retry.sh b/scripts/dev-lead-retry.sh index a76cd13b9..0cd603046 100755 --- a/scripts/dev-lead-retry.sh +++ b/scripts/dev-lead-retry.sh @@ -38,6 +38,8 @@ set -euo pipefail # DISPATCH_DELAY_SEC — seconds between repo dispatches (default: 30) to # prevent cascading org-wide rate-limit hits # DRY_RUN — if "true", log what would be dispatched but don't send +# SWEEP_AGENT_REF — override the sweep's dev-lead channel for the #2086 +# per-repo gate (default: this repo's dev-lead.yml pin) # NOW_ISO — override current time for testing (ISO-8601 UTC) # BOT_COMMENT_RETRY_CLAIM_SETTLE_SEC — seconds to wait after posting a # bot-comment retry marker before re-listing markers @@ -78,6 +80,13 @@ set -euo pipefail # threads with no reply from our automation, and dispatches one deduplicated # fix-reviews pass per PR (lib/bot-thread-retry.sh). Threads that exhaust their # attempts get a single visible notice, so the stall is never silent. +# +# Each target repo is gated on its own dev-lead.yml pin (#2086). The sweep runs one +# release of scripts/ (this repo's pinned channel), but every target runs its +# harness at its own pin. A repo pinned to an older channel than the sweep could +# be sent a client_payload field its harness cannot read, so main() skips it with +# a ::warning:: (lib/dispatch-channel-gate.sh). If the sweep's own pin cannot be +# read, the gate is off and every repo is scanned, as before. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Escalation gate (#946): pr_has_escalation_label / NEEDS_HUMAN_REVIEW_LABEL. @@ -96,6 +105,9 @@ source "$SCRIPT_DIR/lib/bot-comment-retry.sh" # Unreplied bot review-thread retry decision + thread fetch (#2046). # shellcheck source=lib/bot-thread-retry.sh source "$SCRIPT_DIR/lib/bot-thread-retry.sh" +# Per-target-repo channel gate (#2086): dcg_resolve_sweep_ref / dcg_repo_dispatch_allowed. +# shellcheck source=lib/dispatch-channel-gate.sh +source "$SCRIPT_DIR/lib/dispatch-channel-gate.sh" TARGET_ORG="${TARGET_ORG:-petry-projects}" DELEGATION_ORGS="${DELEGATION_ORGS:-}" @@ -1257,8 +1269,23 @@ main() { local repo_count="${#all_repos[@]}" echo "[retry] scanning ${repo_count} repo(s) across org(s)" - local repo_index=0 + # Gate each target on its own dev-lead.yml pin (#2086). If the sweep's own pin + # is unreadable the gate is off: failing closed here would silence the whole + # safety net on one transient read. + local sweep_ref="" + if sweep_ref="$(dcg_resolve_sweep_ref)"; then + echo "[retry] sweep channel: ${sweep_ref} (repos pinned older are skipped)" + else + sweep_ref="" + echo "::warning::[retry] cannot resolve the sweep's dev-lead channel from ${DCG_HOST_REPO}'s dev-lead.yml; per-repo channel gate disabled (#2086)" >&2 + fi + + local repo_index=0 skipped=0 for repo in "${all_repos[@]}"; do + if [ -n "$sweep_ref" ] && ! dcg_repo_dispatch_allowed "$repo" "$sweep_ref"; then + skipped=$(( skipped + 1 )) + continue + fi if [ "$repo_index" -gt 0 ] && [ "$DISPATCH_DELAY_SEC" -gt 0 ]; then # Stagger dispatches to avoid hammering the rate-limited API simultaneously echo "[retry] waiting ${DISPATCH_DELAY_SEC}s before next repo (stagger)..." @@ -1267,6 +1294,7 @@ main() { scan_repo "$repo" repo_index=$(( repo_index + 1 )) done + [ "$skipped" -eq 0 ] || echo "[retry] skipped ${skipped} repo(s) on the channel gate" echo "[retry] done at $(date -u +%Y-%m-%dT%H:%M:%SZ)" } diff --git a/scripts/lib/dispatch-channel-gate.sh b/scripts/lib/dispatch-channel-gate.sh new file mode 100644 index 000000000..c43bf2610 --- /dev/null +++ b/scripts/lib/dispatch-channel-gate.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# dispatch-channel-gate.sh — skip a dispatch target whose dev-lead channel pin is +# older than the sweep's (#2086). +# +# WHY THIS EXISTS +# dev-lead-retry.yml runs scripts/ at ONE release: the channel this repo's +# dev-lead.yml pins via `agent_ref` (#2050). The sweep then sends +# repository_dispatch to every repo in the org, and each target runs its +# harness at ITS OWN dev-lead.yml pin (the stub contract allows a per-repo +# ring/channel pin). A target pinned to an older channel than the sweep can be +# sent a client_payload field its harness cannot read yet — the #2017 / #2050 +# skew again, across repos instead of across time. +# +# WHAT THIS LIBRARY DECIDES +# For each target repo, read its dev-lead.yml `agent_ref` and compare it with +# the sweep's channel by git ancestry (the compare API on the dev-lead host +# repo), not by tag name — rings of one major sit on different commits, and a +# rollback can move a channel backwards: +# identical | behind the target has every commit the sweep has → scan +# ahead | diverged the sweep has commits the target lacks → SKIP, warn +# no dev-lead.yml nothing in that repo receives a dispatch → skip +# unreadable / unknown cannot prove compatibility → SKIP, warn (fail closed) +# A skipped repo keeps its markers; its retries resume once it is repinned to +# the sweep's channel or newer (or the sweep's channel moves back to it). +# +# Env: +# DCG_HOST_REPO repo that hosts the dev-lead reusable, its channel tags and +# the sweep's own dev-lead.yml (default: $GITHUB_REPOSITORY, +# else petry-projects/.github-private) +# SWEEP_AGENT_REF override the sweep's channel (default: DCG_HOST_REPO's pin — +# the same ref dev-lead-retry.yml checks scripts/ out at) + +DCG_HOST_REPO="${DCG_HOST_REPO:-${GITHUB_REPOSITORY:-petry-projects/.github-private}}" +# Same channel-tag shape dev-lead-retry.yml accepts for the sweep's own pin. +DCG_CHANNEL_RE='^dev-lead/v[0-9]+-(next|ring0|ring1|stable)$' +# target agent_ref -> 0 (compatible) | 1 (older) | 2 (unknown), per sweep run. +declare -gA _DCG_COMPAT_CACHE=() + +# dcg_parse_agent_ref: stdin = dev-lead.yml text; prints its `agent_ref` channel +# tag. Returns 1 (prints nothing) when it is missing or not a channel tag. +dcg_parse_agent_ref() { + local ref + ref="$(sed -nE "s/^[[:space:]]*agent_ref:[[:space:]]*[\"']?([^\"'[:space:]#]*).*/\1/p" | head -n 1)" + [[ "$ref" =~ $DCG_CHANNEL_RE ]] || return 1 + printf '%s\n' "$ref" +} + +# dcg_read_repo_pin : prints the repo's dev-lead.yml agent_ref (default +# branch). Returns 2 when the repo has no dev-lead.yml, 1 on any other read +# failure or a malformed pin. +dcg_read_repo_pin() { + local repo="$1" out + if ! out="$(gh api "repos/${repo}/contents/.github/workflows/dev-lead.yml" \ + -H "Accept: application/vnd.github.raw" 2>&1)"; then + if [[ "$out" == *"HTTP 404"* ]]; then + return 2 + fi + return 1 + fi + dcg_parse_agent_ref <<<"$out" +} + +# dcg_resolve_sweep_ref: prints the channel the sweep's scripts/ run at. +dcg_resolve_sweep_ref() { + if [ -n "${SWEEP_AGENT_REF:-}" ]; then + [[ "$SWEEP_AGENT_REF" =~ $DCG_CHANNEL_RE ]] || return 1 + printf '%s\n' "$SWEEP_AGENT_REF" + return 0 + fi + dcg_read_repo_pin "$DCG_HOST_REPO" +} + +# dcg_pin_compat : 0 = the target's channel has every +# commit the sweep's has; 1 = it is older (or diverged); 2 = unknown. Cached. +dcg_pin_compat() { + local target="$1" sweep="$2" status rc + [ "$target" = "$sweep" ] && return 0 + if [ -n "${_DCG_COMPAT_CACHE[$target]+set}" ]; then + return "${_DCG_COMPAT_CACHE[$target]}" + fi + if ! status="$(gh api "repos/${DCG_HOST_REPO}/compare/${target}...${sweep}" --jq '.status' 2>/dev/null)"; then + status="" + fi + case "$status" in + identical|behind) rc=0 ;; + ahead|diverged) rc=1 ;; + *) rc=2 ;; + esac + _DCG_COMPAT_CACHE[$target]="$rc" + return "$rc" +} + +# dcg_repo_dispatch_allowed : 0 = scan and dispatch to the +# repo; 1 = skip it. Logs the reason to stderr. +dcg_repo_dispatch_allowed() { + local repo="$1" sweep="$2" target rc=0 + target="$(dcg_read_repo_pin "$repo")" || rc=$? + case "$rc" in + 0) ;; + 2) + echo "[retry] skipping ${repo}: no dev-lead.yml, so nothing receives a dispatch" >&2 + return 1 ;; + *) + echo "::warning::[retry] skipping ${repo}: cannot read a dev-lead channel from its dev-lead.yml agent_ref, so payload compatibility with the sweep's ${sweep} is unproven (#2086)" >&2 + return 1 ;; + esac + + rc=0 + dcg_pin_compat "$target" "$sweep" || rc=$? + case "$rc" in + 0) return 0 ;; + 1) + echo "::warning::[retry] skipping ${repo}: its dev-lead.yml pins ${target}, older than the sweep's ${sweep}; its harness may not read the sweep's dispatch payloads (#2086). Repin it to ${sweep} or newer." >&2 + return 1 ;; + *) + echo "::warning::[retry] skipping ${repo}: could not compare its pin ${target} with the sweep's ${sweep} (#2086)" >&2 + return 1 ;; + esac +} diff --git a/tests/dev-lead/unit/test_dispatch_channel_gate.bats b/tests/dev-lead/unit/test_dispatch_channel_gate.bats new file mode 100644 index 000000000..1ad0879ce --- /dev/null +++ b/tests/dev-lead/unit/test_dispatch_channel_gate.bats @@ -0,0 +1,294 @@ +#!/usr/bin/env bats +# Per-target-repo channel gate for the dev-lead retry sweep (#2086). +# +# The sweep runs one release of scripts/ (the channel this repo's dev-lead.yml +# pins, #2050) but dispatches to every repo in the org, and each target runs its +# harness at ITS OWN dev-lead.yml pin. A target pinned to an older channel can +# receive a client_payload field its harness cannot read (#2017 skew, across +# repos). The gate reads each target's pin and skips — loudly — any repo whose +# channel lacks commits the sweep's channel has. These tests pin: +# • agent_ref parsing (channel tags only; same regex as dev-lead-retry.yml); +# • pin reads: found / no stub (404) / read error / malformed; +# • the ancestry compare: identical|behind → dispatch, ahead|diverged → skip, +# compare failure → skip (fail closed), same ref → no API call, cached; +# • main(): an older-pinned repo is skipped with a warning while others scan, +# and an unresolvable sweep pin keeps today's scan-everything behaviour. +# +# Run: bats tests/dev-lead/unit/test_dispatch_channel_gate.bats + +REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../../.." && pwd)" +RETRY_SCRIPT="$REPO_ROOT/scripts/dev-lead-retry.sh" + +setup() { + MOCK_BIN="$(mktemp -d)" + export MOCK_BIN + export PATH="$MOCK_BIN:$PATH" + mkdir -p "$MOCK_BIN/stubs" + : >"$MOCK_BIN/compare" + : >"$MOCK_BIN/calls" + export DRY_RUN="true" + export DISPATCH_DELAY_SEC="0" + export TARGET_ORG="petry-projects" + export DCG_HOST_REPO="petry-projects/.github-private" + unset SWEEP_AGENT_REF DELEGATION_ORGS GITHUB_REPOSITORY + + # gh stub. Every call is logged to $MOCK_BIN/calls. + # contents/.github/workflows/dev-lead.yml for owner/name + # -> $MOCK_BIN/stubs/__; missing file -> HTTP 404; + # a file containing exactly ERROR -> HTTP 502 + # compare/... -> status from "$MOCK_BIN/compare" lines + # "... "; no line -> HTTP 500 + # repo list -> $REPO_LIST_JSON (post-jq array of nameWithOwner) + cat >"$MOCK_BIN/gh" <<'GHEOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$MOCK_BIN/calls" +args="$*" +case "$args" in + "repo list"*) printf '%s' "${REPO_LIST_JSON:-[]}"; exit 0 ;; + *contents/.github/workflows/dev-lead.yml*) + repo="${args#*repos/}"; repo="${repo%%/contents/*}" + f="$MOCK_BIN/stubs/${repo//\//__}" + if [ ! -f "$f" ]; then + printf '{"message":"Not Found","status":"404"}' + echo "gh: Not Found (HTTP 404)" >&2 + exit 1 + fi + if [ "$(cat "$f")" = "ERROR" ]; then + echo "gh: Bad Gateway (HTTP 502)" >&2 + exit 1 + fi + cat "$f"; exit 0 ;; + *compare/*) + bh="${args#*compare/}"; bh="${bh%% *}" + st="$(awk -v k="$bh" '$1 == k {print $2}' "$MOCK_BIN/compare")" + if [ -z "$st" ]; then echo "gh: Server Error (HTTP 500)" >&2; exit 1; fi + printf '%s\n' "$st"; exit 0 ;; + *) echo "[]" ;; +esac +GHEOF + chmod +x "$MOCK_BIN/gh" + + # shellcheck disable=SC1090 + source "$RETRY_SCRIPT" +} + +teardown() { + rm -rf "$MOCK_BIN" +} + +# _pin : give a repo a dev-lead.yml caller stub. +_pin() { + cat >"$MOCK_BIN/stubs/${1//\//__}" < +_compare() { + printf '%s...%s %s\n' "$1" "$2" "$3" >>"$MOCK_BIN/compare" +} + +_compare_calls() { + grep -c 'compare/' "$MOCK_BIN/calls" || true +} + +# ── dcg_parse_agent_ref ────────────────────────────────────────────────────── + +@test "parse: returns the agent_ref of a caller stub, for any major and tier" { + local ch out + for ch in dev-lead/v139-stable dev-lead/v1-next dev-lead/v7-ring0 dev-lead/v12-ring1; do + _pin o/r "$ch" + out="$(dcg_parse_agent_ref <"$MOCK_BIN/stubs/o__r")" + [ "$out" = "$ch" ] + done +} + +@test "parse: accepts a quoted agent_ref with a trailing comment" { + out="$(printf ' with:\n agent_ref: "dev-lead/v139-ring1" # pin\n' | dcg_parse_agent_ref)" + [ "$out" = "dev-lead/v139-ring1" ] +} + +@test "parse: reads this repo's live dev-lead.yml stub" { + run dcg_parse_agent_ref <"$REPO_ROOT/.github/workflows/dev-lead.yml" + [ "$status" -eq 0 ] + [[ "$output" =~ ^dev-lead/v[0-9]+-(next|ring0|ring1|stable)$ ]] +} + +@test "parse: rejects a missing or non-channel agent_ref" { + run dcg_parse_agent_ref <<<'jobs: {}' + [ "$status" -ne 0 ] + local bad + for bad in main dev-lead/v139 dev-lead/vX-stable dev-lead/v139-canary pr-review/v1-stable 'dev-lead/v139-stable;x' '../../x'; do + run dcg_parse_agent_ref <<<" agent_ref: $bad" + [ "$status" -ne 0 ] + [ -z "$output" ] + done +} + +# ── dcg_read_repo_pin ──────────────────────────────────────────────────────── + +@test "read pin: returns the repo's agent_ref" { + _pin petry-projects/markets dev-lead/v139-ring1 + run dcg_read_repo_pin petry-projects/markets + [ "$status" -eq 0 ] + [ "$output" = "dev-lead/v139-ring1" ] +} + +@test "read pin: a repo with no dev-lead.yml returns 2" { + run dcg_read_repo_pin petry-projects/no-stub + [ "$status" -eq 2 ] + [ -z "$output" ] +} + +@test "read pin: a read error returns 1 (not mistaken for no stub)" { + echo ERROR >"$MOCK_BIN/stubs/petry-projects__flaky" + run dcg_read_repo_pin petry-projects/flaky + [ "$status" -eq 1 ] + [ -z "$output" ] +} + +@test "read pin: a malformed agent_ref returns 1" { + _pin petry-projects/odd main + run dcg_read_repo_pin petry-projects/odd + [ "$status" -eq 1 ] + [ -z "$output" ] +} + +# ── dcg_pin_compat ─────────────────────────────────────────────────────────── + +@test "compat: same ref → compatible without any API call" { + run dcg_pin_compat dev-lead/v139-stable dev-lead/v139-stable + [ "$status" -eq 0 ] + [ "$(_compare_calls)" -eq 0 ] +} + +@test "compat: target identical to or newer than the sweep → compatible" { + _compare dev-lead/v139-ring0 dev-lead/v139-stable identical + run dcg_pin_compat dev-lead/v139-ring0 dev-lead/v139-stable + [ "$status" -eq 0 ] + _compare dev-lead/v139-ring1 dev-lead/v139-stable behind + run dcg_pin_compat dev-lead/v139-ring1 dev-lead/v139-stable + [ "$status" -eq 0 ] +} + +@test "compat: target older than the sweep (sweep ahead) → older" { + _compare dev-lead/v1-stable dev-lead/v139-stable ahead + run dcg_pin_compat dev-lead/v1-stable dev-lead/v139-stable + [ "$status" -eq 1 ] +} + +@test "compat: diverged channels (e.g. a rollback) → older" { + _compare dev-lead/v139-ring1 dev-lead/v139-stable diverged + run dcg_pin_compat dev-lead/v139-ring1 dev-lead/v139-stable + [ "$status" -eq 1 ] +} + +@test "compat: compare against the dev-lead host repo" { + _compare dev-lead/v1-stable dev-lead/v139-stable ahead + dcg_pin_compat dev-lead/v1-stable dev-lead/v139-stable || true + grep -q 'repos/petry-projects/.github-private/compare/dev-lead/v1-stable...dev-lead/v139-stable' "$MOCK_BIN/calls" +} + +@test "compat: compare failure → unknown (2)" { + run dcg_pin_compat dev-lead/v138-stable dev-lead/v139-stable + [ "$status" -eq 2 ] +} + +@test "compat: result is cached per target ref within a sweep" { + _compare dev-lead/v1-stable dev-lead/v139-stable ahead + dcg_pin_compat dev-lead/v1-stable dev-lead/v139-stable || true + dcg_pin_compat dev-lead/v1-stable dev-lead/v139-stable || true + dcg_pin_compat dev-lead/v1-stable dev-lead/v139-stable || true + [ "$(_compare_calls)" -eq 1 ] +} + +# ── dcg_repo_dispatch_allowed ──────────────────────────────────────────────── + +@test "allowed: same pin as the sweep → scan" { + _pin petry-projects/markets dev-lead/v139-stable + run dcg_repo_dispatch_allowed petry-projects/markets dev-lead/v139-stable + [ "$status" -eq 0 ] +} + +@test "allowed: older pin → skip with a warning naming both channels" { + _pin petry-projects/broodly dev-lead/v1-stable + _compare dev-lead/v1-stable dev-lead/v139-stable ahead + run dcg_repo_dispatch_allowed petry-projects/broodly dev-lead/v139-stable + [ "$status" -eq 1 ] + [[ "$output" == *"::warning::"* ]] + [[ "$output" == *"petry-projects/broodly"* ]] + [[ "$output" == *"dev-lead/v1-stable"* ]] + [[ "$output" == *"dev-lead/v139-stable"* ]] +} + +@test "allowed: no dev-lead.yml → skip (nothing would receive a dispatch)" { + run dcg_repo_dispatch_allowed petry-projects/no-stub dev-lead/v139-stable + [ "$status" -eq 1 ] + [[ "$output" == *"no dev-lead.yml"* ]] +} + +@test "allowed: unreadable pin → skip with a warning (fail closed)" { + echo ERROR >"$MOCK_BIN/stubs/petry-projects__flaky" + run dcg_repo_dispatch_allowed petry-projects/flaky dev-lead/v139-stable + [ "$status" -eq 1 ] + [[ "$output" == *"::warning::"* ]] +} + +@test "allowed: compare failure → skip with a warning (fail closed)" { + _pin petry-projects/odd dev-lead/v138-stable + run dcg_repo_dispatch_allowed petry-projects/odd dev-lead/v139-stable + [ "$status" -eq 1 ] + [[ "$output" == *"::warning::"* ]] +} + +# ── dcg_resolve_sweep_ref ──────────────────────────────────────────────────── + +@test "sweep ref: read from the host repo's dev-lead.yml" { + _pin petry-projects/.github-private dev-lead/v139-stable + run dcg_resolve_sweep_ref + [ "$status" -eq 0 ] + [ "$output" = "dev-lead/v139-stable" ] +} + +@test "sweep ref: SWEEP_AGENT_REF overrides, and must be a channel tag" { + _pin petry-projects/.github-private dev-lead/v139-stable + SWEEP_AGENT_REF=dev-lead/v139-ring1 run dcg_resolve_sweep_ref + [ "$status" -eq 0 ] + [ "$output" = "dev-lead/v139-ring1" ] + SWEEP_AGENT_REF=main run dcg_resolve_sweep_ref + [ "$status" -ne 0 ] +} + +# ── main() wiring ──────────────────────────────────────────────────────────── + +@test "main: older-pinned repo is skipped, same-pinned repo is scanned" { + export REPO_LIST_JSON='["petry-projects/.github-private","petry-projects/broodly","petry-projects/markets"]' + _pin petry-projects/.github-private dev-lead/v139-stable + _pin petry-projects/broodly dev-lead/v1-stable + _pin petry-projects/markets dev-lead/v139-ring1 + _compare dev-lead/v1-stable dev-lead/v139-stable ahead + _compare dev-lead/v139-ring1 dev-lead/v139-stable behind + scan_repo() { echo "SCANNED $1"; } + + run main + [ "$status" -eq 0 ] + [[ "$output" == *"SCANNED petry-projects/.github-private"* ]] + [[ "$output" == *"SCANNED petry-projects/markets"* ]] + [[ "$output" != *"SCANNED petry-projects/broodly"* ]] + [[ "$output" == *"::warning::"*"petry-projects/broodly"* ]] +} + +@test "main: unresolvable sweep pin → warn and scan every repo (today's behaviour)" { + export REPO_LIST_JSON='["petry-projects/a","petry-projects/b"]' + scan_repo() { echo "SCANNED $1"; } + + run main + [ "$status" -eq 0 ] + [[ "$output" == *"::warning::"* ]] + [[ "$output" == *"SCANNED petry-projects/a"* ]] + [[ "$output" == *"SCANNED petry-projects/b"* ]] +} From d732c64751b4d589f8b94157c75cc69ca84a60af Mon Sep 17 00:00:00 2001 From: don-petry <36422719+don-petry@users.noreply.github.com> Date: Fri, 9 Oct 2026 04:20:22 +0000 Subject: [PATCH 2/5] fix(reviews): capture command-substitution status before branching in channel gate Co-Authored-By: Claude Sonnet 5.5 --- scripts/dev-lead-retry.sh | 5 +++-- scripts/lib/dispatch-channel-gate.sh | 12 +++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/scripts/dev-lead-retry.sh b/scripts/dev-lead-retry.sh index 0cd603046..0d902e5a9 100755 --- a/scripts/dev-lead-retry.sh +++ b/scripts/dev-lead-retry.sh @@ -1272,8 +1272,9 @@ main() { # Gate each target on its own dev-lead.yml pin (#2086). If the sweep's own pin # is unreadable the gate is off: failing closed here would silence the whole # safety net on one transient read. - local sweep_ref="" - if sweep_ref="$(dcg_resolve_sweep_ref)"; then + local sweep_ref="" sweep_status=0 + sweep_ref="$(dcg_resolve_sweep_ref)" || sweep_status=$? + if [ "$sweep_status" -eq 0 ]; then echo "[retry] sweep channel: ${sweep_ref} (repos pinned older are skipped)" else sweep_ref="" diff --git a/scripts/lib/dispatch-channel-gate.sh b/scripts/lib/dispatch-channel-gate.sh index c43bf2610..8ad633735 100644 --- a/scripts/lib/dispatch-channel-gate.sh +++ b/scripts/lib/dispatch-channel-gate.sh @@ -49,9 +49,10 @@ dcg_parse_agent_ref() { # branch). Returns 2 when the repo has no dev-lead.yml, 1 on any other read # failure or a malformed pin. dcg_read_repo_pin() { - local repo="$1" out - if ! out="$(gh api "repos/${repo}/contents/.github/workflows/dev-lead.yml" \ - -H "Accept: application/vnd.github.raw" 2>&1)"; then + local repo="$1" out gh_status=0 + out="$(gh api "repos/${repo}/contents/.github/workflows/dev-lead.yml" \ + -H "Accept: application/vnd.github.raw" 2>&1)" || gh_status=$? + if [ "$gh_status" -ne 0 ]; then if [[ "$out" == *"HTTP 404"* ]]; then return 2 fi @@ -73,12 +74,13 @@ dcg_resolve_sweep_ref() { # dcg_pin_compat : 0 = the target's channel has every # commit the sweep's has; 1 = it is older (or diverged); 2 = unknown. Cached. dcg_pin_compat() { - local target="$1" sweep="$2" status rc + local target="$1" sweep="$2" status rc cmp_status=0 [ "$target" = "$sweep" ] && return 0 if [ -n "${_DCG_COMPAT_CACHE[$target]+set}" ]; then return "${_DCG_COMPAT_CACHE[$target]}" fi - if ! status="$(gh api "repos/${DCG_HOST_REPO}/compare/${target}...${sweep}" --jq '.status' 2>/dev/null)"; then + status="$(gh api "repos/${DCG_HOST_REPO}/compare/${target}...${sweep}" --jq '.status' 2>/dev/null)" || cmp_status=$? + if [ "$cmp_status" -ne 0 ]; then status="" fi case "$status" in From 2021740049406a84a8f0d7154c23b6cbf3cc598a Mon Sep 17 00:00:00 2001 From: donpetry-bot <{}+donpetry-bot@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:14:44 +0000 Subject: [PATCH 3/5] fix(#2086): gate each retry payload on the target's pinned parser, not whole repos Review feedback: skipping every repo pinned older than the sweep would stop all retries for the fleet (stable / v1-stable), though most payloads carry only fields every release reads. The gate is now per payload: - lib/dispatch-channel-gate.sh reads each target's dev-lead.yml agent_ref (the reusable's default `main` when absent), then the `.client_payload.` reads in dev-lead-intent.sh and the libs it names at that ref (comments stripped), cached per sweep. A payload goes out only when every non-informational field is read; same pin as the sweep needs no lookup. No stub, an unreadable pin or parser holds the payload (fail closed); a missing field warns once per repo. - Every dispatcher calls dcg_payload_allowed after building its payload; the bot-comment scan checks BOT_COMMENT_RETRY_FIELDS before posting its marker. - main() no longer skips repos. The gate is on only when SWEEP_AGENT_REF is set (dev-lead-retry.yml now passes its resolved channel); an invalid value fails. - Tests: mock gh fails on any unexpected call; dispatcher coverage and the marker-field list are pinned; end-to-end bot-comment hold test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019K6UsD7r25pAcRjJL3G6Kx --- .github/workflows/dev-lead-retry.yml | 14 +- scripts/dev-lead-retry.sh | 61 ++-- scripts/lib/dispatch-channel-gate.sh | 246 ++++++++----- .../dev-lead/unit/test_bot_comment_retry.bats | 25 ++ .../unit/test_dispatch_channel_gate.bats | 339 +++++++++++------- 5 files changed, 432 insertions(+), 253 deletions(-) diff --git a/.github/workflows/dev-lead-retry.yml b/.github/workflows/dev-lead-retry.yml index 7c86efbd2..ba665836f 100644 --- a/.github/workflows/dev-lead-retry.yml +++ b/.github/workflows/dev-lead-retry.yml @@ -32,10 +32,9 @@ name: Dev-Lead Retry (rate-limited) # info_status_pattern match) gets one deduplicated fix-bot-comment retry per scan, # addressed by the comment's node id (scan_pr_for_undispositioned_bot_comments). # -# Each target repo runs its harness at its OWN dev-lead.yml pin, so the sweep -# skips (with a ::warning::) any repo pinned to an older channel than the sweep's. -# Such a repo could be sent a client_payload field it cannot read yet (#2086, -# scripts/lib/dispatch-channel-gate.sh). +# Each target repo parses a payload at its OWN dev-lead.yml pin, so the sweep +# holds back (with a ::warning::) any payload carrying a client_payload field the +# target's pinned parser does not read (#2086, scripts/lib/dispatch-channel-gate.sh). on: schedule: @@ -77,8 +76,8 @@ jobs: # `agent_ref`; running the sweep from main let it send payload fields that # channel could not read yet (#2017). Read the pin from the default branch # and check scripts/ out at it. Never hard-code the major here. - # One pin covers the whole sweep: target repos whose dev-lead.yml pins an - # older channel than this repo can still see the skew (#2086). + # Target repos pinned to another channel are covered per payload by the + # gate the run step turns on with SWEEP_AGENT_REF (#2086). - name: Fetch dev-lead caller stub uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -111,4 +110,7 @@ jobs: persist-credentials: false - name: Scan and retry rate-limited PRs + env: + # Turns on the per-target payload-field gate (#2086). + SWEEP_AGENT_REF: ${{ steps.channel.outputs.agent_ref }} run: bash scripts/dev-lead-retry.sh diff --git a/scripts/dev-lead-retry.sh b/scripts/dev-lead-retry.sh index 79e73a038..da68cf9ab 100755 --- a/scripts/dev-lead-retry.sh +++ b/scripts/dev-lead-retry.sh @@ -38,8 +38,9 @@ set -euo pipefail # DISPATCH_DELAY_SEC — seconds between repo dispatches (default: 30) to # prevent cascading org-wide rate-limit hits # DRY_RUN — if "true", log what would be dispatched but don't send -# SWEEP_AGENT_REF — override the sweep's dev-lead channel for the #2086 -# per-repo gate (default: this repo's dev-lead.yml pin) +# SWEEP_AGENT_REF — the dev-lead channel the sweep's scripts/ run at; +# turns on the #2086 per-target payload-field gate +# (dev-lead-retry.yml passes it; unset = gate off) # NOW_ISO — override current time for testing (ISO-8601 UTC) # BOT_COMMENT_RETRY_CLAIM_SETTLE_SEC — seconds to wait after posting a # bot-comment retry marker before re-listing markers @@ -81,12 +82,12 @@ set -euo pipefail # fix-reviews pass per PR (lib/bot-thread-retry.sh). Threads that exhaust their # attempts get a single visible notice, so the stall is never silent. # -# Each target repo is gated on its own dev-lead.yml pin (#2086). The sweep runs one -# release of scripts/ (this repo's pinned channel), but every target runs its -# harness at its own pin. A repo pinned to an older channel than the sweep could -# be sent a client_payload field its harness cannot read, so main() skips it with -# a ::warning:: (lib/dispatch-channel-gate.sh). If the sweep's own pin cannot be -# read, the gate is off and every repo is scanned, as before. +# Each payload is gated on the target repo's own dev-lead.yml pin (#2086). The +# sweep runs one release of scripts/ (this repo's pinned channel), but every +# target parses a payload with dev-lead-intent.sh at its own pin. A payload +# carrying a client_payload field that parser does not read is held back, with +# a ::warning:: naming the field (lib/dispatch-channel-gate.sh); payloads every +# release reads still reach older-pinned repos. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Escalation gate (#946): pr_has_escalation_label / NEEDS_HUMAN_REVIEW_LABEL. @@ -105,7 +106,7 @@ source "$SCRIPT_DIR/lib/bot-comment-retry.sh" # Unreplied bot review-thread retry decision + thread fetch (#2046). # shellcheck source=lib/bot-thread-retry.sh source "$SCRIPT_DIR/lib/bot-thread-retry.sh" -# Per-target-repo channel gate (#2086): dcg_resolve_sweep_ref / dcg_repo_dispatch_allowed. +# Per-target payload-field gate (#2086): dcg_init / dcg_payload_allowed / dcg_target_reads. # shellcheck source=lib/dispatch-channel-gate.sh source "$SCRIPT_DIR/lib/dispatch-channel-gate.sh" @@ -243,6 +244,8 @@ dispatch_ci_retry() { app_slug: "github-actions", id: $check_run_id}] } }') + # The target's pinned parser must read every field sent (#2086). + dcg_payload_allowed "$repo" "$payload" || return 1 if ! echo "$payload" | gh api --method POST "repos/${repo}/dispatches" --input - >/dev/null 2>&1; then echo " [warn] dispatch failed for PR ${pr_number} in ${repo}" >&2 return 1 @@ -273,6 +276,8 @@ dispatch_reviews_retry() { intent_type: $intent_type } }') + # The target's pinned parser must read every field sent (#2086). + dcg_payload_allowed "$repo" "$payload" || return 1 if ! echo "$payload" | gh api --method POST "repos/${repo}/dispatches" --input - >/dev/null 2>&1; then echo " [warn] dispatch failed for PR ${pr_number} in ${repo}" >&2 return 1 @@ -304,6 +309,8 @@ dispatch_issue_retry() { attempt: $attempt } }') + # The target's pinned parser must read every field sent (#2086). + dcg_payload_allowed "$repo" "$payload" || return 1 if ! echo "$payload" | gh api --method POST "repos/${repo}/dispatches" --input - >/dev/null 2>&1; then echo " [warn] dispatch failed for issue ${issue_number} in ${repo}" >&2 fi @@ -601,6 +608,11 @@ withdraw_bot_comment_retry_marker() { fi } +# The client_payload keys dispatch_bot_comment_retry sends, so the scan can check +# the target reads them (#2086) BEFORE it posts a retry marker. Keep in step with +# the payload below (tests/dev-lead/unit/test_dispatch_channel_gate.bats checks). +BOT_COMMENT_RETRY_FIELDS=(pr_number head_sha repo intent_type comment_node_id) + # dispatch_bot_comment_retry # Re-dispatches a fix-bot-comment pass for ONE bot comment (#2017). Reuses the # dev-lead-reviews-retry type every caller stub already subscribes to, so no stub @@ -630,6 +642,8 @@ dispatch_bot_comment_retry() { comment_node_id: $comment_node_id } }') + # The target's pinned parser must read every field sent (#2086). + dcg_payload_allowed "$repo" "$payload" || return 1 if ! echo "$payload" | gh api --method POST "repos/${repo}/dispatches" --input - >/dev/null 2>&1; then echo " [warn] dispatch failed for PR ${pr_number} in ${repo}" >&2 return 1 @@ -775,6 +789,13 @@ scan_pr_for_undispositioned_bot_comments() { now_iso="${NOW_ISO:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" echo " [retry] bot-comment ${cid} ($(jq -r '.login' <<< "$pick")) on PR ${pr_number}: undispositioned → fix-bot-comment attempt ${attempt}" >&2 + # A target pinned to a parser that drops comment_node_id would fail the run + # (#2050); hold before the marker, which would block the comment for the whole + # pending window (#2086). + if ! dcg_target_reads "$repo" "${BOT_COMMENT_RETRY_FIELDS[@]}"; then + echo "0"; return 0 + fi + # Record the attempt BEFORE dispatching so a concurrent caller (the cron and # pr-review's gate verdict) sees it as pending and does not duplicate it. local marker_id="" @@ -1269,24 +1290,17 @@ main() { local repo_count="${#all_repos[@]}" echo "[retry] scanning ${repo_count} repo(s) across org(s)" - # Gate each target on its own dev-lead.yml pin (#2086). If the sweep's own pin - # is unreadable the gate is off: failing closed here would silence the whole - # safety net on one transient read. - local sweep_ref="" sweep_status=0 - sweep_ref="$(dcg_resolve_sweep_ref)" || sweep_status=$? - if [ "$sweep_status" -eq 0 ]; then - echo "[retry] sweep channel: ${sweep_ref} (repos pinned older are skipped)" + # Hold each retry payload back from a target whose pinned parser does not + # read all its fields (#2086). Off unless the workflow passes SWEEP_AGENT_REF. + dcg_init || exit 1 + if [ -n "$DCG_SWEEP_REF" ]; then + echo "[retry] sweep channel: ${DCG_SWEEP_REF} (a payload goes only to repos whose pinned parser reads all its fields)" else - sweep_ref="" - echo "::warning::[retry] cannot resolve the sweep's dev-lead channel from ${DCG_HOST_REPO}'s dev-lead.yml; per-repo channel gate disabled (#2086)" >&2 + echo "[retry] SWEEP_AGENT_REF unset: per-target payload-field gate off (#2086)" fi - local repo_index=0 skipped=0 + local repo_index=0 for repo in "${all_repos[@]}"; do - if [ -n "$sweep_ref" ] && ! dcg_repo_dispatch_allowed "$repo" "$sweep_ref"; then - skipped=$(( skipped + 1 )) - continue - fi if [ "$repo_index" -gt 0 ] && [ "$DISPATCH_DELAY_SEC" -gt 0 ]; then # Stagger dispatches to avoid hammering the rate-limited API simultaneously echo "[retry] waiting ${DISPATCH_DELAY_SEC}s before next repo (stagger)..." @@ -1295,7 +1309,6 @@ main() { scan_repo "$repo" repo_index=$(( repo_index + 1 )) done - [ "$skipped" -eq 0 ] || echo "[retry] skipped ${skipped} repo(s) on the channel gate" echo "[retry] done at $(date -u +%Y-%m-%dT%H:%M:%SZ)" } diff --git a/scripts/lib/dispatch-channel-gate.sh b/scripts/lib/dispatch-channel-gate.sh index 8ad633735..d564b52d4 100644 --- a/scripts/lib/dispatch-channel-gate.sh +++ b/scripts/lib/dispatch-channel-gate.sh @@ -1,121 +1,199 @@ #!/usr/bin/env bash -# dispatch-channel-gate.sh — skip a dispatch target whose dev-lead channel pin is -# older than the sweep's (#2086). +# dispatch-channel-gate.sh — hold a retry payload back from a target repo whose +# pinned dev-lead harness does not read every field in it (#2086). # # WHY THIS EXISTS # dev-lead-retry.yml runs scripts/ at ONE release: the channel this repo's # dev-lead.yml pins via `agent_ref` (#2050). The sweep then sends -# repository_dispatch to every repo in the org, and each target runs its -# harness at ITS OWN dev-lead.yml pin (the stub contract allows a per-repo -# ring/channel pin). A target pinned to an older channel than the sweep can be -# sent a client_payload field its harness cannot read yet — the #2017 / #2050 +# repository_dispatch events to every repo in the org, and each target parses +# them with scripts/dev-lead-intent.sh at ITS OWN dev-lead.yml pin (the stub +# contract allows a per-repo ring/channel pin). A target pinned older than the +# sweep can be sent a client_payload field its parser drops — the #2017 / #2050 # skew again, across repos instead of across time. # # WHAT THIS LIBRARY DECIDES -# For each target repo, read its dev-lead.yml `agent_ref` and compare it with -# the sweep's channel by git ancestry (the compare API on the dev-lead host -# repo), not by tag name — rings of one major sit on different commits, and a -# rollback can move a channel backwards: -# identical | behind the target has every commit the sweep has → scan -# ahead | diverged the sweep has commits the target lacks → SKIP, warn -# no dev-lead.yml nothing in that repo receives a dispatch → skip -# unreadable / unknown cannot prove compatibility → SKIP, warn (fail closed) -# A skipped repo keeps its markers; its retries resume once it is repinned to -# the sweep's channel or newer (or the sweep's channel moves back to it). +# Per payload, not per repo: most retry payloads carry only fields every +# release reads, so a target on an older channel (the fleet sits on stable) +# keeps its retries; only a payload with a field the target's parser lacks is +# held back. For each target: +# pin its dev-lead.yml `agent_ref` (the reusable's default, main, when +# the stub passes none) — the ref its harness checks scripts/ out at +# fields the `.client_payload.` reads in dev-lead-intent.sh and the +# lib/*.sh files it names, at that pin (comments stripped; the +# same reading scripts/check-retry-payload-fields.sh does in CI) +# A payload goes out only when the target's parser reads all its fields +# (INFORMATIONAL ones aside). Pin == the sweep's channel needs no lookup: the +# sweep and that parser ship together, and CI checks them against each other. +# no dev-lead.yml nothing in that repo receives it → hold, no warning +# pin or parser unreadable compatibility unproven → hold, warn (fail closed) +# a field not read hold, warn once per repo and field set +# A held payload leaves its markers in place, so the retry resumes once the +# target is repinned to a channel that reads the field. +# +# The gate is OFF (every payload allowed, no API reads) until dcg_init runs with +# SWEEP_AGENT_REF set — dev-lead-retry.yml passes its resolved channel. Unit +# tests and scripts/check-retry-payload-fields.sh source the sweep without it. # # Env: -# DCG_HOST_REPO repo that hosts the dev-lead reusable, its channel tags and -# the sweep's own dev-lead.yml (default: $GITHUB_REPOSITORY, -# else petry-projects/.github-private) -# SWEEP_AGENT_REF override the sweep's channel (default: DCG_HOST_REPO's pin — -# the same ref dev-lead-retry.yml checks scripts/ out at) +# SWEEP_AGENT_REF the channel tag the sweep's scripts/ run at; enables the gate +# DCG_HOST_REPO repo hosting the dev-lead scripts and channel tags (default: +# $GITHUB_REPOSITORY, else petry-projects/.github-private) DCG_HOST_REPO="${DCG_HOST_REPO:-${GITHUB_REPOSITORY:-petry-projects/.github-private}}" # Same channel-tag shape dev-lead-retry.yml accepts for the sweep's own pin. DCG_CHANNEL_RE='^dev-lead/v[0-9]+-(next|ring0|ring1|stable)$' -# target agent_ref -> 0 (compatible) | 1 (older) | 2 (unknown), per sweep run. -declare -gA _DCG_COMPAT_CACHE=() +DCG_PARSER="scripts/dev-lead-intent.sh" +# Sent for logs and audit only; no harness code reads them. Mirrors +# INFORMATIONAL_FIELDS in scripts/check-retry-payload-fields.sh. +DCG_INFORMATIONAL_FIELDS=(repo attempt) +DCG_SWEEP_REF="" +# repo -> pin | "!" (2 = no stub, 1 = unreadable); ref -> fields | "!" +declare -gA _DCG_PIN_CACHE=() _DCG_FIELDS_CACHE=() _DCG_WARNED=() -# dcg_parse_agent_ref: stdin = dev-lead.yml text; prints its `agent_ref` channel -# tag. Returns 1 (prints nothing) when it is missing or not a channel tag. +# dcg_init: turn the gate on for the sweep's channel. Returns 1 when +# SWEEP_AGENT_REF is set but is not a channel tag (a configuration error). +dcg_init() { + DCG_SWEEP_REF="" + [ -n "${SWEEP_AGENT_REF:-}" ] || return 0 + if [[ ! "$SWEEP_AGENT_REF" =~ $DCG_CHANNEL_RE ]]; then + echo "::error::[retry] SWEEP_AGENT_REF '${SWEEP_AGENT_REF}' is not a dev-lead channel tag (dev-lead/v-next|ring0|ring1|stable)" >&2 + return 1 + fi + DCG_SWEEP_REF="$SWEEP_AGENT_REF" +} + +# dcg_parse_agent_ref: stdin = dev-lead.yml text; prints the ref its harness +# runs at — `agent_ref`, else the reusable's default `main`. Returns 1 when the +# value is not a plain ref (an expression, say), so it cannot be resolved here. dcg_parse_agent_ref() { - local ref - ref="$(sed -nE "s/^[[:space:]]*agent_ref:[[:space:]]*[\"']?([^\"'[:space:]#]*).*/\1/p" | head -n 1)" - [[ "$ref" =~ $DCG_CHANNEL_RE ]] || return 1 + local text ref + text="$(cat)" + ref="$(sed -nE "s/^[[:space:]]*agent_ref:[[:space:]]*[\"']?([^\"'[:space:]#]*).*/\1/p" <<<"$text" | head -n 1)" + if ! grep -qE "^[[:space:]]*agent_ref:" <<<"$text"; then + ref="main" + fi + [[ "$ref" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] || return 1 printf '%s\n' "$ref" } -# dcg_read_repo_pin : prints the repo's dev-lead.yml agent_ref (default -# branch). Returns 2 when the repo has no dev-lead.yml, 1 on any other read -# failure or a malformed pin. -dcg_read_repo_pin() { - local repo="$1" out gh_status=0 +# dcg_target_pin : prints the ref 's dev-lead harness runs at. +# Returns 2 when the repo has no dev-lead.yml, 1 when the pin is unreadable. +# Cached per sweep. +dcg_target_pin() { + local repo="$1" out rc=0 + if [ -n "${_DCG_PIN_CACHE[$repo]+set}" ]; then + out="${_DCG_PIN_CACHE[$repo]}" + if [[ "$out" == "!"* ]]; then return "${out#!}"; fi + printf '%s\n' "$out"; return 0 + fi out="$(gh api "repos/${repo}/contents/.github/workflows/dev-lead.yml" \ - -H "Accept: application/vnd.github.raw" 2>&1)" || gh_status=$? - if [ "$gh_status" -ne 0 ]; then - if [[ "$out" == *"HTTP 404"* ]]; then - return 2 - fi - return 1 + -H "Accept: application/vnd.github.raw" 2>&1)" || rc=$? + if [ "$rc" -ne 0 ]; then + if [[ "$out" == *"HTTP 404"* ]]; then rc=2; else rc=1; fi + elif ! out="$(dcg_parse_agent_ref <<<"$out")"; then + rc=1 fi - dcg_parse_agent_ref <<<"$out" + if [ "$rc" -ne 0 ]; then + _DCG_PIN_CACHE[$repo]="!${rc}" + return "$rc" + fi + _DCG_PIN_CACHE[$repo]="$out" + printf '%s\n' "$out" } -# dcg_resolve_sweep_ref: prints the channel the sweep's scripts/ run at. -dcg_resolve_sweep_ref() { - if [ -n "${SWEEP_AGENT_REF:-}" ]; then - [[ "$SWEEP_AGENT_REF" =~ $DCG_CHANNEL_RE ]] || return 1 - printf '%s\n' "$SWEEP_AGENT_REF" - return 0 - fi - dcg_read_repo_pin "$DCG_HOST_REPO" +# _dcg_fetch_at : raw file content from the host repo at . +_dcg_fetch_at() { + local path="$1" ref="$2" enc + enc="$(jq -rn --arg r "$ref" '$r | @uri')" || return 1 + gh api "repos/${DCG_HOST_REPO}/contents/${path}?ref=${enc}" \ + -H "Accept: application/vnd.github.raw" 2>/dev/null } -# dcg_pin_compat : 0 = the target's channel has every -# commit the sweep's has; 1 = it is older (or diverged); 2 = unknown. Cached. -dcg_pin_compat() { - local target="$1" sweep="$2" status rc cmp_status=0 - [ "$target" = "$sweep" ] && return 0 - if [ -n "${_DCG_COMPAT_CACHE[$target]+set}" ]; then - return "${_DCG_COMPAT_CACHE[$target]}" +# dcg_fields_read_at : the top-level client_payload keys the intent parser +# (and the lib/*.sh files it names, transitively) reads at , one per line. +# Returns 1 when any of those files cannot be read. Cached per sweep. +dcg_fields_read_at() { + local ref="$1" src all="" lib + if [ -n "${_DCG_FIELDS_CACHE[$ref]+set}" ]; then + [ "${_DCG_FIELDS_CACHE[$ref]}" != "!" ] || return 1 + printf '%s' "${_DCG_FIELDS_CACHE[$ref]}"; return 0 fi - status="$(gh api "repos/${DCG_HOST_REPO}/compare/${target}...${sweep}" --jq '.status' 2>/dev/null)" || cmp_status=$? - if [ "$cmp_status" -ne 0 ]; then - status="" - fi - case "$status" in - identical|behind) rc=0 ;; - ahead|diverged) rc=1 ;; - *) rc=2 ;; - esac - _DCG_COMPAT_CACHE[$target]="$rc" - return "$rc" + local -a queue=("$DCG_PARSER") seen=() + while [ "${#queue[@]}" -gt 0 ]; do + lib="${queue[0]}"; queue=("${queue[@]:1}") + case " ${seen[*]} " in *" $lib "*) continue ;; esac + seen+=("$lib") + if ! src="$(_dcg_fetch_at "$lib" "$ref")" || [ -z "$src" ]; then + _DCG_FIELDS_CACHE[$ref]="!" + return 1 + fi + # Drop shell comments (not `$#`), so prose naming a field is not a read. + src="$(sed -E 's/(^|[^$])#.*$/\1/' <<<"$src")" + all+="$src"$'\n' + mapfile -t -O "${#queue[@]}" queue < <(grep -oE '/lib/[A-Za-z0-9_.-]+\.sh' <<<"$src" | sed 's|^|scripts|' | sort -u) + done + all="$(grep -oE '\.client_payload\.[A-Za-z_][A-Za-z0-9_]*' <<<"$all" \ + | sed 's/^\.client_payload\.//' | sort -u || true)" + [ -z "$all" ] || all+=$'\n' + _DCG_FIELDS_CACHE[$ref]="$all" + printf '%s' "$all" } -# dcg_repo_dispatch_allowed : 0 = scan and dispatch to the -# repo; 1 = skip it. Logs the reason to stderr. -dcg_repo_dispatch_allowed() { - local repo="$1" sweep="$2" target rc=0 - target="$(dcg_read_repo_pin "$repo")" || rc=$? +# _dcg_warn_once : a ::warning:: per key per sweep, so a repo with +# many held PRs logs its reason once. +_dcg_warn_once() { + [ -z "${_DCG_WARNED[$1]+set}" ] || return 0 + _DCG_WARNED[$1]=1 + echo "::warning::$2" >&2 +} + +# dcg_target_reads ...: 0 when 's pinned parser reads every +# field (informational ones aside), or the gate is off; 1 to hold the payload. +dcg_target_reads() { + local repo="$1"; shift + [ -n "$DCG_SWEEP_REF" ] || return 0 + local pin rc=0 + pin="$(dcg_target_pin "$repo")" || rc=$? case "$rc" in 0) ;; 2) - echo "[retry] skipping ${repo}: no dev-lead.yml, so nothing receives a dispatch" >&2 + echo " [hold] ${repo}: no dev-lead.yml, so nothing there receives a retry (#2086)" >&2 return 1 ;; *) - echo "::warning::[retry] skipping ${repo}: cannot read a dev-lead channel from its dev-lead.yml agent_ref, so payload compatibility with the sweep's ${sweep} is unproven (#2086)" >&2 + _dcg_warn_once "pin:${repo}" "[retry] holding retries for ${repo}: cannot read the ref its dev-lead.yml runs the harness at (agent_ref), so the fields its parser reads are unproven (#2086)" return 1 ;; esac + [ "$pin" != "$DCG_SWEEP_REF" ] || return 0 - rc=0 - dcg_pin_compat "$target" "$sweep" || rc=$? - case "$rc" in - 0) return 0 ;; - 1) - echo "::warning::[retry] skipping ${repo}: its dev-lead.yml pins ${target}, older than the sweep's ${sweep}; its harness may not read the sweep's dispatch payloads (#2086). Repin it to ${sweep} or newer." >&2 - return 1 ;; - *) - echo "::warning::[retry] skipping ${repo}: could not compare its pin ${target} with the sweep's ${sweep} (#2086)" >&2 - return 1 ;; - esac + local fields + if ! fields="$(dcg_fields_read_at "$pin")"; then + _dcg_warn_once "fields:${pin}" "[retry] holding retries for repos pinned to ${pin}: cannot read ${DCG_PARSER} and its libs there, so the fields it reads are unproven (#2086)" + return 1 + fi + local f info missing=() + for f in "$@"; do + [ -n "$f" ] || continue + for info in "${DCG_INFORMATIONAL_FIELDS[@]}"; do + [ "$f" != "$info" ] || continue 2 + done + grep -qxF -- "$f" <<<"$fields" || missing+=("$f") + done + [ "${#missing[@]}" -eq 0 ] && return 0 + _dcg_warn_once "miss:${repo}:${missing[*]}" "[retry] holding retries for ${repo}: it pins ${pin}, whose ${DCG_PARSER} does not read client_payload field(s) ${missing[*]} that the sweep at ${DCG_SWEEP_REF} sends (#2086). Repin it to a channel that reads them." + echo " [hold] ${repo} (${pin}) does not read: ${missing[*]}" >&2 + return 1 +} + +# dcg_payload_allowed : dcg_target_reads over the top-level +# client_payload keys of a repository_dispatch body. +dcg_payload_allowed() { + local repo="$1" body="$2" keys + [ -n "$DCG_SWEEP_REF" ] || return 0 + if ! keys="$(jq -r '.client_payload | keys[]' <<<"$body" 2>/dev/null)"; then + echo " [hold] ${repo}: unreadable retry payload (#2086)" >&2 + return 1 + fi + local -a fields=() + mapfile -t fields <<<"$keys" + dcg_target_reads "$repo" "${fields[@]}" } diff --git a/tests/dev-lead/unit/test_bot_comment_retry.bats b/tests/dev-lead/unit/test_bot_comment_retry.bats index eeaa23fbb..45e57adea 100644 --- a/tests/dev-lead/unit/test_bot_comment_retry.bats +++ b/tests/dev-lead/unit/test_bot_comment_retry.bats @@ -477,6 +477,31 @@ _graphql_page() { [ "$marker_line" -lt "$dispatch_line" ] } +@test "sweep(#2086): a target whose pinned parser drops comment_node_id gets no retry marker and no dispatch" { + _setup_sweep + export TRUSTED_BOTS="$TRUSTED" + export GRAPHQL_RESPONSE + GRAPHQL_RESPONSE="$(_graphql_page "$(_bot IC_cr coderabbitai 'Walkthrough')")" + # Gate on; the target pins an older channel whose parser reads every retry + # field except comment_node_id (the #2050 skew). + DCG_SWEEP_REF=dev-lead/v139-ring0 + dcg_target_pin() { echo dev-lead/v1-stable; } + dcg_fields_read_at() { printf '%s\n' checks head_sha intent_type issue_number pr_number; } + + run scan_pr_for_undispositioned_bot_comments "petry-projects/.github-private" 2009 + [ "$status" -eq 0 ] + [ "${lines[-1]}" = "0" ] + [[ "$output" == *"::warning::"*"comment_node_id"* ]] + if grep -q '/dispatches' "$GH_LOG"; then cat "$GH_LOG"; return 1; fi + if grep -q 'dev-lead-bot-comment-retry' "$GH_LOG"; then cat "$GH_LOG"; return 1; fi + + # Once that channel reads the field, the same scan dispatches. + dcg_fields_read_at() { printf '%s\n' checks comment_node_id head_sha intent_type issue_number pr_number; } + run scan_pr_for_undispositioned_bot_comments "petry-projects/.github-private" 2009 + [ "${lines[-1]}" = "1" ] + [ "$(grep -c '/dispatches' "$GH_LOG")" -eq 1 ] +} + @test "sweep(#2089): a dev-lead:hands-off PR gets no retry marker and no dispatch" { export PR_JSON='{"state":"open","head":{"sha":"abc","ref":"dev-lead/issue-2008-x","repo":{"full_name":"petry-projects/.github-private"}},"user":{"login":"don-petry"},"labels":[{"name":"dev-lead:hands-off"}]}' _setup_sweep diff --git a/tests/dev-lead/unit/test_dispatch_channel_gate.bats b/tests/dev-lead/unit/test_dispatch_channel_gate.bats index 1ad0879ce..0861657a5 100644 --- a/tests/dev-lead/unit/test_dispatch_channel_gate.bats +++ b/tests/dev-lead/unit/test_dispatch_channel_gate.bats @@ -1,50 +1,63 @@ #!/usr/bin/env bats -# Per-target-repo channel gate for the dev-lead retry sweep (#2086). +# Per-target payload-field gate for the dev-lead retry sweep (#2086). # # The sweep runs one release of scripts/ (the channel this repo's dev-lead.yml -# pins, #2050) but dispatches to every repo in the org, and each target runs its -# harness at ITS OWN dev-lead.yml pin. A target pinned to an older channel can -# receive a client_payload field its harness cannot read (#2017 skew, across -# repos). The gate reads each target's pin and skips — loudly — any repo whose -# channel lacks commits the sweep's channel has. These tests pin: -# • agent_ref parsing (channel tags only; same regex as dev-lead-retry.yml); -# • pin reads: found / no stub (404) / read error / malformed; -# • the ancestry compare: identical|behind → dispatch, ahead|diverged → skip, -# compare failure → skip (fail closed), same ref → no API call, cached; -# • main(): an older-pinned repo is skipped with a warning while others scan, -# and an unresolvable sweep pin keeps today's scan-everything behaviour. +# pins, #2050) but sends repository_dispatch events to every repo in the org, and +# each target parses them with dev-lead-intent.sh at ITS OWN dev-lead.yml pin. A +# payload carrying a client_payload field that parser does not read must be held +# back; a payload of fields every release reads must still reach older-pinned +# repos (the fleet sits on stable). These tests pin: +# • agent_ref parsing (any plain ref; the reusable's default `main` when absent); +# • pin reads: found / no stub (404) / read error / unresolvable, cached; +# • the fields a ref's parser reads: parser + libs it names, comments ignored, +# an unreadable file fails closed, cached, ref URL-encoded; +# • the decision: gate off without SWEEP_AGENT_REF, same pin skips the lookup, +# a missing field holds with one warning naming it, informational fields ignored; +# • every dispatcher is gated, and BOT_COMMENT_RETRY_FIELDS matches its payload; +# • main(): every repo is still scanned; an invalid SWEEP_AGENT_REF is an error. # # Run: bats tests/dev-lead/unit/test_dispatch_channel_gate.bats REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../../.." && pwd)" RETRY_SCRIPT="$REPO_ROOT/scripts/dev-lead-retry.sh" +# The v1 parser reads every field except comment_node_id (the #2050 skew). +V1_FIELDS=(pr_number issue_number head_sha checks intent_type) + setup() { MOCK_BIN="$(mktemp -d)" export MOCK_BIN export PATH="$MOCK_BIN:$PATH" - mkdir -p "$MOCK_BIN/stubs" - : >"$MOCK_BIN/compare" + mkdir -p "$MOCK_BIN/stubs" "$MOCK_BIN/src" : >"$MOCK_BIN/calls" - export DRY_RUN="true" + export DRY_RUN="false" export DISPATCH_DELAY_SEC="0" export TARGET_ORG="petry-projects" export DCG_HOST_REPO="petry-projects/.github-private" unset SWEEP_AGENT_REF DELEGATION_ORGS GITHUB_REPOSITORY - # gh stub. Every call is logged to $MOCK_BIN/calls. - # contents/.github/workflows/dev-lead.yml for owner/name - # -> $MOCK_BIN/stubs/__; missing file -> HTTP 404; + # gh stub. Every call is logged to $MOCK_BIN/calls; an unexpected call fails + # loudly (exit 97) so a test never passes on a silent default. + # repos//contents/?ref= + # -> $MOCK_BIN/src//; missing -> HTTP 404 + # repos///contents/.github/workflows/dev-lead.yml + # -> $MOCK_BIN/stubs/__; missing -> HTTP 404; # a file containing exactly ERROR -> HTTP 502 - # compare/... -> status from "$MOCK_BIN/compare" lines - # "... "; no line -> HTTP 500 - # repo list -> $REPO_LIST_JSON (post-jq array of nameWithOwner) + # repos//dispatches -> body appended to $MOCK_BIN/dispatched + # repo list -> $REPO_LIST_JSON cat >"$MOCK_BIN/gh" <<'GHEOF' #!/usr/bin/env bash printf '%s\n' "$*" >>"$MOCK_BIN/calls" args="$*" case "$args" in "repo list"*) printf '%s' "${REPO_LIST_JSON:-[]}"; exit 0 ;; + *"/dispatches"*) cat >>"$MOCK_BIN/dispatched"; echo >>"$MOCK_BIN/dispatched"; exit 0 ;; + *"contents/"*"?ref="*) + p="${args#*/contents/}"; p="${p%% *}" + ref="${p#*\?ref=}"; path="${p%%\?ref=*}" + f="$MOCK_BIN/src/${ref}/${path//\//__}" + if [ ! -f "$f" ]; then echo "gh: Not Found (HTTP 404)" >&2; exit 1; fi + cat "$f"; exit 0 ;; *contents/.github/workflows/dev-lead.yml*) repo="${args#*repos/}"; repo="${repo%%/contents/*}" f="$MOCK_BIN/stubs/${repo//\//__}" @@ -58,12 +71,7 @@ case "$args" in exit 1 fi cat "$f"; exit 0 ;; - *compare/*) - bh="${args#*compare/}"; bh="${bh%% *}" - st="$(awk -v k="$bh" '$1 == k {print $2}' "$MOCK_BIN/compare")" - if [ -z "$st" ]; then echo "gh: Server Error (HTTP 500)" >&2; exit 1; fi - printf '%s\n' "$st"; exit 0 ;; - *) echo "[]" ;; + *) echo "mock gh: unexpected call: $args" >&2; exit 97 ;; esac GHEOF chmod +x "$MOCK_BIN/gh" @@ -87,20 +95,37 @@ jobs: EOF } -# _compare -_compare() { - printf '%s...%s %s\n' "$1" "$2" "$3" >>"$MOCK_BIN/compare" +# _parser ...: the host repo's dev-lead-intent.sh at reads +# s — the last one through a lib it sources. +_parser() { + local ref="$1" enc; shift + enc="$(jq -rn --arg r "$ref" '$r | @uri')" + mkdir -p "$MOCK_BIN/src/$enc" + local f last="" body="" + for f in "$@"; do last="$f"; done + for f in "$@"; do + [ "$f" = "$last" ] && continue + body+=" ${f}=\$(jq -r '.client_payload.${f} // empty' \"\$EVENT_PATH\")"$'\n' + done + { + echo '#!/usr/bin/env bash' + echo '# .client_payload.comment_node_id is named only in this comment' + echo 'source "$(dirname "$0")/lib/helper.sh"' + echo 'parse() {' + printf '%s' "$body" + echo '}' + } >"$MOCK_BIN/src/$enc/scripts__dev-lead-intent.sh" + printf 'helper() { jq -r %s; }\n' "'.client_payload.${last} // empty'" \ + >"$MOCK_BIN/src/$enc/scripts__lib__helper.sh" } -_compare_calls() { - grep -c 'compare/' "$MOCK_BIN/calls" || true -} +_calls() { grep -c -- "$1" "$MOCK_BIN/calls" || true; } # ── dcg_parse_agent_ref ────────────────────────────────────────────────────── -@test "parse: returns the agent_ref of a caller stub, for any major and tier" { +@test "parse: returns the agent_ref of a caller stub" { local ch out - for ch in dev-lead/v139-stable dev-lead/v1-next dev-lead/v7-ring0 dev-lead/v12-ring1; do + for ch in dev-lead/v139-stable dev-lead/v1-next dev-lead/v7-ring0 main 4fd4eac; do _pin o/r "$ch" out="$(dcg_parse_agent_ref <"$MOCK_BIN/stubs/o__r")" [ "$out" = "$ch" ] @@ -112,183 +137,219 @@ _compare_calls() { [ "$out" = "dev-lead/v139-ring1" ] } +@test "parse: no agent_ref → the reusable's default, main" { + out="$(printf 'jobs:\n dev-lead:\n uses: o/r/.github/workflows/dev-lead-reusable.yml@dev-lead/v1-stable\n' | dcg_parse_agent_ref)" + [ "$out" = "main" ] +} + @test "parse: reads this repo's live dev-lead.yml stub" { run dcg_parse_agent_ref <"$REPO_ROOT/.github/workflows/dev-lead.yml" [ "$status" -eq 0 ] [[ "$output" =~ ^dev-lead/v[0-9]+-(next|ring0|ring1|stable)$ ]] } -@test "parse: rejects a missing or non-channel agent_ref" { - run dcg_parse_agent_ref <<<'jobs: {}' - [ "$status" -ne 0 ] +@test "parse: rejects an empty or non-literal agent_ref" { local bad - for bad in main dev-lead/v139 dev-lead/vX-stable dev-lead/v139-canary pr-review/v1-stable 'dev-lead/v139-stable;x' '../../x'; do + for bad in '' '${{ inputs.ref }}' '../../x' '-x'; do run dcg_parse_agent_ref <<<" agent_ref: $bad" [ "$status" -ne 0 ] [ -z "$output" ] done } -# ── dcg_read_repo_pin ──────────────────────────────────────────────────────── +# ── dcg_target_pin ─────────────────────────────────────────────────────────── -@test "read pin: returns the repo's agent_ref" { +@test "pin: returns the repo's agent_ref, read once per sweep" { _pin petry-projects/markets dev-lead/v139-ring1 - run dcg_read_repo_pin petry-projects/markets + run dcg_target_pin petry-projects/markets [ "$status" -eq 0 ] [ "$output" = "dev-lead/v139-ring1" ] + dcg_target_pin petry-projects/markets >/dev/null + dcg_target_pin petry-projects/markets >/dev/null + [ "$(_calls 'repos/petry-projects/markets/contents')" -eq 2 ] # run's subshell + the cached one } -@test "read pin: a repo with no dev-lead.yml returns 2" { - run dcg_read_repo_pin petry-projects/no-stub +@test "pin: no dev-lead.yml → 2; read error or unresolvable pin → 1" { + run dcg_target_pin petry-projects/no-stub [ "$status" -eq 2 ] - [ -z "$output" ] -} - -@test "read pin: a read error returns 1 (not mistaken for no stub)" { echo ERROR >"$MOCK_BIN/stubs/petry-projects__flaky" - run dcg_read_repo_pin petry-projects/flaky + run dcg_target_pin petry-projects/flaky [ "$status" -eq 1 ] - [ -z "$output" ] -} - -@test "read pin: a malformed agent_ref returns 1" { - _pin petry-projects/odd main - run dcg_read_repo_pin petry-projects/odd + _pin petry-projects/expr '${{ inputs.ref }}' + run dcg_target_pin petry-projects/expr [ "$status" -eq 1 ] - [ -z "$output" ] } -# ── dcg_pin_compat ─────────────────────────────────────────────────────────── +# ── dcg_fields_read_at ─────────────────────────────────────────────────────── -@test "compat: same ref → compatible without any API call" { - run dcg_pin_compat dev-lead/v139-stable dev-lead/v139-stable +@test "fields: the parser's reads plus its libs', not names in comments" { + _parser dev-lead/v1-stable "${V1_FIELDS[@]}" + run dcg_fields_read_at dev-lead/v1-stable [ "$status" -eq 0 ] - [ "$(_compare_calls)" -eq 0 ] + [ "$output" = "$(printf '%s\n' "${V1_FIELDS[@]}" | sort -u)" ] + # The ref is URL-encoded and read from the host repo. + grep -q 'repos/petry-projects/.github-private/contents/scripts/lib/helper.sh?ref=dev-lead%2Fv1-stable' "$MOCK_BIN/calls" } -@test "compat: target identical to or newer than the sweep → compatible" { - _compare dev-lead/v139-ring0 dev-lead/v139-stable identical - run dcg_pin_compat dev-lead/v139-ring0 dev-lead/v139-stable - [ "$status" -eq 0 ] - _compare dev-lead/v139-ring1 dev-lead/v139-stable behind - run dcg_pin_compat dev-lead/v139-ring1 dev-lead/v139-stable - [ "$status" -eq 0 ] +@test "fields: a lib that cannot be read fails closed" { + _parser dev-lead/v1-stable "${V1_FIELDS[@]}" + rm "$MOCK_BIN/src/dev-lead%2Fv1-stable/scripts__lib__helper.sh" + run dcg_fields_read_at dev-lead/v1-stable + [ "$status" -eq 1 ] } -@test "compat: target older than the sweep (sweep ahead) → older" { - _compare dev-lead/v1-stable dev-lead/v139-stable ahead - run dcg_pin_compat dev-lead/v1-stable dev-lead/v139-stable - [ "$status" -eq 1 ] +@test "fields: cached per ref within a sweep" { + _parser dev-lead/v1-stable "${V1_FIELDS[@]}" + dcg_fields_read_at dev-lead/v1-stable >/dev/null + dcg_fields_read_at dev-lead/v1-stable >/dev/null + [ "$(_calls 'dev-lead-intent.sh?ref=')" -eq 1 ] } -@test "compat: diverged channels (e.g. a rollback) → older" { - _compare dev-lead/v139-ring1 dev-lead/v139-stable diverged - run dcg_pin_compat dev-lead/v139-ring1 dev-lead/v139-stable +# ── dcg_init / dcg_target_reads ────────────────────────────────────────────── + +@test "init: unset → gate off; a channel tag → on; anything else is an error" { + dcg_init + [ -z "$DCG_SWEEP_REF" ] + SWEEP_AGENT_REF=dev-lead/v139-ring0 dcg_init + [ "$DCG_SWEEP_REF" = "dev-lead/v139-ring0" ] + SWEEP_AGENT_REF=main run dcg_init [ "$status" -eq 1 ] + [[ "$output" == *"::error::"* ]] } -@test "compat: compare against the dev-lead host repo" { - _compare dev-lead/v1-stable dev-lead/v139-stable ahead - dcg_pin_compat dev-lead/v1-stable dev-lead/v139-stable || true - grep -q 'repos/petry-projects/.github-private/compare/dev-lead/v1-stable...dev-lead/v139-stable' "$MOCK_BIN/calls" +@test "reads: gate off → allowed with no API call" { + run dcg_target_reads petry-projects/broodly comment_node_id + [ "$status" -eq 0 ] + [ ! -s "$MOCK_BIN/calls" ] } -@test "compat: compare failure → unknown (2)" { - run dcg_pin_compat dev-lead/v138-stable dev-lead/v139-stable - [ "$status" -eq 2 ] +@test "reads: pinned to the sweep's channel → allowed without reading its parser" { + DCG_SWEEP_REF=dev-lead/v139-ring0 + _pin petry-projects/x dev-lead/v139-ring0 + run dcg_target_reads petry-projects/x comment_node_id + [ "$status" -eq 0 ] + [ "$(_calls 'dev-lead-intent.sh')" -eq 0 ] } -@test "compat: result is cached per target ref within a sweep" { - _compare dev-lead/v1-stable dev-lead/v139-stable ahead - dcg_pin_compat dev-lead/v1-stable dev-lead/v139-stable || true - dcg_pin_compat dev-lead/v1-stable dev-lead/v139-stable || true - dcg_pin_compat dev-lead/v1-stable dev-lead/v139-stable || true - [ "$(_compare_calls)" -eq 1 ] +@test "reads: an older pin whose parser reads every field → allowed" { + DCG_SWEEP_REF=dev-lead/v139-ring0 + _pin petry-projects/broodly dev-lead/v1-stable + _parser dev-lead/v1-stable "${V1_FIELDS[@]}" + run dcg_target_reads petry-projects/broodly pr_number head_sha repo intent_type + [ "$status" -eq 0 ] + [[ "$output" != *"::warning::"* ]] } -# ── dcg_repo_dispatch_allowed ──────────────────────────────────────────────── - -@test "allowed: same pin as the sweep → scan" { - _pin petry-projects/markets dev-lead/v139-stable - run dcg_repo_dispatch_allowed petry-projects/markets dev-lead/v139-stable +@test "reads: informational fields (repo, attempt) need no reader" { + DCG_SWEEP_REF=dev-lead/v139-ring0 + _pin petry-projects/broodly dev-lead/v1-stable + _parser dev-lead/v1-stable issue_number + run dcg_target_reads petry-projects/broodly issue_number repo attempt [ "$status" -eq 0 ] } -@test "allowed: older pin → skip with a warning naming both channels" { +@test "reads: a field the pinned parser does not read → held, one warning naming it" { + DCG_SWEEP_REF=dev-lead/v139-ring0 _pin petry-projects/broodly dev-lead/v1-stable - _compare dev-lead/v1-stable dev-lead/v139-stable ahead - run dcg_repo_dispatch_allowed petry-projects/broodly dev-lead/v139-stable - [ "$status" -eq 1 ] - [[ "$output" == *"::warning::"* ]] - [[ "$output" == *"petry-projects/broodly"* ]] - [[ "$output" == *"dev-lead/v1-stable"* ]] - [[ "$output" == *"dev-lead/v139-stable"* ]] + _parser dev-lead/v1-stable "${V1_FIELDS[@]}" + local err="$BATS_TEST_TMPDIR/err" rc=0 + dcg_target_reads petry-projects/broodly pr_number comment_node_id 2>"$err" || rc=$? + [ "$rc" -eq 1 ] + dcg_target_reads petry-projects/broodly pr_number comment_node_id 2>>"$err" || rc=$? + [ "$(grep -c '::warning::' "$err")" -eq 1 ] + grep -q '::warning::.*petry-projects/broodly.*dev-lead/v1-stable.*comment_node_id' "$err" } -@test "allowed: no dev-lead.yml → skip (nothing would receive a dispatch)" { - run dcg_repo_dispatch_allowed petry-projects/no-stub dev-lead/v139-stable +@test "reads: no dev-lead.yml → held without a warning" { + DCG_SWEEP_REF=dev-lead/v139-ring0 + run dcg_target_reads petry-projects/no-stub pr_number [ "$status" -eq 1 ] - [[ "$output" == *"no dev-lead.yml"* ]] + [[ "$output" != *"::warning::"* ]] } -@test "allowed: unreadable pin → skip with a warning (fail closed)" { +@test "reads: unreadable pin or parser → held with a warning (fail closed)" { + DCG_SWEEP_REF=dev-lead/v139-ring0 echo ERROR >"$MOCK_BIN/stubs/petry-projects__flaky" - run dcg_repo_dispatch_allowed petry-projects/flaky dev-lead/v139-stable + run dcg_target_reads petry-projects/flaky pr_number + [ "$status" -eq 1 ] + [[ "$output" == *"::warning::"* ]] + _pin petry-projects/old dev-lead/v1-ring1 # no parser source at that ref + run dcg_target_reads petry-projects/old pr_number [ "$status" -eq 1 ] [[ "$output" == *"::warning::"* ]] } -@test "allowed: compare failure → skip with a warning (fail closed)" { - _pin petry-projects/odd dev-lead/v138-stable - run dcg_repo_dispatch_allowed petry-projects/odd dev-lead/v139-stable +# ── dispatchers ────────────────────────────────────────────────────────────── + +_v1_target() { + DCG_SWEEP_REF=dev-lead/v139-ring0 + _pin petry-projects/broodly dev-lead/v1-stable + _parser dev-lead/v1-stable "${V1_FIELDS[@]}" +} + +@test "dispatch: a bot-comment retry is held from a target that drops comment_node_id" { + _v1_target + run dispatch_bot_comment_retry petry-projects/broodly 7 abc IC_x [ "$status" -eq 1 ] - [[ "$output" == *"::warning::"* ]] + [ ! -s "$MOCK_BIN/dispatched" ] } -# ── dcg_resolve_sweep_ref ──────────────────────────────────────────────────── +@test "dispatch: reviews, CI and issue retries still reach a v1-pinned target" { + _v1_target + lookup_check_run_details() { echo '{"id":"","details_url":""}'; } + dispatch_reviews_retry petry-projects/broodly 7 abc fix-reviews + dispatch_ci_retry petry-projects/broodly 7 abc "CI" + dispatch_issue_retry petry-projects/broodly 9 1 + [ "$(grep -c 'client_payload' "$MOCK_BIN/dispatched")" -eq 3 ] +} -@test "sweep ref: read from the host repo's dev-lead.yml" { - _pin petry-projects/.github-private dev-lead/v139-stable - run dcg_resolve_sweep_ref - [ "$status" -eq 0 ] - [ "$output" = "dev-lead/v139-stable" ] +@test "dispatch: every dispatcher in the sweep consults the gate" { + local fn n=0 + for fn in $(declare -F | awk '{print $3}'); do + # A sweep dispatcher POSTs its payload to the target's /dispatches. + declare -f "$fn" | grep -qF 'POST "repos/${repo}/dispatches"' || continue + n=$((n + 1)) + declare -f "$fn" | grep -q 'dcg_payload_allowed' || { echo "ungated: $fn"; return 1; } + done + [ "$n" -ge 4 ] } -@test "sweep ref: SWEEP_AGENT_REF overrides, and must be a channel tag" { - _pin petry-projects/.github-private dev-lead/v139-stable - SWEEP_AGENT_REF=dev-lead/v139-ring1 run dcg_resolve_sweep_ref - [ "$status" -eq 0 ] - [ "$output" = "dev-lead/v139-ring1" ] - SWEEP_AGENT_REF=main run dcg_resolve_sweep_ref - [ "$status" -ne 0 ] +@test "BOT_COMMENT_RETRY_FIELDS matches dispatch_bot_comment_retry's payload" { + dispatch_bot_comment_retry petry-projects/x 7 abc IC_x + [ "$(jq -r '.client_payload | keys[]' "$MOCK_BIN/dispatched" | sort)" \ + = "$(printf '%s\n' "${BOT_COMMENT_RETRY_FIELDS[@]}" | sort)" ] } # ── main() wiring ──────────────────────────────────────────────────────────── -@test "main: older-pinned repo is skipped, same-pinned repo is scanned" { - export REPO_LIST_JSON='["petry-projects/.github-private","petry-projects/broodly","petry-projects/markets"]' - _pin petry-projects/.github-private dev-lead/v139-stable - _pin petry-projects/broodly dev-lead/v1-stable - _pin petry-projects/markets dev-lead/v139-ring1 - _compare dev-lead/v1-stable dev-lead/v139-stable ahead - _compare dev-lead/v139-ring1 dev-lead/v139-stable behind +@test "main: with the gate on, every repo is still scanned" { + export REPO_LIST_JSON='["petry-projects/.github-private","petry-projects/broodly"]' scan_repo() { echo "SCANNED $1"; } - - run main + SWEEP_AGENT_REF=dev-lead/v139-ring0 run main [ "$status" -eq 0 ] + [[ "$output" == *"sweep channel: dev-lead/v139-ring0"* ]] [[ "$output" == *"SCANNED petry-projects/.github-private"* ]] - [[ "$output" == *"SCANNED petry-projects/markets"* ]] - [[ "$output" != *"SCANNED petry-projects/broodly"* ]] - [[ "$output" == *"::warning::"*"petry-projects/broodly"* ]] + [[ "$output" == *"SCANNED petry-projects/broodly"* ]] } -@test "main: unresolvable sweep pin → warn and scan every repo (today's behaviour)" { - export REPO_LIST_JSON='["petry-projects/a","petry-projects/b"]' +@test "main: SWEEP_AGENT_REF unset → gate off, logged" { + export REPO_LIST_JSON='["petry-projects/a"]' scan_repo() { echo "SCANNED $1"; } - run main [ "$status" -eq 0 ] - [[ "$output" == *"::warning::"* ]] + [[ "$output" == *"gate off"* ]] [[ "$output" == *"SCANNED petry-projects/a"* ]] - [[ "$output" == *"SCANNED petry-projects/b"* ]] +} + +@test "main: an invalid SWEEP_AGENT_REF fails the sweep before any scan" { + export REPO_LIST_JSON='["petry-projects/a"]' + scan_repo() { echo "SCANNED $1"; } + SWEEP_AGENT_REF='dev-lead/v139-canary' run main + [ "$status" -ne 0 ] + [[ "$output" != *"SCANNED"* ]] +} + +@test "workflow: the run step passes the resolved channel as SWEEP_AGENT_REF" { + grep -q 'SWEEP_AGENT_REF: \${{ steps.channel.outputs.agent_ref }}' \ + "$REPO_ROOT/.github/workflows/dev-lead-retry.yml" } From 136ccf1dce244c42b910e6b793833d9606ebf9cb Mon Sep 17 00:00:00 2001 From: donpetry-bot <{}+donpetry-bot@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:15:48 +0000 Subject: [PATCH 4/5] fix(#2086): a contents 404 means 'no dev-lead.yml' only when the repo itself is readable Review (cubic P2): GitHub also answers 404 for a repo the token cannot read, which the gate would have held silently as 'no stub'. Confirm the repo is readable first; otherwise it is an unreadable pin and holds with a ::warning::. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019K6UsD7r25pAcRjJL3G6Kx --- scripts/lib/dispatch-channel-gate.sh | 12 +++++++++-- .../unit/test_dispatch_channel_gate.bats | 21 +++++++++++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/scripts/lib/dispatch-channel-gate.sh b/scripts/lib/dispatch-channel-gate.sh index d564b52d4..90b3612cb 100644 --- a/scripts/lib/dispatch-channel-gate.sh +++ b/scripts/lib/dispatch-channel-gate.sh @@ -25,6 +25,7 @@ # (INFORMATIONAL ones aside). Pin == the sweep's channel needs no lookup: the # sweep and that parser ship together, and CI checks them against each other. # no dev-lead.yml nothing in that repo receives it → hold, no warning +# (a 404 counts only when the repo itself is readable) # pin or parser unreadable compatibility unproven → hold, warn (fail closed) # a field not read hold, warn once per repo and field set # A held payload leaves its markers in place, so the retry resumes once the @@ -77,7 +78,8 @@ dcg_parse_agent_ref() { } # dcg_target_pin : prints the ref 's dev-lead harness runs at. -# Returns 2 when the repo has no dev-lead.yml, 1 when the pin is unreadable. +# Returns 2 when the (readable) repo has no dev-lead.yml, 1 when the pin is +# unreadable — the repo itself included. # Cached per sweep. dcg_target_pin() { local repo="$1" out rc=0 @@ -89,7 +91,13 @@ dcg_target_pin() { out="$(gh api "repos/${repo}/contents/.github/workflows/dev-lead.yml" \ -H "Accept: application/vnd.github.raw" 2>&1)" || rc=$? if [ "$rc" -ne 0 ]; then - if [[ "$out" == *"HTTP 404"* ]]; then rc=2; else rc=1; fi + rc=1 + # GitHub also answers 404 for a repo the token cannot read; only a readable + # repo's 404 means the stub is absent. + if [[ "$out" == *"HTTP 404"* ]] \ + && gh api "repos/${repo}" --jq '.full_name' >/dev/null 2>&1; then + rc=2 + fi elif ! out="$(dcg_parse_agent_ref <<<"$out")"; then rc=1 fi diff --git a/tests/dev-lead/unit/test_dispatch_channel_gate.bats b/tests/dev-lead/unit/test_dispatch_channel_gate.bats index 0861657a5..a893579bd 100644 --- a/tests/dev-lead/unit/test_dispatch_channel_gate.bats +++ b/tests/dev-lead/unit/test_dispatch_channel_gate.bats @@ -8,7 +8,8 @@ # back; a payload of fields every release reads must still reach older-pinned # repos (the fleet sits on stable). These tests pin: # • agent_ref parsing (any plain ref; the reusable's default `main` when absent); -# • pin reads: found / no stub (404) / read error / unresolvable, cached; +# • pin reads: found / no stub (404 on a readable repo) / unreadable repo / +# read error / unresolvable, cached; # • the fields a ref's parser reads: parser + libs it names, comments ignored, # an unreadable file fails closed, cached, ref URL-encoded; # • the decision: gate off without SWEEP_AGENT_REF, same pin skips the lookup, @@ -28,7 +29,7 @@ setup() { MOCK_BIN="$(mktemp -d)" export MOCK_BIN export PATH="$MOCK_BIN:$PATH" - mkdir -p "$MOCK_BIN/stubs" "$MOCK_BIN/src" + mkdir -p "$MOCK_BIN/stubs" "$MOCK_BIN/src" "$MOCK_BIN/unreadable" : >"$MOCK_BIN/calls" export DRY_RUN="false" export DISPATCH_DELAY_SEC="0" @@ -43,6 +44,8 @@ setup() { # repos///contents/.github/workflows/dev-lead.yml # -> $MOCK_BIN/stubs/__; missing -> HTTP 404; # a file containing exactly ERROR -> HTTP 502 + # repos// --jq .full_name -> readable, unless + # $MOCK_BIN/unreadable/__ exists (HTTP 404) # repos//dispatches -> body appended to $MOCK_BIN/dispatched # repo list -> $REPO_LIST_JSON cat >"$MOCK_BIN/gh" <<'GHEOF' @@ -71,6 +74,10 @@ case "$args" in exit 1 fi cat "$f"; exit 0 ;; + "api repos/"*" --jq .full_name") + repo="${args#api repos/}"; repo="${repo%% *}" + if [ -f "$MOCK_BIN/unreadable/${repo//\//__}" ]; then echo "gh: Not Found (HTTP 404)" >&2; exit 1; fi + echo "$repo"; exit 0 ;; *) echo "mock gh: unexpected call: $args" >&2; exit 97 ;; esac GHEOF @@ -169,6 +176,16 @@ _calls() { grep -c -- "$1" "$MOCK_BIN/calls" || true; } [ "$(_calls 'repos/petry-projects/markets/contents')" -eq 2 ] # run's subshell + the cached one } +@test "pin: a 404 from a repo the token cannot read is unreadable (1), not absent" { + touch "$MOCK_BIN/unreadable/petry-projects__private" + run dcg_target_pin petry-projects/private + [ "$status" -eq 1 ] + DCG_SWEEP_REF=dev-lead/v139-ring0 + run dcg_target_reads petry-projects/private pr_number + [ "$status" -eq 1 ] + [[ "$output" == *"::warning::"* ]] +} + @test "pin: no dev-lead.yml → 2; read error or unresolvable pin → 1" { run dcg_target_pin petry-projects/no-stub [ "$status" -eq 2 ] From 47348db9b98a66b241b6fe7c7b2a53c51aabf379 Mon Sep 17 00:00:00 2001 From: donpetry-bot <{}+donpetry-bot@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:20:12 +0000 Subject: [PATCH 5/5] fix(#2086): keep the gate's cache in per-sweep files so it survives subshells Review (CodeAnt): the pin and parser-field lookups run inside command substitutions, and the bot-comment scan is itself called in one, so the in-memory cache was discarded on every return and each payload refetched the target's stub and parser files. dcg_init now makes a per-sweep cache directory (RUNNER_TEMP); pins, parser fields and warn-once keys are files there. A test drives the gate three times through $(...) and asserts one read of each file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019K6UsD7r25pAcRjJL3G6Kx --- scripts/lib/dispatch-channel-gate.sh | 50 ++++++++++++++----- .../unit/test_dispatch_channel_gate.bats | 30 +++++++++-- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/scripts/lib/dispatch-channel-gate.sh b/scripts/lib/dispatch-channel-gate.sh index 90b3612cb..437f744f9 100644 --- a/scripts/lib/dispatch-channel-gate.sh +++ b/scripts/lib/dispatch-channel-gate.sh @@ -48,8 +48,28 @@ DCG_PARSER="scripts/dev-lead-intent.sh" # INFORMATIONAL_FIELDS in scripts/check-retry-payload-fields.sh. DCG_INFORMATIONAL_FIELDS=(repo attempt) DCG_SWEEP_REF="" -# repo -> pin | "!" (2 = no stub, 1 = unreadable); ref -> fields | "!" -declare -gA _DCG_PIN_CACHE=() _DCG_FIELDS_CACHE=() _DCG_WARNED=() +# Per-sweep cache. The sweep runs its scans and these lookups inside command +# substitutions (subshells), where a shell variable written is lost on return, so +# the cache lives in files: pin. = ref | "!", fields. = keys | "!", +# warned.. dcg_init makes it; without it nothing is cached. +DCG_CACHE_DIR="${DCG_CACHE_DIR:-}" + +_dcg_cache_file() { + printf '%s/%s.%s' "$DCG_CACHE_DIR" "$1" "$(printf '%s' "$2" | sha256sum | cut -c1-32)" +} +# _dcg_cache_get : prints the cached value; returns 1 on a miss. +_dcg_cache_get() { + [ -n "$DCG_CACHE_DIR" ] || return 1 + local f + f="$(_dcg_cache_file "$1" "$2")" + [ -f "$f" ] || return 1 + cat -- "$f" +} +# _dcg_cache_put +_dcg_cache_put() { + [ -n "$DCG_CACHE_DIR" ] || return 0 + printf '%s' "$3" >"$(_dcg_cache_file "$1" "$2")" 2>/dev/null || true +} # dcg_init: turn the gate on for the sweep's channel. Returns 1 when # SWEEP_AGENT_REF is set but is not a channel tag (a configuration error). @@ -61,6 +81,9 @@ dcg_init() { return 1 fi DCG_SWEEP_REF="$SWEEP_AGENT_REF" + if [ -z "$DCG_CACHE_DIR" ]; then + DCG_CACHE_DIR="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/dcg.XXXXXX")" || return 1 + fi } # dcg_parse_agent_ref: stdin = dev-lead.yml text; prints the ref its harness @@ -83,8 +106,7 @@ dcg_parse_agent_ref() { # Cached per sweep. dcg_target_pin() { local repo="$1" out rc=0 - if [ -n "${_DCG_PIN_CACHE[$repo]+set}" ]; then - out="${_DCG_PIN_CACHE[$repo]}" + if out="$(_dcg_cache_get pin "$repo")"; then if [[ "$out" == "!"* ]]; then return "${out#!}"; fi printf '%s\n' "$out"; return 0 fi @@ -102,10 +124,10 @@ dcg_target_pin() { rc=1 fi if [ "$rc" -ne 0 ]; then - _DCG_PIN_CACHE[$repo]="!${rc}" + _dcg_cache_put pin "$repo" "!${rc}" return "$rc" fi - _DCG_PIN_CACHE[$repo]="$out" + _dcg_cache_put pin "$repo" "$out" printf '%s\n' "$out" } @@ -122,17 +144,19 @@ _dcg_fetch_at() { # Returns 1 when any of those files cannot be read. Cached per sweep. dcg_fields_read_at() { local ref="$1" src all="" lib - if [ -n "${_DCG_FIELDS_CACHE[$ref]+set}" ]; then - [ "${_DCG_FIELDS_CACHE[$ref]}" != "!" ] || return 1 - printf '%s' "${_DCG_FIELDS_CACHE[$ref]}"; return 0 + if all="$(_dcg_cache_get fields "$ref")"; then + [ "$all" != "!" ] || return 1 + [ -z "$all" ] || printf '%s\n' "$all" + return 0 fi + all="" local -a queue=("$DCG_PARSER") seen=() while [ "${#queue[@]}" -gt 0 ]; do lib="${queue[0]}"; queue=("${queue[@]:1}") case " ${seen[*]} " in *" $lib "*) continue ;; esac seen+=("$lib") if ! src="$(_dcg_fetch_at "$lib" "$ref")" || [ -z "$src" ]; then - _DCG_FIELDS_CACHE[$ref]="!" + _dcg_cache_put fields "$ref" "!" return 1 fi # Drop shell comments (not `$#`), so prose naming a field is not a read. @@ -143,15 +167,15 @@ dcg_fields_read_at() { all="$(grep -oE '\.client_payload\.[A-Za-z_][A-Za-z0-9_]*' <<<"$all" \ | sed 's/^\.client_payload\.//' | sort -u || true)" [ -z "$all" ] || all+=$'\n' - _DCG_FIELDS_CACHE[$ref]="$all" + _dcg_cache_put fields "$ref" "$all" printf '%s' "$all" } # _dcg_warn_once : a ::warning:: per key per sweep, so a repo with # many held PRs logs its reason once. _dcg_warn_once() { - [ -z "${_DCG_WARNED[$1]+set}" ] || return 0 - _DCG_WARNED[$1]=1 + ! _dcg_cache_get warned "$1" >/dev/null || return 0 + _dcg_cache_put warned "$1" 1 echo "::warning::$2" >&2 } diff --git a/tests/dev-lead/unit/test_dispatch_channel_gate.bats b/tests/dev-lead/unit/test_dispatch_channel_gate.bats index a893579bd..7822115a9 100644 --- a/tests/dev-lead/unit/test_dispatch_channel_gate.bats +++ b/tests/dev-lead/unit/test_dispatch_channel_gate.bats @@ -36,6 +36,9 @@ setup() { export TARGET_ORG="petry-projects" export DCG_HOST_REPO="petry-projects/.github-private" unset SWEEP_AGENT_REF DELEGATION_ORGS GITHUB_REPOSITORY + # The per-sweep cache dcg_init would make. + export DCG_CACHE_DIR="$BATS_TEST_TMPDIR/dcg" + mkdir -p "$DCG_CACHE_DIR" # gh stub. Every call is logged to $MOCK_BIN/calls; an unexpected call fails # loudly (exit 97) so a test never passes on a silent default. @@ -172,8 +175,8 @@ _calls() { grep -c -- "$1" "$MOCK_BIN/calls" || true; } [ "$status" -eq 0 ] [ "$output" = "dev-lead/v139-ring1" ] dcg_target_pin petry-projects/markets >/dev/null - dcg_target_pin petry-projects/markets >/dev/null - [ "$(_calls 'repos/petry-projects/markets/contents')" -eq 2 ] # run's subshell + the cached one + [ "$(dcg_target_pin petry-projects/markets)" = "dev-lead/v139-ring1" ] + [ "$(_calls 'repos/petry-projects/markets/contents')" -eq 1 ] } @test "pin: a 404 from a repo the token cannot read is unreadable (1), not absent" { @@ -224,11 +227,14 @@ _calls() { grep -c -- "$1" "$MOCK_BIN/calls" || true; } # ── dcg_init / dcg_target_reads ────────────────────────────────────────────── -@test "init: unset → gate off; a channel tag → on; anything else is an error" { +@test "init: unset → gate off; a channel tag → on with a cache dir; anything else is an error" { dcg_init [ -z "$DCG_SWEEP_REF" ] - SWEEP_AGENT_REF=dev-lead/v139-ring0 dcg_init + DCG_CACHE_DIR="" + SWEEP_AGENT_REF=dev-lead/v139-ring0 RUNNER_TEMP="$BATS_TEST_TMPDIR" dcg_init [ "$DCG_SWEEP_REF" = "dev-lead/v139-ring0" ] + [ -d "$DCG_CACHE_DIR" ] + [[ "$DCG_CACHE_DIR" == "$BATS_TEST_TMPDIR"/dcg.* ]] SWEEP_AGENT_REF=main run dcg_init [ "$status" -eq 1 ] [[ "$output" == *"::error::"* ]] @@ -277,6 +283,22 @@ _calls() { grep -c -- "$1" "$MOCK_BIN/calls" || true; } grep -q '::warning::.*petry-projects/broodly.*dev-lead/v1-stable.*comment_node_id' "$err" } +@test "reads: the cache survives the subshells the sweep calls it in" { + DCG_SWEEP_REF=dev-lead/v139-ring0 + _pin petry-projects/broodly dev-lead/v1-stable + _parser dev-lead/v1-stable "${V1_FIELDS[@]}" + local i out + for i in 1 2 3; do + out="$(dcg_target_reads petry-projects/broodly pr_number comment_node_id 2>&1 || true)" + done + [ "$(_calls 'repos/petry-projects/broodly/contents')" -eq 1 ] + [ "$(_calls 'dev-lead-intent.sh?ref=')" -eq 1 ] + [ "$(_calls 'helper.sh?ref=')" -eq 1 ] + # ...and so does warn-once: the later calls only log the hold. + [[ "$out" != *"::warning::"* ]] + [[ "$out" == *"[hold] petry-projects/broodly"* ]] +} + @test "reads: no dev-lead.yml → held without a warning" { DCG_SWEEP_REF=dev-lead/v139-ring0 run dcg_target_reads petry-projects/no-stub pr_number