Skip to content

ci: bound and retry every apt-get on a CI critical path - #437

Merged
wshallwshall merged 4 commits into
mainfrom
lander/apt-guard
Aug 19, 2026
Merged

ci: bound and retry every apt-get on a CI critical path#437
wshallwshall merged 4 commits into
mainfrom
lander/apt-guard

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

An unguarded apt-get sits on the critical path of every ubuntu leg, with no retry and no step
timeout. Tonight it converted an external mirror hiccup into a blocked merge queue: it killed the
same PR twice.

Measured on #427, three hangs across two attempts, on three different jobs, all on the same step:

attempt job outcome
1 diff-coverage (advisory) hung 20m02s, cancelled
1 web console tests (ubuntu-latest) hung, cancelled
2 web console tests (ubuntu-latest) passed
2 test (ubuntu-latest, py3.14) hung, cancelled at 01:47:17

That last one is a required context, so the queue could not drain.

The priority is the opposite of what the symptom suggested

All five Qt-libs sites sit in jobs with a job-level timeout (ubuntu 43, windows 66, webconsole
20), so they die at a cap. The three ODBC sites -- sqlserver-store, load-test-sqlserver,
benchmark.yml's baseline-sqlserver -- have no job-level timeout at all, so the same hiccup
there runs to the platform default.

Visibility and severity were anti-correlated, structurally: the bound is what makes a site loud,
so triaging by "what is blocking me" surfaces the survivable sites first. A fix scoped to the step
that was visibly hurting the queue would have hardened the safe half and left the dangerous half
alone.

What this changes

Two levers, because they answer different failures. A per-command timeout kills a hang so the retry
can run at all; a step-level cap is the backstop so the step can never eat a job budget again. After
three attempts it prints an annotation saying the cause is the runner mirror and not the change
under test
-- which had to be broadcast by hand tonight because nothing in the failure said so.

Completeness, same instrument before and after: 8 apt steps across three workflow files, 8 now
carrying both a step cap and a retry, 0 unguarded.

Provenance and verification limits

Both commits are cherry-picked unchanged from claude/builder-2bee12, which authored them; they
are split out here because that branch also carries ledger commits that are not ready. Content
equivalence verified by git patch-id --stable: identical on both.

Verified in this worktree: all three files parse as YAML (12 / 5 / 3 jobs), and the completeness
walk above.

NOT verified here, stated rather than implied: pytest, actionlint and the shell-syntax gate
cannot run in this worktree -- pydantic is absent from its venv and actionlint is not on PATH.
The authoring session reports them green on identical content. CI on this PR is the authority.

NOT established by anyone: no hang has been reproduced on an ODBC leg, so that half is a claim
about the configuration, not an observed outage. The retry itself is unproven until a leg fires --
this PR's own CI run is its first real test.

Filed as BACKLOG #1286 by a third session, with three-way attribution.

wshallwshall and others added 3 commits August 17, 2026 20:49
…ocked queue

NO BACKLOG NUMBER IS CITED, DELIBERATELY. None has been allocated for this, and citing a
number I have not allocated is the trap that arms itself the day someone legitimately issues
it -- the citation would begin resolving, to unrelated work, with nothing reporting a problem.
Naming the subject costs nothing and cannot arm. The Lander found this and explicitly left it
unfiled; whoever files it should allocate then.

WHAT IT FIXES, measured by the Lander on PR #427 on 2026-08-18 and relayed as content: the
Qt system-library install runs `sudo apt-get update && sudo apt-get install -y ...` with NO
retry and NO timeout on every ubuntu leg. THREE HANGS ACROSS TWO ATTEMPTS ON THREE DIFFERENT
JOBS, the worst 27+ minutes and still hanging when reported. One of those jobs is
`test (ubuntu-latest, py3.14)`, a REQUIRED context -- so an external apt mirror stalling
blocks the merge queue outright.

I MEASURED THE SURFACE MYSELF RATHER THAN TAKING THE COUNT: 8 unguarded apt steps across
three workflow files, not the 3+1 reported. Five are this Qt step (ci.yml x3,
quality-advisory.yml x2) and are guarded here.

DELIBERATELY NOT TOUCHED, and this is scope rather than oversight: the three ODBC steps
(ci.yml x2, benchmark.yml x1). They are a DIFFERENT SHAPE -- a larger block that also curls
Microsoft's signing key and repo config before apt -- so the retry above is not
transplantable without thinking about what re-running those curls means. They also sit on
path-gated server-DB legs rather than on a required context, so they are not the measured
blocker. They remain unguarded and someone should price them.

TWO LEVERS, BECAUSE THEY ANSWER DIFFERENT FAILURES:
  per-command `timeout 120/180` kills a HANG so the retry can run at all
  step `timeout-minutes: 8`   backstop -- this step can never eat a job budget again, even
                              if the loop is later edited wrong
A retry alone would still hang forever on attempt 1; a timeout alone would fail the leg on a
blip that a second attempt clears. The Lander measured one job passing on attempt 2, which is
what makes the retry worth having and also why it is not called a fix for the mirror.

THE FAILURE TEXT NAMES THE CAUSE. After three attempts it prints, as a GitHub error
annotation, that this is the ubuntu runner mirror and NOT the change under test. That is the
same defect class as the margin gate: a check whose label points a reader at the wrong subject
costs diagnostic time on every future occurrence, and the Lander had to broadcast "do not go
hunting in your change" precisely because nothing in the failure said so.

WHAT THIS DOES NOT CLAIM: any root cause beyond the step. The Lander did not establish one,
did not measure other repos or other times of day, and neither did I. This bounds the blast
radius of an external failure; it does not diagnose it.

Verification, with its scope: both workflow files still parse as YAML (12 and 5 jobs);
tests/test_workflow_shell_syntax.py 3 passed, which is the harness that runs every `run:`
block through `bash -n` -- the new loop is real shell and is checked as such;
tests/test_ci_step_margin.py + test_required_contexts.py + test_ci_docs_only_detector.py +
test_ci_engine_step_excludes_webconsole.py, 68 passed, so the job-cap nesting invariant and
the pinned required contexts are unchanged. actionlint runs in pre-commit over these files.
Not run here: the full suite. NOT VERIFIED ON A RUNNER -- no local run can exercise a GitHub
apt mirror, so the retry's real behaviour is unproven until a leg fires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… all

THE PRIORITY INVERTED AFTER MY PREVIOUS COMMIT, AND THAT COMMIT GUARDED THE SAFE HALF.
a177702 bounded the five Qt sites because those were the ones visibly blocking the queue.
Re-measured here, independently, after the Lander re-measured it:

  ci.yml  test / webconsole / tooling      job timeout-minutes present   -> a hang dies at the cap
  quality-advisory  coverage / mutation    job timeout-minutes present   -> same
  ci.yml  sqlserver-store                  NO job-level timeout          -> GitHub default, 360 min
  ci.yml  load-test-sqlserver              NO job-level timeout          -> 360 min
  benchmark.yml  baseline-sqlserver        NO job-level timeout          -> 360 min

ALL FIVE SITES I FIXED FIRST SIT IN BOUNDED JOBS. ALL THREE I DEFERRED SIT IN UNBOUNDED ONES.
So the sites that hurt were the ones that could not hurt much, and the ones nobody was watching
are where the same mirror hiccup burns a six-hour runner budget -- on path-gated legs where a
stalled job is least likely to be noticed.

THE GENERAL SHAPE, worth more than either commit: VISIBILITY AND SEVERITY WERE ANTICORRELATED
HERE. The queue-blocking symptom pointed at the bounded half precisely BECAUSE it was bounded --
it failed fast enough to be seen. Scoping a fix to "the one that is hurting us" would have left
the worse half in place and looked finished.

WHAT THIS ADDS to those three steps:
  timeout-minutes: 10   bounds EVERY command in the step, including the two curls, which are
                        equally unguarded network calls in a job with no cap of its own
  --max-time 60         on both curls, so the bound is not solely the step cap
  a 3-attempt retry     around the apt pair ONLY. Re-running apt is idempotent; re-fetching the
                        signing key and the repo list is not the part that hangs, so wrapping
                        those in a retry would add churn without adding resilience.

THE DEEPER FIX IS A JOB-LEVEL TIMEOUT ON THOSE THREE JOBS and it is deliberately NOT here.
That would bound every step rather than the one that bit -- correct altitude -- but choosing the
number needs a measurement of how long each job legitimately runs, and a guessed cap on a
server-DB or benchmark leg kills real work. The step comments say so at each site rather than
leaving the next reader to wonder whether the omission was considered.

COMPLETENESS, re-derived rather than asserted: a walk over all three workflow files now reports
8 apt steps, 8 with both a step cap and a retry, 0 unguarded. That is the same instrument that
found the original 8, so the before and after are comparable.

Verification, with its scope: all three workflow files parse as YAML (12, 5 and 3 jobs);
tests/test_workflow_shell_syntax.py + test_ci_step_margin.py + test_required_contexts.py,
43 passed, so every new `run:` block is real shell under `bash -n` and the pinned required
contexts and job-cap nesting invariant are unchanged; actionlint green in pre-commit.
NOT VERIFIED ON A RUNNER -- no local run can exercise a GitHub apt mirror or a 360-minute hang.

No BACKLOG number cited: none is allocated, and citing an unallocated number arms a reference
that begins resolving to unrelated work the day someone issues it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Do not merge without rebasing first: this would silently DELETE content from main

Measured with git merge-tree --write-tree origin/main <head>, then diffing the resulting tree against main -- i.e. what the merge would actually produce, not what the branch contains.

168 deletions across 3 files, all CI wiring:

  • .github/workflows/ci.yml (-120) -- the file that defines the required contexts
  • .github/workflows/benchmark.yml, .github/workflows/quality-advisory.yml

Why nothing would warn you

This merges CLEAN. Git conflicts on concurrent edits, never on invalidated claims -- a stale branch silently drops work it never touched, no marker appears, and every check stays green. The deletions above are main's content that this branch predates.

The remedy

Rebase onto origin/main, then re-review. The rebase is where you decide, line by line, which of these deletions were intended and which are the base being old. Do not clear the BEHIND state with update-branch and merge on green -- green does not see this.

Found during a sweep of all open PRs; five of twelve show this shape. Not a judgement on the work, only on the base.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Correction to my previous comment: I quoted the wrong side of the stat

My earlier comment said 168 deletions. That number is what this branch ADDS, not what merging would lose. I read git diff --stat in the wrong direction.

The corrected figure: merging would lose 22 line(s) that main currently has.

git diff --shortstat origin/main <merge-tree>
  -> 168 insertions (this branch's work, correctly added)
  -> 22 deletions  (main's content, silently dropped)

The finding itself stands and so does the remedy -- rebase before merging, do not clear BEHIND and merge on green -- because a clean merge still drops those 22 lines with no marker. But the magnitude was overstated and the number in my first comment should not be quoted.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

RETRACTION: my "would silently delete content" comments on this PR were wrong

Both of my earlier comments are withdrawn. The finding does not survive a discriminator, and the remedy I attached to it was justified by a defect that is not there.

What I did wrong

I counted removed lines in a merge result and reported them as lost content. Those are different questions. A line-for-line REPLACEMENT scores identically to a DELETION under a line count -- and this branch's changes are overwhelmingly rewrites.

The discriminator, run on this PR

For every line main has that the merge result lacks, I extracted a distinctive identifier and asked whether it survives anywhere in the merged tree:

removed lines carrying an identifier : 11
payload SURVIVES in the merged tree  : 11
genuinely absent                     :  0

Zero -- and this PR is where the error was caught. The apt-get install lines I scored as removed appear in the merged tree wrapped in timeout and a conditional, which is this branch's subject.

The concrete case that broke my own claim

I said this branch deletes initial_password_expiry_hours (ASVS 6.4.1). Measured occurrence counts:

initial_password_expiry_hours   main: 10   after merge: 25
bootstrap_expiry_hours          main: 35   after merge: 65
password_claimed_at             main:  0   after merge: 52

Not deleted -- expanded, plus a new mechanism (ADR 0164, password_claimed_at: recorded rather than inferred from mutable credential state).

What still stands, and what does not

Does not stand: any claim that merging this drops content, and the "never update-branch-and-merge-on-green" remedy insofar as it rested on that.

Still true, but unremarkable: this branch is BEHIND, and branch protection is strict: true, so it must re-sync before merging regardless.

Credit where due: a peer session caught this by verifying one file in #437 -- apt-get install lines I scored as removed were present in the merged tree wrapped in timeout and a conditional, which is that branch's entire subject. Same shape as errors I flagged in others today: the instrument answered truthfully, about the neighbouring question.

@wshallwshall
wshallwshall merged commit c6d402f into main Aug 19, 2026
58 of 62 checks passed
@wshallwshall
wshallwshall deleted the lander/apt-guard branch August 19, 2026 15:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant