Repository navigation
fix(ci): strict merge-preflight refused every PR into an unprotected base - #452
Conversation
The required-contexts leg reads branches/<base>/protection. For a base with no classic protection GitHub answers 404 "Branch not protected" - the same shape as a permission failure - so strict mode printed "could not read" and PREFLIGHT FAIL for every PR into feat/aoa-balance-mode-5.5.0 (#446, #447), and those PRs were being merged past a gate that could never pass. Accept "requires nothing" only on two positive reads: branches/<base> says protected == false (missing field -> "null" -> still FAIL), and the rulesets endpoint (paginated, slurped) is an array of pages containing no required_status_checks rule. Anything else remains an unread value. Controls (PR 447 = unprotected feat base, PR 450 = protected main): - live 447: INFO unprotected -> PASS; live 450: "all 6 required contexts" (unchanged) - gh shim, must FAIL: protected=null; ruleset requiring checks on page 2; rules endpoint error; rules endpoint returns an object -> all 4 FAIL - gh shim, must PASS: passthrough; [[]]; non-check rule only -> all 3 PASS Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177oe2fF7ywx8M9VSKcEbij
…ncode the base Codex round 1 on 0072faf: - High: the base branch was interpolated raw into endpoint paths. `feat/x#y` is a legal ref; unencoded, gh requests `feat/x` (fragment dropped) and the leg reports a different branch's protection. All three endpoints in this leg (protection, branch, rulesets) now use the @uri-encoded name; an encoding failure is refused like an unreadable base. - Medium: `[ "$(gh api ... --jq .protected)" = false ]` discarded gh's exit status. Each gh call and jq parse is now an exit-checked assignment; jq -e with error() on any unexpected shape. My own regression while fixing it: jq -e exits 1 when the result IS false, so the hardened leg could never accept - the positive controls went red and caught it. `.protected` is now emitted via tostring. Controls (447 = unprotected feat base; 450 = protected main): - must FAIL (9/9): protected missing; protected:"false" (string); gh exit 1 printing {"protected":false}; non-JSON branch body; ruleset requiring checks on page 2; rules gh exit 1 printing [[]]; rules object; rules non-JSON; base named `feat/...#locked` (log shows %2F/%23 in the request path) - must PASS (3/3): passthrough, [[]], non-check rule only - live 447 PASS; live 450 still "all 6 required contexts reported"; --ci unchanged Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177oe2fF7ywx8M9VSKcEbij
|
[repo:dsr] DSR strict review — REQUEST CHANGES @ 本 PR 修复了 unprotected base 的 404 误拒绝、URL 编码和新增分支的命令退出码,但目前仍不能作为“不会误放行”的严格 merge gate:
已独立确认的正向项:URL 编码正确;新增 unprotected 分支的 gh/jq 普通错误 fail closed;真实 #447 在当前 head 的严格运行进入 unprotected/no-rules 路径并 PASS;bash 语法与 diff whitespace 无误。 因此请不要合并 #452,也不要据此合并 #447,直到上述 High 项修复且 strict-only fixture test 入库。修复后请请求对新完整 head SHA重新 review。 |
clestons
left a comment
There was a problem hiding this comment.
#452 — APPROVE [2-round: R1a+R1b; no Opus — CI script fix, no security surface touched]
Head 8b6786df2ff70baa389a91d1d25b7b2631c6f94d. Single file (scripts/merge-preflight.sh, +30/-2): the strict-mode required-contexts check previously printed FAIL for any unprotected base, because GitHub's 404 "Branch not protected" and "no permission" look identical over the API — this blocked every PR into a long-lived unprotected feature branch under strict gating. Fix accepts "base requires nothing" only on two independent positive reads (branch object has protected:false as a real boolean, AND the rulesets endpoint returns no required_status_checks rule), plus URL-encodes the base name (Codex round-1 catch: an unencoded feat/x#y ref gets truncated to feat/x at the fragment).
Verification (run by me, not just read)
- Confirmed with a fresh
git diff --stat origin/main...HEADagainst this PR's actual branch: exactly 1 file, +30/-2 — matches the PR body's own claim that pre-pr-check's SZ-3/SZ-4 findings (cross-directory, high-risk mix) were false positives from a stale localmaincomparison base. - Re-ran
pre-pr-check.sh --base origin/mainfresh in a worktree at PR head: 0 block / 8 review (D1×6, T1, B1) — exactly matches the PR's self-report once the two stale SZ findings are subtracted (10 → 8). - Directly tested every cell the PR's own truth table claims, via real
jqinvocations with the exact expressions from the diff:[[]](single empty page) → accepted (nreq=0);[[{"type":"required_status_checks"}]]→ rejected (nreq=1); a bare JSON object, non-JSON body, and{"protected":"false"}(string not boolean) all correctly error viajq -er ... else error("unread")and exit non-zero. All match the claimed PASS/FAIL cells. - Confirmed the self-reported regression is real:
echo '{"protected":false}' | jq -er '... then .protected else error(...) end'(without thetostringfix) printsfalsebut exits 1 —jq -ereally does treat a boolean-falseresult as a non-match. Withouttostring, the unprotected-branch acceptance leg could never succeed even on the exact case it exists for. This is a real, subtle jq behavior and the fix for it is correct and necessary.
R1a [Medium] — REJECT (empirically disproven): "the all(.[]; type=="array") slurp-shape check may reject a valid single-page response." Tested directly above — a single-page --paginate --slurp response is [[...]] (page wrapped once by the endpoint's own array return, once by slurp), which passes the check and extracts correctly. Not reproducible.
R1a [Low]×2 — REJECT: F2 (tostring comparison) is exactly the documented, verified-necessary workaround above, not a latent risk. F3 (ruleset .type field) matches GitHub's actual documented ruleset rule-object schema (required_status_checks is a real type value); no evidence given it's wrong.
R1b [Medium]/[Low] — both affirm the fix rather than finding new risk: URL-encoding via jq -n --arg b ... '$b|@uri' does prevent path/fragment injection as claimed; the two-positive-reads design with per-call exit-code checking is exactly what the D1 2>/dev/null additions are paired with (confirmed above — every 2>/dev/null sits inside an &&-chained exit-code check, never silently swallowed).
T1 (no new test): PR body's reasoning holds — the behavior depends on a real open PR's live GitHub state (an actually-unprotected branch, an actually-protected one for regression), which isn't reasonably mockable as a stable repo-internal test; the PR's own two-PR table (open #447 unprotected / #450 main protected) is the operational equivalent, and removing the fix reproduces the exact pre-fix FAIL on #447 as claimed.
Scope confirmed: only the unprotected-base acceptance leg is new; the protected-base path and --ci mode are untouched (confirmed by reading the diff context — both existing branches above/below the new elif are unmodified).
…g (Codex round 2) Codex round 2 confirmed both round-1 fixes and found: - New (mine): the @uri encoding's exit status was discarded - a jq that printed the value and exited non-zero was trusted. Now `|| base_enc=""`. The base lookup at the top of the leg gets the same treatment. - Pre-existing: the protected path's guard and reqctx parse ignored jq's status. `contexts: 42` made jq exit 5 with empty output, read as "requires no status checks". Both are now jq -e with error() on unexpected shapes; a reqctx parse failure is its own FAIL. - Pre-existing: if the here-string cannot be created (bash 3.2 uses a temp file) the loop body never runs and `missing` stays empty -> "all reported". The loop's status is now checked; the body's last command always succeeds, so non-zero can only be the redirection. Controls (450 = protected main, 447 = unprotected feat): - pre-fix column: main@origin 450 ctx42 -> "INFO requires no status checks"; 8b6786d 447 encfail -> PREFLIGHT PASS. Both fail-open. - after: 450 ctx42 -> FAIL could not parse; 450 protection+garbage -> FAIL; 450 contexts=[never-ran-check] -> FAIL never reported (shim-took control); 450 passthrough -> "all 6 required contexts reported"; 447 encfail -> FAIL. - loop mechanism checked on bash 3.2 via a failed input redirection (loop_ok=0) vs normal input with misses and blank lines (loop_ok=1); the here-string failure itself could not be induced locally. - round-1 matrix re-run: 4 FAIL cells FAIL, 3 PASS cells PASS. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177oe2fF7ywx8M9VSKcEbij
|
第三个提交
修复前那一列
两处都是 fail-open。 修复后:上面两格都变为 FAIL。另外:
第 3 条的验证是间接的:我在本机没能真的让 here-string 创建失败,所以改用一次失败的输入重定向,在 bash 3.2 上验证 |
|
[repo:dsr] Incremental re-review — still REQUEST CHANGES @ 已确认第三个提交修复了 base/encoding 退出码、明显错误的 protected contexts 类型和 loop redirection;但上一条 DSR review 的主要 blockers 尚未关闭:
远端 head 在结论前复核未再变化。当前仍不建议 approve/merge;请修复后对新的完整 head 重新请求独立 review。 |
clestons
left a comment
There was a problem hiding this comment.
#452 — REQUEST_CHANGES (round 2) — retracts my own prior APPROVE on 8b6786df
Head d865790ea6e6e35b9c5e5a24ce58e3bca9c6a941. My prior round approved 8b6786df for the unprotected-base acceptance leg. An independent reviewer on this repo ("DSR") then posted REQUEST_CHANGES at that exact same head with 3 High + 2 Medium findings that my own review missed. I re-derived each High finding myself, against the current head, before writing this:
Still open — 2 of 3 DSR High findings, both empirically reproduced by me just now against the current code (not just re-asserted):
-
Ruleset-shape fail-open (
merge-preflight.shcurrentnreq=line, unchanged by this round's commit): I ran the actualjqexpression from the current script against[],[[null]],[[{}]],[[{"type":null}]],[[{"type":42}]]— every one of these malformed/garbage shapes producesnreq=0, exit 0, which is exactly the "requires nothing" acceptance value. The shape guard only checkstype=="array" and all(.[]; type=="array")— it never checks that each rule item is an object with a real string.type, so a page containingnull,{}, or a non-stringtypesilently contributes nothing to the count instead of refusing. This is the same "unread value treated as a confirmed zero" bug class this whole PR exists to close, just one level deeper (object shape, not page shape). Not touched by this round's commit — confirmed viagit show origin/fix/preflight-unprotected-base:scripts/merge-preflight.shstill contains the identical line. -
Required-context presence ignores conclusion (
merge-preflight.sh, theallcheck/matching loop, also unchanged by this round's commit):allcheckis built as[.check_runs[].name]with no conclusion filter, and the matching loop doesprintf '%s\n' "$allcheck" | grep -qxF "$c"— checking only that a check-run with the required name exists, regardless of itsconclusion. A required check that concludedstale(a real GitHub value, not in the existing failure blacklistfailure/timed_out/cancelled/action_required) still satisfies "reported" and the gate reportsOK. I did not need to fabricate a live case — the logic itself admits any conclusion, including one that is neither passing nor in the failure list, as satisfying presence. This is directly adjacent to the hunk this round touched (same loop body, three lines below the newloop_okguard) but the presence check itself was left as-is.
Fixed — 1 of 3 DSR High findings, confirmed by me: "protected-base jq failure read as zero required checks" — I tested {"enforce_admins":{},"required_status_checks":{"contexts":true}} (a malformed-but-plausible protection response) against the current reqctx= line: it now correctly exits non-zero and sets reqctx_ok=0 → FAIL, where the pre-round-2 code silently produced an empty string that was indistinguishable from "requires nothing." This specific regression closure is real and verified.
DSR Medium findings (not independently re-verified by me, carried forward for the author):
- CI only exercises
--cimode; the changed branch ([ "$CI_MODE" -ne 1 ]) has no fixture-backed test in the repo, same T1 gap as round 1 — now more pressing given two more fail-open paths were found by inspection rather than by a reproducible test. - Ruleset-only required-checks currently always FAIL (fail-closed, not a safety bug) — behavior-scope clarification only, not blocking.
Why I'm flagging my own prior approval: I verified round 1's specific truth-table cells exhaustively, but did not independently probe adjacent, unchanged code in the same gating function for the same bug class — the ruleset-shape and stale-conclusion gaps live one level below where I looked. This is a correction I can verify myself, not a secondhand claim: both reproductions above use the actual current jq expression from the file, run directly.
Not blocking — my own round-1 verification still holds for what it covered: the URL-encoding fix, the base_for_req/base_enc empty-fallback fix, and the .protected tostring fix are all still correct and untouched by this finding — this is not a full retraction, only an extension of scope to two adjacent gaps in the same function.
Please close DSR's two still-open High findings (ruleset-item shape validation; conclusion-aware required-context matching) before this can be approved as a strict gate. Re-request review with the new full head SHA once pushed.
…not normalized (Codex round 3)
Codex round 3 confirmed all round-2 fixes closed and found five more:
1. A protected base never consulted rulesets, so a ruleset-required check
was invisible. The leg now always reads both sources and verifies the
union; an unprotected base with a ruleset check rule is now VERIFIED
(was: refused outright).
2. `.contexts // []` normalized false/null/missing into "requires nothing",
`[""]` passed, and `checks[].context` was never read. A real protection
object's required_status_checks is now either null (genuine zero) or an
object whose contexts is an array; contexts + checks[].context are read.
3. Ruleset entries were not validated: [[{}]], [[null]], [[{"type":42}]]
all read as zero required rules. Every rule must be an object with a
string type; a required_status_checks rule must carry a context list.
4. `grep -qxF "$c"` let a name like `-ebuild` act as an option; now `-e`.
5. Newline-joined sets let one name containing a newline forge a boundary.
Names must be non-empty strings without a newline, in both the required
set and the check-run list (the latter fails the api lookup -> exit 4).
Matrix (450 = protected main, 447 = unprotected feat), 31/31 as expected:
- 450 FAIL (11): contexts missing-check, 42, garbage, false+checks, null,
absent, [""], checks-only absent check, "-etest", "test\nx402-node",
ruleset requiring absent `security`
- 450 ACCEPT (2): contexts [test]+checks [x402-node]; passthrough (6 contexts)
- 447 FAIL (14): branch null/"false"/exit1/garbage; rules exit1/object/
garbage/[[{}]]/[[null]]/[[{"type":42}]]/bad params/absent `security`;
base `...#locked`; @uri jq exit 7
- 447 ACCEPT (4): ruleset requiring present `cla-check` (verified, 1 context);
[[]]; non-check rule; passthrough
Newline check-run name: the allcheck jq exits 5 on {"name":"build\nsecurity"},
0 on two separate names and on [].
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177oe2fF7ywx8M9VSKcEbij
|
第四个提交 |
clestons
left a comment
There was a problem hiding this comment.
#452 — REQUEST_CHANGES (round 3) — 1 of my round-2 findings still open
Head 1fbc43ecb50340660d3b7d999632aabee45a1189. This round (Codex round 3) substantially redesigned the required-contexts leg: the required set is now the UNION of classic branch-protection requirements and repository-ruleset requirements (previously a protected base never consulted rulesets at all), every source is validated rather than normalized, and two injection-style issues (a --prefixed check name being parsed as a grep option; a newline inside a check-run name forging a boundary in the newline-joined comparison set) are closed.
Fixed — my round-2 finding #1 (ruleset-item-shape fail-open), re-verified empirically against the new code: ran the exact new jq validation expression against [], [[null]], [[{}]], [[{"type":null}]], [[{"type":42}]] — the 4 真正 malformed shapes now correctly exit 5 (unread); only the structurally-empty [] case still passes through as "zero rules" (unchanged from round 1's accepted behavior for that cell, not a regression).
Spot-checked and confirmed, 2 of this round's own claimed fixes: a check name like -ebuild is now correctly matched literally via grep -qxF -e (reproduced the OLD bug too: without -e, grep errors trying to parse it as an option); a check-run name containing an embedded newline is now correctly rejected (error("newline in check name"), exit 5) while two genuinely separate normal names still pass through fine (exit 0).
Still open — my round-2 finding #2 (required-context presence ignores conclusion), confirmed unaddressed by this round and sharper than I first framed it: allcheck (line 340 of the current file) is still built as [.check_runs[].name] — still no conclusion filter, and the matching loop at line 560 still does printf '%s\n' "$allcheck" | grep -qxF -e "$c", checking only that a check-run with the required name exists. I simulated the exact matching logic with a required context "deploy" present in allcheck with no conclusion check at all: it reports OK, with no way to distinguish a genuinely successful run from one that reported under the same name with conclusion stale, neutral, or skipped. The last one is the practically important case, not the rare one — GitHub's documented check-run conclusion enum includes skipped for a job that ran but short-circuited (a path filter inside the job via an if: condition, as opposed to a workflow-level path filter that would make the check never appear at all), and skipped is not in the existing bad-conclusion blacklist (failure/timed_out/cancelled/action_required) and the check is status: "completed" so it's not in pend either. This directly contradicts the PR's own repeatedly-stated design principle, verbatim from its own comment a few lines below the matching loop: "A required check that did not run is ABSENT, not passing." A required check that ran but skipped is exactly the case that principle is supposed to cover, and today it isn't — it's silently read as "reported," i.e. passing.
Verification (run by me)
- All CI checks green (2 legitimately
skipping, unrelated to this script). git diff --stat origin/main...HEAD(fresh fetch): exactly 1 file,scripts/merge-preflight.sh, +95/-41 cumulative — matches the PR's own stated scope across all 3 commits.- Re-ran
pre-pr-check.sh: 0 block/10 review (D1×8, T1, B1) against the correct scope. The PR body's own pre-pr-check answer section is from round 1 and hasn't been updated to answer round 3's own D1 findings by ID — not blocking (same D1 category already thoroughly answered twice for the same pattern), but worth a one-line update for the record.
Not blocking — everything else in this round checks out: the protected+ruleset union, the contexts/checks[].context dual-read, and the newline/grep-option closures are all real, verified fixes, not regressions.
Please add a conclusion check to the matching loop — require conclusion=="success" (or at minimum exclude neutral/skipped/stale alongside the existing failure-conclusion blacklist) for any name counted as "reported" — before this can be approved as a strict gate. Re-request review with the new full head SHA once pushed.
Codex round 4 confirmed round-3 items 1/2/4/5 closed (3 partial) and found: - High (mine): the awk union's exit status was unchecked; a failed union read as "requires nothing". Both sources now emit validated JSON arrays and the union is one status-checked jq call (`could not form ... required set`). - `$r.checks // []` normalized false/null; now `checks` absent -> [], present -> must be an array of objects. - Whitespace-only names were dropped by awk NF; NUL bytes are stripped by bash command substitution (`bu\0ild` -> `build`). Names must contain a non-space and no [[:cntrl:]] char; the check-run list refuses control chars too. (`\u0000` ranges do not work in jq/Oniguruma regex - matched literal text and rejected every real name; caught by probing valid names first.) - Rulesets: outer [] (zero pages) and `"type":""` passed; now >=1 page and a non-empty string type are required. - Protection discriminator: `enforce_admins: null` qualified; now enforce_admins must be an object and url a string. Matrix 40/40 (fixtures for the protected path now carry `url`, so earlier cells still test what they claim rather than failing at the discriminator): - 450 FAIL 18: + checks:false, [" \t"], ["te\0st"], enforce_admins:null, no url, ruleset context " ", union jq exit 7 - 450 ACCEPT 2: contexts+checks present; passthrough (6 contexts) - 447 FAIL 16: + rules [] (zero pages), rule type "" - 447 ACCEPT 4: ruleset requiring present cla-check; [[]]; non-check rule; passthrough Validator probes: real names incl. "Stage 1 — solhint + build (EIP-170)" pass; "", " ", " \t", NUL, \n, DEL, non-string rejected; allcheck jq rc=5 on "build\nsecurity" and NUL, rc=0 on normal names and []. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177oe2fF7ywx8M9VSKcEbij
|
第五个提交处理了 Codex 第四轮的结论(1 High + 5 Medium:awk 并集退出码没检查、checks 被归一化、空白/NUL 名字、空 rulesets 页/空 type、enforce_admins:null)。40 格对照全部符合预期,见 commit message。第五轮 Codex 尚未运行,按作者要求先停在这里。 |
clestons
left a comment
There was a problem hiding this comment.
#452 — REQUEST_CHANGES (round 4) — my finding from round 2 still unaddressed, now through 2 rounds
Head a2e5cb348261f6356bace689889716728761ec5e. Codex round 4 found and this commit fixes 5 more real gaps: the awk-based union's exit status was unchecked (a failed union silently read as "requires nothing" — now a single status-checked jq call); checks // [] normalized false/null (now checks must be absent→[] or present→an array of objects); whitespace-only and NUL/control-char names slipped through awk's NF test and bash's command-substitution NUL-stripping (now every name must contain a non-space character and no control character, checked on both the required set and the check-run list); a ruleset response of zero pages ([]) and an empty-string rule type both passed as "no rules" (now rejected); and the protection-object discriminator accepted enforce_admins: null (now requires enforce_admins to be an object and url to be a string).
All 5 of this round's claimed fixes verified empirically by me, with the actual jq expressions from the diff:
- Union exit-code check:
jq -ern --argjson a [] --argjson b 'not-json'correctly errors (exit 2, caught by the script'sif ! reqctx=$(...)); a well-formed union still produces the correct joined set. - Whitespace/control-char name validation:
[" ", "x"]and["\t"]both correctly rejected (exit 5);["build"]passes; a NUL-containing string correctly rejected. - Ruleset outer-empty/empty-type:
[]and[[{"type":""}]]both now correctly rejected (exit 5); a legitimate non-empty rule entry still passes. - Protection discriminator:
{"enforce_admins":null,"url":"x"}now correctly rejected;{"enforce_admins":{...},"url":"x"}passes; missingurlis rejected.
Still open — my round-2 finding #2 (required-context presence ignores conclusion), now unaddressed through round 3 AND this round 4: I re-checked the exact two lines involved at this new head — allcheck (line 340) is still built as [.check_runs[].name] (round 4 only added a control-character check to this same line, the name-only construction itself is untouched), and the matching loop (line 565) is still printf '%s\n' "$allcheck" | grep -qxF -e "$c" — matching on name only, no conclusion check anywhere. Both Codex rounds 3 and 4 have focused entirely on shape/encoding robustness (malformed JSON, NUL bytes, whitespace, empty strings) — a different bug class from mine, which is about a well-formed, real check-run reporting under its required name with a conclusion that isn't a pass (skipped, neutral, or stale). This is unrelated to anything either Codex round has probed, so it's unsurprising it keeps surviving, but it's still a real, concrete gap in exactly the property this whole PR exists to establish.
To make this maximally actionable this time: the fix is one line at ~565, adding a conclusion check alongside the existing name check. Something like:
printf '%s\n' "$allcheck" | grep -qxF -e "$c" \
&& [ "$(printf '%s' "$succeeded" | grep -qxF -e "$c" && echo y)" = y ] \
|| missing="${missing:+$missing, }$c"where $succeeded is a NEW api call (or an additional field pulled from the EXISTING check-runs response, which the script already fetches in full at the top of this section) filtering to conclusion=="success" only — the same --paginate response already used to build allcheck, bad, and pend already carries .conclusion for every run, so this doesn't need a new API round-trip, just reading one more field off data already in hand.
Verification (run by me)
- All CI checks green (2 legitimately
skipping, unrelated). git diff --stat origin/main...HEADfresh: 1 file,scripts/merge-preflight.sh, +100/-41 cumulative.- The PR body is now substantially stale (still shows round-1-era content); the commit messages are the actual source of truth per the PR's own established convention, and I read those directly rather than relying on the body.
Please add the conclusion check before this can be approved as a strict gate — this is the 3rd round (across my rounds 2, 3, 4) this same finding has gone unaddressed, through two full Codex rounds focused on an orthogonal bug class. Re-request review with the new full head SHA once pushed.
…-mode fixture suite in CI Review rounds 2-4 on #452 (DSR + pr-daemon) flagged, and I left unaddressed three times while working only from Codex output: the required-checks leg matched by NAME alone. A required check whose latest run was skipped, neutral, stale, still running or failed - or a same-named run from a different App - counted as "reported". Live proof on #450 (main): Stage 2 and `test` concluded failure; the old leg printed "OK all 6 required contexts reported" (the separate failing-checks leg caught it, this one did not). - Requirements are {context, app_id} from classic protection (contexts -> any App, checks[].app_id) and rulesets (integration_id). A specific-App requirement supersedes an any-App one for the same context. - Satisfied only if the LATEST run (max id) with that name, from that App, is completed with conclusion == success: an allowlist. Evaluated in one status-checked jq call over validated run records (no newline-joined text, no grep). - `stale` added to the global failing-conclusions list. - app_id must be null or a positive integer; run records must be well-formed. scripts/test_merge_preflight_strict.py: offline suite (fake gh serving fixtures, emulating --jq/--paginate/--slurp; jq wrapper to inject print-then-fail). 48 scenarios; each asserts the exit status AND the exact line the required-checks leg printed. New workflow preflight-selftest.yml runs it on every PR (no paths filter: a skipped workflow is absent, not passing). Results: - this head: 48/48 (macOS bash 3.2, 97 s) - a2e5cb3 (previous head): exit 0 where FAIL is required in 8 scenarios - skipped, neutral, stale, other App, wrong ruleset integration, string app_id, malformed run record, union jq failure. Other mismatches there are message-wording only (exit 1 both ways) and prove nothing about this fix. - origin/main: exit 0 where FAIL is required in 23 scenarios. - live: 447 PASS (unprotected, no rule); 450 FAIL naming the two failed required checks; --ci 447 unchanged. Required-check conclusions on the last 15 merged main PRs: success x6 each, so the allowlist does not block normal PRs. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177oe2fF7ywx8M9VSKcEbij
|
更正:DSR 在
真实数据:#450 上 PR 正文已重写为当前状态。请对完整 head |
First CI run of preflight-selftest failed all 7 positive controls and passed all 41 negatives: inside Actions GITHUB_JOB names the test job itself, the script took it as SELF_NAME, and "SELF_NAME matches no check run" failed every fixture. Only the positive controls could see this - every negative would have read FAIL either way. The suite now strips GITHUB_* and pins SELF_NAME. Re-run locally with GITHUB_JOB/GITHUB_RUN_ID set: 48/48. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0177oe2fF7ywx8M9VSKcEbij
|
head 更新为 这一提交只改了测试文件:第一次 CI 运行时,7 个正对照全部失败,41 个负对照全部通过。原因是在 Actions 里, 修复:测试改为清掉全部 |
clestons
left a comment
There was a problem hiding this comment.
#452 — APPROVE (round 5) — the 3-round finding is finally closed
Head 3c558f4d390a252e4cdc615ca86c1c6ef97981f7. This round finally fixes the finding I raised across rounds 2/3/4: the required-checks leg matched a required check by name alone, so skipped/neutral/stale runs, a same-named run from a different GitHub App, or a latest-run-failed-after-an-older-success scenario all incorrectly counted as "reported." The author's own commit message explicitly acknowledges this was "left unaddressed three times while working only from Codex output" and includes live proof: on real PR #450 (main), Stage 2 and test concluded failure while the old leg printed OK all 6 required contexts reported.
The fix: requirements are now {context, app_id} pairs (classic protection contexts→any-App, checks[].app_id→specific; rulesets' integration_id→specific), unioned, with a specific-App requirement superseding an any-App one for the same context. A requirement is satisfied only if the latest run (max_by(.i)) matching {name, app} is status=="completed" and conclusion=="success" — an explicit allowlist, not a failure blacklist — evaluated in one status-checked jq call over validated, well-formed check-run records. A new 48-scenario offline fixture suite (test_merge_preflight_strict.py, fake gh+jq injection) plus a new CI workflow (preflight-selftest.yml, no paths filter) runs it on every PR.
Verification (run by me, not just read)
- Ran the actual fixture suite myself: 48/48 pass at this head.
- Mutation test: removed the
conclusion != "success"branch from the verdict jq → exactly the 4 scenarios tied to my own finding flipped (skipped,neutral,stale,latest-failed-after-older-success). Reverted, re-confirmed 48/48. - Two standalone jq probes on the app_id-supersedence logic: any-App+specific-App(99) requirement for the same context, any-App succeeds/App-99 fails → correctly reports only the App-99 requirement, correctly flags failure (not masked). Reversed case → any-App correctly ignored, verdict passes.
- All CI green including the new
preflight strict-mode fixturescheck.git diff --stat origin/main...HEAD: exactly 3 files, +458/-42.
R2's deeper adversarial pass, independently verified live on #450's real data: confirmed the three-way duplicate/ambiguity case resolves correctly via a real jq run (exact-duplicate {context,app_id} collapses via unique, any-App drops out, both distinct specific-App entries survive independently). Found the check-runs API call has no filter= param (GitHub defaults to filter=latest), meaning same-suite reruns are already collapsed server-side — the "older-success-then-newer-failure" fixture is mostly defence-in-depth; but same-named runs from different check suites do both survive filter=latest, and R2 confirmed this live on #450's actual data (preflight-report appears twice across two suites) and confirmed max_by(.i) picks the newer run, correctly. Confirmed a completed run with conclusion: null is correctly treated as not-success.
R4's confirmation + one new residual note: confirmed R2's test-coverage gap is real (no fixture directly names "a single required run that simply failed" — the real #450 incident's actual shape — though the logic is covered by implication via the skipped/neutral/stale fixtures sharing the same code branch, and the mutation test already proved that branch is load-bearing). Also confirmed the app_id-supersedence select line itself has no dedicated fixture — deleting it wouldn't currently turn any test red; only the standalone probes (mine and R2's) cover it. New, Low, non-blocking: flagged a narrow theoretical divergence from GitHub's own merge-button semantics — if classic protection requires build from any-App and a ruleset requires it from a specific App-X, the script drops the any-App requirement entirely; if App-X's latest run succeeds while a newer any-App run (from App-Y) fails, this script would pass where GitHub's own gate (which may evaluate each source independently) could still block. Not verified against live GitHub behavior either way — flagged as worth a doc note or a conservative "keep both" change, not blocking.
Should-fix (non-blocking, all about test-suite coverage, not logic correctness)
- Add a fixture for a single required run that simply concluded
failure— the actual real-world #450 incident shape, currently covered only by implication. - Add a fixture for the any-App-superseded-by-specific-App case, so the
selectsupersedence line is actually guarded by CI. - Add a fixture for two distinct specific-App requirements on the same context (one fails, must not be masked by the other's success).
- Document (or reconsider) whether dropping the any-App requirement when a specific-App one exists for the same context matches GitHub's own merge-gate semantics — the safe fallback if unsure would be requiring both rather than superseding.
- The PR body is still round-1-era and doesn't answer this round's own pre-pr-check IDs (D1×10, SZ-4, T2, B1) — worth an update for the record, though the commit messages already cover the substance thoroughly.
- Whether
preflight strict-mode fixturesbecomes a required check in branch protection is the repo owner's call, separate from this PR.
This closes the finding. Nothing here blocks — every open item is a test-coverage or documentation gap, not a logic defect, and the core allowlist behavior (the thing that actually matters) is proven correct by direct mutation testing and live data, not just the suite's own self-report.
自评块
- 轮数: 直接复核(我自己验证)+R2(Opus)对抗性核查+R4(Opus)最终确认;未升Codex(核心逻辑经变异测试+live数据双重验证,无Medium+)。
- 我自己的验证:实测48/48 fixture suite;变异测试精确对应我自己连续3轮提出的发现(skipped/neutral/stale/latest-failed-after-success四个场景同时翻转);两组standalone jq探针验证app_id优先级逻辑双向正确;CI全绸;diff范围核对(3文件)。
- R2: 独立对抗性核查,直接拉取#450真实数据验证max_by(.i)在跨check-suite场景下的真实表现;发现fixture suite本身缺少"单次required run直接失败"这个真实事故形状的专用用例(逻辑有覆盖,命名缺失);发现supersede逻辑的select行没有专属fixture守护。
- R4: 独立确认APPROVE,核实R2两条覆盖缺口均真实存在但不是逻辑bug;新增1条Low(any-App被specific-App覆盖掉这个设计可能与GitHub自己的merge-gate语义存在窄范围分歧,未实测验证,标注为非阻塞待办)。
- 结论: 持续3轮的发现这轮真正关闭,核心allowlist逻辑经直接变异测试+真实PR数据双重验证,不只是依赖suite自报。
当前状态(head
ff568e25730c62557aea97c5751e3028aea48399,以此为准;逐轮细节见各 commit message)修复
scripts/merge-preflight.sh严格模式(不带--ci)里的 required-checks 一段。该段原先存在两个问题:最终设计
{context, app_id}。classic 部分读取contexts和checks[].app_id;rulesets 部分读取integration_id。required_status_checks只有为null时才算零项;app_id只能是 null 或正整数;type的对象;enforce_admins是对象,并且url是字符串;stale也加入全局的失败结论列表。protected:false时,classic 部分才算没有要求;rulesets 照常核对。测试(DSR 要求入库)
scripts/test_merge_preflight_strict.py:离线测试,用一个假 gh 服务夹具,共 48 个场景。每个场景同时断言退出码和 required-checks 一段打印的那一行。preflight-selftest.yml,在每个 PR 上运行,不设 paths 过滤。a2e5cb34有 8 个场景应 FAIL 却放行:skipped、neutral、stale、其它 App、ruleset 的 integration 不匹配、app_id 为字符串、run 记录畸形、并集 jq 失败;OK all 6 required contexts reported);--ci行为不变。pre-pr-check:0 block / 13 review
2>/dev/null):都只丢弃 stderr,每一处的退出码都参与&&或||判定。对应的测试场景包括 gh exit 1、jq 先打印再 exit 7 等。合并后
#447 的 strict gate 可以运行;#446 仍需要 reviewer 用完整 head 重新 approve。
🤖 Generated with Claude Code
https://claude.ai/code/session_0177oe2fF7ywx8M9VSKcEbij