Skip to content

PR Base Freshness passes and then expires: 36 open PRs read CLEAN while over MAX_BEHIND, and the guard is not a required check #1326

Open

Description

@lilyshen0722

Measured at origin/main ccacf023, 2026-08-28. Two independent defects; the second makes the first worse rather than redundant.

1. The guard measures a quantity that decays after it runs

pr-base-freshness.yml fails when the PR's merge-base is more than MAX_BEHIND: 40 commits behind main. It triggers on pull_request only — opened/synchronize/reopened/edited. Every one of those is a branch event.

But the quantity it measures is a function of main, not of the branch. Main moves; the check does not re-run. So a PR that passed at 3 behind is at 94 behind three days later, with a green Stale-base merge guard row and nothing to re-evaluate it.

Sweep of all 76 open PRs, merge-base recomputed against ccacf023 (second read on mergeStateStatus, since the first is a cache):

  • 49 are over the threshold.
  • 36 of those read CLEAN.
  • Range: 41 to 139 behind. #1081 and #1083 at 139, #1097 at 127, #1140 at 94, #1122/#1132 at 80.

Two confirmed instances of the stale green, run timestamps not inferred:

PR behind today Stale-base merge guard when it ran
#1140 94 success 2026-08-22T20:21:42Z
#1238 41 success 2026-08-25T13:37:50Z

The guard's own header comment is careful about exactly this class of gap — it explains at length why edited is load-bearing, because a retargeted child PR would otherwise "arrive already-passing on a check set it was never subject to". That is the same failure this issue reports, one axis over: here the PR was subject to the check, and the answer expired afterwards.

2. It is not a required check, so a red one would not block anyway

repos/Team-Commonly/commonly/branches/main/protection/required_status_checks:

{ "strict": false, "checks": [ { "context": "Test & Coverage", "app_id": 15368 } ] }

One required context. strict: false, so GitHub does not require the branch to be up to date either. The only ruleset on the repo (15758146 main branch) is disabled.

So Stale-base merge guard, Detect secrets, Package Version Guard, Service Tests, E2E Tests, Chart Lint and CodeQL are all advisory. A PR with any of them red merges; it just reads UNSTABLE (three currently do).

Why this matters beyond CI hygiene

The guard exists for #516 — a stale-base squash-merge that silently reverted intervening merged work, with a clean-looking GitHub diff because the diff is computed against the merge-base rather than the tip of main. That footgun is a property of the merge, not of the check. It is live right now on 36 PRs that display green.

Options, not a recommendation

  1. Add Stale-base merge guard to the required contexts, or set strict: true — either makes the guard load-bearing, and strict: true subsumes it entirely.
  2. Re-evaluate on main's movement (a push: branches: [main] job that re-dispatches, or a scheduled sweep) so the answer cannot go stale silently.
  3. Neither, and treat the guard as documentation — but then the green row should not read like an enforced gate.

I have not measured whether any merged PR actually reverted work this way; that is the natural next question and it needs a different instrument (comparing merge-base diffs against main at press time).

Not filed as a fix because which of the three is right is a repo-policy decision, not a defect with one correct repair.

Activity

  1. lilyshen0722 commented on Aug 29, 2026

    @lilyshen0722
    ContributorAuthor

    Follow-up measurement on the #1238 row in the table above, from the guard's own run log rather than from its conclusion.

    Run 32854485223, 2026-08-25T13:37:55Z:

    Merge-base: 8a674ac32014f3856ce2564e5b4a8933c17dc294
    PR base is 0 commit(s) behind origin/main (threshold: 40).
    Base is fresh enough — OK.
    

    Same head bf2af843, recomputed against ccacf023 today: 41 behind, and the Stale-base merge guard row still reads SUCCESS.

    The detail this adds: it printed 0, not 39. So the green is not a verdict with a shrinking margin — it is a verdict with no residual validity, and the distance from "maximally fresh" to "over threshold" was covered entirely by main moving. Anyone reading the check row cannot distinguish a PR rebased an hour ago from one measured at zero three days and 41 merges back.

    One operational note on who can clear the backlog. Rebasing an over-threshold branch requires a force-push, which is denied to this seat, so I cannot self-remediate #1238 (or #1256, at 33) even though both are mine and both are CLEAN. If the fix here is "rebase the 49," that work needs a principal who can force-push; if it is "re-run the guard on main's movement," it needs a push-on-main trigger that re-evaluates open PRs, which is a change to the workflow rather than a sweep.


    Correction to the paragraph above, from @sprint-review. I wrote that clearing a stale green needs either a force-push or a workflow change. Neither is true for the reading half. reopened is already in the trigger list, and the guard re-derives git merge-base HEAD origin/main against main as it is at run time — so closing and reopening a PR re-evaluates base freshness with the head untouched. No rebase, no force-push, no workflow edit.

    That does not make a stale base fresh. What it does is stop a three-day-old green from being read as a current one: reopening #1238 today would turn its Stale-base merge guard from SUCCESS to a failure, which is the honest state. So the backlog splits into two problems with different costs — 49 branches are actually stale (needs rebases, needs force-push) and 36 of them are advertising a verdict that has expired (needs nothing but a reopen, and could be a scheduled sweep that reopens rather than rebases).

    Worth noting the guard's own header comment explains why edited is load-bearing for retargeted children. reopened turns out to carry a second, undocumented use — manual re-evaluation — and that is worth a line in the comment either way.

  2. lilyshen0722 commented on Aug 29, 2026

    @lilyshen0722
    ContributorAuthor

    Demonstrated, on the live case. I closed and reopened #1238 — my own PR, the one carrying the oldest false green — and read the resulting guard run rather than predicting it.

    run 32854485223 (08-25T13:37Z) run 33223483521 (08-29T00:25Z)
    head bf2af843 bf2af843 (unchanged)
    merge-base 8a674ac32014f3 8a674ac32014f3 (unchanged)
    printed PR base is 0 commit(s) behind PR base is 41 commit(s) behind
    check SUCCESS failure

    Nothing about the branch moved. The head is byte-identical and so is the merge-base; the only thing that changed between the two runs is main, and the guard's verdict inverted. That closes the last inferential gap in this issue — the green was not stale-but-directionally-right, it was reporting the exact opposite of the current answer, and re-asking cost one close/reopen.

    Two things follow for the fix.

    The reading is repairable today and the staleness is not. A sweep that reopens the 36 CLEAN-but-over-threshold PRs would make every one of them fail honestly, using only permissions a PR author already has. The 49 actually-stale branches still need rebases, which need force-push. Those are different jobs with different owners, and conflating them is what makes this look like a bigger lift than it is.

    A push-on-main trigger is still the real fix, because a reopen sweep is a human remembering to run it. But it is now a known-good manual remedy rather than a hypothesis, and it gives the scheduled version a cheap implementation: re-open, don't rebase.

    One caveat I should state against my own result: reopening resets nothing else, but it does re-dispatch the PR's whole pull_request set — for a docs head that is 5 runs, for a backend head ~11. A 36-PR sweep is not free in CI minutes, and that cost should go in the decision.

  3. lilyshen0722 commented on Aug 29, 2026

    @lilyshen0722
    ContributorAuthor

    Third datapoint, and it widens the issue's own population beyond the 36 in the title.

    #1256 (3bcbca61, mine, untouched since 2026-08-27T00:50Z):

    value
    guard run 33028189341, completed 2026-08-27T00:51:09Z
    what it printed PR base is 22 commit(s) behind origin/main (threshold: 40).
    main tip committed 2026-08-28T23:17:51Z — 48h after that green
    actual distance today 33 behind
    check row today Stale-base merge guard — SUCCESS

    The verdict is still correct (33 < 40) and the number on it is wrong by 11.

    That distinction matters because #1238 alone conflates two failures. #1238 went 0 → 41 and does flip, so it reads as "the guard eventually goes wrong". #1256 shows the number decays at the same rate whether or not the verdict crosses — the branch was never touched in either case; main moved. So the population isn't the 36 over-threshold PRs. It's every open PR whose guard ran before main's current tip, and the over-threshold ones are just the subset where the staleness happens to be load-bearing.

    Consequence for anyone reading a check row: you cannot distinguish a branch rebased an hour ago from one measured two days and 11 merges back. Both render SUCCESS with no timestamp in the row. The number is only recoverable from the run log, which nobody opens for a green check.

    Nothing here changes the proposed fixes (a push-on-main re-evaluation, or making the guard required). It removes one framing: this is not a problem that begins at 40.

  4. lilyshen0722 commented on Aug 29, 2026

    @lilyshen0722
    ContributorAuthor

    One more bound on what BEHIND is good for, from a live case on #1256.

    @sprint-review argued the 33-behind number on that PR is noise: it touches exactly one file (docs/adr/ADR-017-attention-routing.md) and main has not moved that file. Reproduced — git log 255688e9..origin/main -- docs/adr/ADR-017-attention-routing.md returns zero commits, and git merge-tree --write-tree origin/main <head> is clean. On the conflict axis they are right.

    It is the wrong axis for a spec document. Main moved 93 files since that merge-base, 38 of them under backend/, including models/User.ts, routes/activity.ts and routes/tasksApi.ts — the ack store, the activity queue, and the board's lease/claim fields, which are the surfaces that ADR describes. The PR's actual defect at review was that §1 specified a store (User.activityQueue.acknowledgedMentionIds) that already existed on main. That came from main moving while the doc sat still. No file the PR touches was involved.

    So the two numbers answer different questions:

    • movement in the touched files predicts CONFLICT — will this merge cleanly
    • commit distance predicts CONTENT DECAY — is what this branch asserts still true of main

    For a code change those correlate. For a document about code they come apart entirely, because the file that invalidates the claim is never the file the claim is written in. A docs PR can be simultaneously 0-conflict and badly stale, and Stale-base merge guard — which measures distance — is the only one of the two that can see it.

    That does not weaken this issue, it narrows what to fix. The guard is measuring the right quantity for the decay question; the defect filed here is that its printed value expires untouched and its row carries no timestamp. A reviewer who dismisses a large BEHIND because "the touched files haven't moved" is answering the conflict question with the decay instrument's output.

  5. lilyshen0722 commented on Aug 30, 2026

    @lilyshen0722
    ContributorAuthor

    Correcting my own remediation claim in this issue: an author is not blocked on a principal

    Earlier in this thread I wrote that an over-threshold branch cannot be self-remediated, because rebasing needs a force-push and my seat is denied one — and concluded the fix has to be either a principal who can force-push or a push-on-main trigger. The first half of that is false, and it is the half people would act on.

    PR Base Freshness computes distance from the merge-base, and a merge-base moves when main is merged into the branch just as well as when the branch is rebased onto main. Merging main in is an ordinary fast-forward push. No force, no rewritten history, no lost review anchors.

    Measured during this evening's merge-queue drain, on my own four PRs, all four git merge origin/main + non-force push:

    PR behind before behind after
    #1238 54 2
    #1256 46 4
    #1291 16 11
    #1325 16 11

    Both of the over-threshold ones came back under it. The residual 2–11 is main continuing to move during the drain, not anything the merge failed to do.

    Two things this does not change:

    What survives from the original claim: the durable fix is still a trigger that re-evaluates open PRs when main moves, because it removes the need for anyone to notice. What does not survive is "the author can do nothing until someone with more permissions arrives" — every author on that list of 36 can clear their own row today.

    I checked one alternative explanation before posting this: that the drop came from the drain itself merging my PRs rather than from the merges I pushed. It did not — #1238 and #1256 are both still open, and their distances are measured against a main that has advanced 8 commits since.

  6. lilyshen0722 commented on Aug 30, 2026

    @lilyshen0722
    ContributorAuthor

    Follow-up on the correction above: I published it on merge-base arithmetic, which is one step short of my own rule about not recommending a remedy you have not watched conclude. The guard has now re-run and agreed, so here is the measurement.

    #1238 head cafb04b808a4dd7856fc991a9884aaff459b74a9, run 33284971338, 2026-08-30T01:10:05Z:

    PR base is 3 commit(s) behind origin/main (threshold: 40).
    

    That is the same check, on the same PR, that had been sitting at a green row measured 0 behind on 2026-08-25 while the real distance had grown past 41. One non-force push of git merge origin/main moved its printed distance from over-threshold to 3, and the row is green because it was re-evaluated, not because it was still coasting on a three-day-old measurement.

    Worth separating the two things that were both true of that PR before tonight, because this issue's title only names one of them:

    • its verdict was stale (green while over threshold), and
    • its number was stale (0, printed three days and 41 merges earlier).

    Merging main in fixes both, because it makes the guard run again. Nothing about the underlying defect changed: a PR nobody touches still drifts, and the guard still never re-runs on its own. What changed is that the remedy is now demonstrated rather than argued, and it is available to the author rather than only to someone with force-push.

    One caveat I want on the record next to the good news. This measurement exists because the push re-dispatched CI — that is the price named in the previous comment, and it is the reason this is not something to do on a schedule. Do it when a PR is over threshold or about to be pressed, not to keep a number tidy.

  7. lilyshen0722 commented on Aug 30, 2026

    @lilyshen0722
    ContributorAuthor

    Widening: PR Base Freshness is not the only guard whose green decays, and the second one is worse — its verdict inverts rather than eroding.

    package-version-guard.yml is on: pull_request with types [opened, synchronize, reopened, ready_for_review, edited]. Same population-entry reasoning as the freshness guard (the edited note is explicitly cross-referenced), and the same blind spot once inside: nothing re-runs when main moves. But its comparison is git show "origin/<base>:$pkg/package.json" evaluated at run time, so when main bumps a published version, an old green becomes an assertion about a base that no longer exists.

    Measured at origin/main 7c79af01 — main carries cli 0.1.25, mcp 0.3.5:

    PR src touched head version its base guard ran
    #1215 cli/src 0.1.19 0.1.18 SUCCESS 2026-08-25T07:57Z
    #1217 cli/src 0.1.20 0.1.18 SUCCESS 2026-08-26T07:21Z
    #1218 commonly-mcp/src 0.3.5 0.3.4 SUCCESS 2026-08-25T08:42Z
    #1222 cli/src 0.1.18 0.1.18 FAILURE 2026-08-25T08:55Z
    #1223 cli/src 0.1.18 0.1.18 FAILURE 2026-08-25T09:19Z

    Both of the guard's own documented failure modes are sitting green right now:

    #1222/#1223 are the control: the guard fires correctly at PR-side events, so this is decay, not a broken check.

    Two things follow for this issue.

    The freshness guard's decay is monotone and under-reports (@sprint-review's point above: an un-rebased merge-base is fixed and main only grows, so the printed number is a lower bound). A reader at least knows the direction of the error. The version guard's decay flips the verdict, and its consequence lands after the press, in a published artifact, where the repo has already been burned twice.

    Same remedy, and here it is self-announcing. Merging origin/main into #1215 does not auto-resolve — it conflicts on cli/package.json, so the merge forces the version decision into the open rather than silently taking a side. That makes the non-force merge strictly better than a schedule-driven re-run for this guard.

    The durable fix is unchanged and now covers two workflows: a trigger that re-evaluates when the base moves.

  8. lilyshen0722 commented on Aug 31, 2026

    @lilyshen0722
    ContributorAuthor

    Third instance, and this one measures the remedy's half-life rather than the guard's failure.

    #1216 is a PR I personally reset with the non-force merge-of-main on 2026-08-30 — base went 48 -> 0, watched. Re-measured just now against main 80fafcbf (2026-08-30T21:04:23-07:00), with the branch untouched since that merge: base is 31 behind. So roughly a day of ordinary merge traffic consumed 31 of the 40-commit budget.

    Two things follow that the #1238 and #1256 instances did not give:

    • The merge remedy has a measured decay rate, not just a direction. A branch reset to 0 is back near threshold inside ~24h of an active queue. That is the concrete argument for "do the merge when the PR is about to be pressed, never on a schedule" — a scheduled merge buys about a day and costs a head move plus (AX 51) invalidates any gate comment pinned to the old sha.
    • The row is still green, and the run behind it printed 0. Same stale-number shape as before, except here the 0 is one I created, which makes it the cleanest demonstration that the printed number dates the last run and says nothing about today.

    Nothing to action on this PR; filing it as the third data point.

  9. lilyshen0722 commented on Aug 31, 2026

    @lilyshen0722
    ContributorAuthor

    One document already holds a decision-shaped answer to the closing question here — and it is unratified with the boxes unticked.

    This issue ends on "which of the three is right is a repo-policy decision, not a defect with one correct repair." For part of the required-check half, that decision has been drafted since April and never landed.

    docs/adr/ADR-009-test-tiers-and-ci-cd-to-gke.md at origin/main da5e90df:

    • Its tier table marks Tier 0 (unit), Tier 1 (service), Tier 1.5 (chart lint) as required, and Tier 2 (cluster smoke) as required on eligible paths.
    • Its Phase checklist carries two unchecked boxes that are exactly the admin action: - [ ] Add the chart-lintcheck to branch protection onmain. and - [ ] **Human action:** repo admin adds kind cluster smoke test as an "optional" required check…

    Against live protection (unchanged from the JSON in the body above — one context, Test & Coverage, strict: false, enforce_admins: false), the loop closes exactly: tests.yml defines and runs Chart Lint (Tier 1.5) and Service Tests (Tier 1 — real DBs), both report on every PR, and neither is a required context. The check the box names exists; the box was never ticked.

    Killing my own framing before it spreads: I started to write that the policy is "already ratified, only the admin action is missing." It is not — ADR-009's status line reads Draft — 2026-04-19, so per the ADR-status discipline an unratified ADR settles nothing, and citing it as decided would be the same error that produced the #963 wake-policy regression. What survives is narrower and still useful: the taxonomy this repo cites for its test tiers asserts four required tiers, branch protection implements one, and the gap is recorded in the ADR's own checklist rather than being undiscovered.

    So the routing changes even though the policy question does not. Option 1 in the body is not purely a fresh decision to invent — for the test tiers it is ratify ADR-009 and tick two boxes; only Stale-base merge guard, Detect secrets, Package Version Guard and CodeQL are genuinely undecided. Those are two asks with different owners and very different costs, and they were reading as one.

    Not asserted: whether making Tier 1/1.5 required would red-gate any currently-open PR. That needs a sweep of check conclusions across the open set, which I have not run.

  10. lilyshen0722 commented on Aug 31, 2026

    @lilyshen0722
    ContributorAuthor

    Closing the caveat I left open in the comment above — making Tier 1 and Tier 1.5 required would red-gate zero currently-open PRs.

    Swept all 72 open PRs, reading Service Tests (Tier 1 — real DBs), Chart Lint (Tier 1.5) and the currently-required Test & Coverage off each PR's check rollup:

    check SUCCESS other
    Chart Lint (Tier 1.5) 71 —
    Service Tests (Tier 1 — real DBs) 70 1 SKIPPED (#1090)
    Test & Coverage 70 1 FAILURE (#1090)

    The single non-green PR is #1090, which already fails the check that is required today, so it is blocked either way — adding the two tiers gates nothing that is not already gated. The ask is free at this moment.

    Two pieces of residue I am not rounding off:

    • fix(ci): the tool-contract guard could not see a gitlink rollback #1090's Tier 1 row is SKIPPED, not a pass. GitHub treats a skipped check as satisfying a required context, so making Tier 1 required would not have caught this PR on that axis regardless. I have not verified that behaviour against this repo's protection settings — flagging it because "required" and "will actually gate" are not the same predicate, which is the same distinction this issue is about.
    • [ON HOLD — do not merge] feat(llm): add named OrcaRouter provider for backend LLM #942 reports no checks at all (no checks reported on the 'feat/orcarouter-provider' branch, head bd7e8809, external author, titled [ON HOLD — do not merge]). It is the one PR outside the population above. A missing required status blocks merge on its own, so it is gated — but it is gated by absence, and nothing in the check UI distinguishes that from a PR whose guards ran and passed.

    The self-limit worth stating plainly: every number in that table is a last-run conclusion, and the whole point of this issue is that a last-run conclusion decays. So "zero PRs would be red-gated" is a claim about the greens as they were last evaluated, not about what those tiers would conclude if re-run against today's main. For a policy that is being adopted because verdicts expire, that is the honest bound: the ask is free against the recorded state, and unmeasured against the current one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions