From 5d5ff8ef157d6b976d87c27aef985ca73faaaa32 Mon Sep 17 00:00:00 2001 From: Noah Gift Date: Fri, 9 Oct 2026 20:24:15 +0200 Subject: [PATCH 1/3] fix(ratchet): a pull_request re-run judges against its merge commit's first parent, not main's moving tip Refs #4983 A pull_request run checks out GitHub's merge commit at depth 1, so there is no merge-base and every baseline ratchet fell back to the origin/main TIP. A re-run keeps the old merge commit but fetches main fresh, so once main moves, main's own shrinks read as this PR's growth and an unchanged PR turns red on files it never touched (#4970 run 37945794011, m17). - lib_baseline_ratchet.sh: new FIRSTPARENT comparand, ahead of TIP, only for origin/main, only with no merge-base, only when the event is pull_request and HEAD is GITHUB_SHA with two or more parents. The parent is read from the commit object (HEAD^1 does not resolve under a depth-1 graft). Fail-closed: parent not fetched -> UNRESOLVABLE with a fetch-by-sha hint, never the tip; parent lacks the path -> BOOTSTRAP or ABSENT, never the tip. - ci/sections.yml (guard-tree and guard-cargo setup steps): on pull_request, fetch that first parent at depth 1. - census, complexity and readme callers: FIRSTPARENT admitted by census; UNRESOLVABLE hints name the fetch that resolves the ref. Red/green: ratchet_semantics_test.sh --class complexity C8 (control: the tip path reproduces the false red), C9 (parent unfetched -> RED at preflight, nothing measured), C10 (re-run GREEN on FIRSTPARENT), C11 (first run gives the same verdict on both paths), C12 (registered mutation at RATCHET-FIRSTPARENT-MUTATION-POINT turns C10 RED again). check_baseline_ratchets.sh --self-test gains 10 resolver/end-to-end rows; the same mutation turns 5 of them RED. Agent: aprender-a7n Co-Authored-By: Claude Opus 5.5 --- ci/sections.yml | 36 ++++++-- scripts/check_baseline_ratchets.sh | 101 ++++++++++++++++++++++ scripts/check_census_derived.sh | 4 +- scripts/check_complexity_ratchet.sh | 2 +- scripts/check_readme_claims.sh | 2 +- scripts/lib_baseline_ratchet.sh | 59 ++++++++++++- scripts/tests/ratchet_semantics_test.sh | 108 ++++++++++++++++++++++++ 7 files changed, 298 insertions(+), 14 deletions(-) diff --git a/ci/sections.yml b/ci/sections.yml index 249aedae8c..7f27b365d6 100644 --- a/ci/sections.yml +++ b/ci/sections.yml @@ -1147,10 +1147,12 @@ jobs: # never as "no growth", so the mistake would have been loud rather than # silent. It is still a mistake. One fetch, before all of them. # - # fetch-depth is 1 here, so the merge-base is usually unavailable and the - # ratchets fall back to the origin/main TIP. That is the STRICTER - # comparand, and it means the CI path is the tip path: a local run that - # passes on the merge-base can still red here, and CI is authoritative. + # fetch-depth is 1 here, so the merge-base is usually unavailable. On a + # pull_request run the ratchets then compare against the first parent of + # the run's merge commit (#4983), fetched below; on every other event + # without a merge-base they fall back to the origin/main TIP, the STRICTER + # comparand. A local run that passes on the merge-base can still red + # here, and CI is authoritative. - id: guard-setup name: Fetch origin/main as the comparand for every ratchet in this job run: | @@ -1159,6 +1161,14 @@ jobs: # its FIRST PARENT (scripts/lib/resolve_base.sh: HEAD vs HEAD would pass vacuously, the # G-10 quorum's finding), and a depth-1 checkout has not fetched that parent. Deepen by one. if [ "${GITHUB_EVENT_NAME:-}" = push ]; then git fetch --no-tags --deepen=1 origin +refs/heads/main:refs/remotes/origin/main; fi + # pull_request shape (#4983): HEAD is GitHub's merge commit, and its FIRST PARENT is the main + # this run merged the PR onto. A re-run keeps that commit but the fetch above brings main's + # NEW tip, so the ratchets judge against that parent (scripts/lib_baseline_ratchet.sh + # FIRSTPARENT), never the moving tip. Not fetched, every ratchet fails UNRESOLVABLE. + if [ "${GITHUB_EVENT_NAME:-}" = pull_request ]; then + p1=$(git cat-file -p HEAD | awk '/^parent /{ n++; if (n == 1) p = $2 } /^$/{ exit } END { if (n >= 2) print p }') + if [ -n "$p1" ] && ! git cat-file -e "${p1}^{commit}" 2>/dev/null; then git fetch --no-tags --depth=1 origin "$p1"; fi + fi # #4415 (operator A4, 2026-09-25): ONE step runs every guard step of guard-tree-steps # below, keeps going past a red one, and reports every failure. GitHub would # stop this job at its first red step, and the others would never be seen. @@ -1685,10 +1695,12 @@ jobs: # never as "no growth", so the mistake would have been loud rather than # silent. It is still a mistake. One fetch, before all of them. # - # fetch-depth is 1 here, so the merge-base is usually unavailable and the - # ratchets fall back to the origin/main TIP. That is the STRICTER - # comparand, and it means the CI path is the tip path: a local run that - # passes on the merge-base can still red here, and CI is authoritative. + # fetch-depth is 1 here, so the merge-base is usually unavailable. On a + # pull_request run the ratchets then compare against the first parent of + # the run's merge commit (#4983), fetched below; on every other event + # without a merge-base they fall back to the origin/main TIP, the STRICTER + # comparand. A local run that passes on the merge-base can still red + # here, and CI is authoritative. - name: Fetch origin/main as the comparand for every ratchet in this job run: | git fetch --no-tags --depth=1 origin +refs/heads/main:refs/remotes/origin/main @@ -1696,6 +1708,14 @@ jobs: # its FIRST PARENT (scripts/lib/resolve_base.sh: HEAD vs HEAD would pass vacuously, the # G-10 quorum's finding), and a depth-1 checkout has not fetched that parent. Deepen by one. if [ "${GITHUB_EVENT_NAME:-}" = push ]; then git fetch --no-tags --deepen=1 origin +refs/heads/main:refs/remotes/origin/main; fi + # pull_request shape (#4983): HEAD is GitHub's merge commit, and its FIRST PARENT is the main + # this run merged the PR onto. A re-run keeps that commit but the fetch above brings main's + # NEW tip, so the ratchets judge against that parent (scripts/lib_baseline_ratchet.sh + # FIRSTPARENT), never the moving tip. Not fetched, every ratchet fails UNRESOLVABLE. + if [ "${GITHUB_EVENT_NAME:-}" = pull_request ]; then + p1=$(git cat-file -p HEAD | awk '/^parent /{ n++; if (n == 1) p = $2 } /^$/{ exit } END { if (n >= 2) print p }') + if [ -n "$p1" ] && ! git cat-file -e "${p1}^{commit}" 2>/dev/null; then git fetch --no-tags --depth=1 origin "$p1"; fi + fi # DOCKER CREATES A MISSING BIND SOURCE AS root:root, AND `--user` ALONE # THEN EACCESs ON IT. The per-RUN target path is fresh every run, so a # container that mounts it and drops to the runner's uid cannot write diff --git a/scripts/check_baseline_ratchets.sh b/scripts/check_baseline_ratchets.sh index 2952390bb2..3a8ea7fc31 100644 --- a/scripts/check_baseline_ratchets.sh +++ b/scripts/check_baseline_ratchets.sh @@ -372,6 +372,107 @@ if [ "${1:-}" = "--self-test" ] || [ "${1:-}" = "--selftest" ]; then bad=1 fi + # FIRSTPARENT (#4983): a pull_request re-run. CI checks out GitHub's + # merge commit at depth 1, so there is no merge-base, and the re-run + # fetches origin/main fresh. Here main moved on and SHRANK the + # baseline (b deleted). Against the tip, the PR "appends" b, an + # entry it never touched; against the first parent, it changed + # nothing. The shallow clone is its own repository under $TD. + SC="$TD/shallow" + if git -C "$SR" checkout -q -b fp-pr "$SR_BASE" >/dev/null 2>&1 \ + && printf 'pr edit\n' > "$SR/scripts/unrelated.txt" \ + && git -C "$SR" -c commit.gpgsign=false commit -qam 'pr: unrelated edit' >/dev/null 2>&1 \ + && SR_X=$(git -C "$SR" rev-parse HEAD) \ + && git -C "$SR" checkout -q --detach "$SR_BASE" >/dev/null 2>&1 \ + && git -C "$SR" -c commit.gpgsign=false merge -q --no-ff --no-edit "$SR_X" >/dev/null 2>&1 \ + && SR_M=$(git -C "$SR" rev-parse HEAD) \ + && git -C "$SR" update-ref refs/pull/1/merge "$SR_M" \ + && git -C "$SR" checkout -q --detach "$SR_NOBASE" >/dev/null 2>&1 \ + && git -C "$SR" -c commit.gpgsign=false merge -q --no-ff --no-edit "$SR_BASE" >/dev/null 2>&1 \ + && SR_M2=$(git -C "$SR" rev-parse HEAD) \ + && git -C "$SR" update-ref refs/pull/2/merge "$SR_M2" \ + && git -C "$SR" checkout -q --detach "$SR_BASE" >/dev/null 2>&1 \ + && printf '# header\na\n' > "$SR/$P" \ + && git -C "$SR" -c commit.gpgsign=false commit -qam 'main: shrink the baseline' >/dev/null 2>&1 \ + && git -C "$SR" update-ref refs/heads/fp-main "$(git -C "$SR" rev-parse HEAD)" \ + && git -C "$SR" checkout -q --detach "$SR_BASE" >/dev/null 2>&1 \ + && git -C "$SR" config uploadpack.allowAnySHA1InWant true \ + && git init -q "$SC" >/dev/null 2>&1 \ + && [ "$(git -C "$SC" rev-parse --absolute-git-dir 2>/dev/null)" = "$SC/.git" ] \ + && git -C "$SC" fetch -q --no-tags --depth=1 "file://$SR" '+refs/pull/1/merge:refs/remotes/pull/1/merge' >/dev/null 2>&1 \ + && git -C "$SC" checkout -q --detach refs/remotes/pull/1/merge >/dev/null 2>&1 \ + && git -C "$SC" fetch -q --no-tags --depth=1 "file://$SR" '+refs/heads/fp-main:refs/remotes/origin/main' >/dev/null 2>&1 \ + && [ -z "$(git -C "$SC" merge-base HEAD origin/main 2>/dev/null)" ] \ + && ! git -C "$SC" cat-file -e "${SR_BASE}^{commit}" 2>/dev/null; then + + fp_row() { # fp_row