fix(actions): Lopu repairs failed PR checks - #430
Conversation
Lopu review — the diagnosis holds up; one defect in the new step, fixed in placeI confirmed the root cause independently, then found one real problem in the fix itself and two convention gaps. Changes are in the worktree and will land on this branch. The diagnosis is rightThe code-scanning API for
GHAS opened PR #427's check at Two other things I checked and can confirm safe:
🐛 The new step was fail-closed on a transient API error
Reproduced by extracting the step's own
Fixed with the idiom this same file already uses twice for optional lookups (lines 118–123, 139–148) — 🔒 Added a regression lock
Each one is proven non-vacuous. Applied to a throwaway copy of
📝 Added the missing CHANGELOG entry
|
Lopu repository reviewLopu reviewed this PR against github-actions as Thingtime's principal PR and repository manager. Using Claude Opus 5. Lopu made justified improvements and pushed 9047a3c to lopu/workflow-check-fix-33045330366. Lopu review — PR #430 ·
|
| uploaded | category |
|---|---|
| 06:17:21Z | /language:actions |
| 06:22:09Z | /language:javascript-typescript |
GHAS opened PR #427's check at 06:17:20Z and closed it timed_out at 06:17:36Z
— i.e. while only one of the two language configurations existed at that
head. That is the same phenomenon as this repo's recurring
"1 configuration not found" refresh commits (13f7f5fb, 481cbcfe), so the
fix targets the real mechanism rather than a symptom.
2. Permission addition is safe
pull-requests: read on analyze is within every caller's grant — both the
main and develop listeners grant it at workflow level, and alpha/beta
have no listener at all. The scope job already required the same scope, so
this adds no new ceiling to the reusable workflow.
3. Event gating is correct
Parsed from the YAML: the new step is id: ownership,
if: github.event_name == 'push' && needs.scope.outputs.analysis_ref == '', and
Analyze the triggering revision is gated on
… && steps.ownership.outputs.upload != 'false'. A skipped step yields an empty
output, and '' != 'false', so pull_request, schedule, and the centrally
dispatched merge-ref path keep their existing semantics unchanged.
4. Defect fixed — the new step was fail-closed on a transient API error
set -euo pipefail with an unguarded gh pr list means one rate-limited or
transient response aborts the step, which fails the analyze job. That is
strictly worse than either outcome the step chooses between: it produces exactly
the red CodeQL check this PR exists to eliminate, and discards a CodeQL
database that was already built. It runs on every push to every branch
(push: branches: ["**"] on the product listener), so the exposure is broad.
Reproduced by running the step's own run: body extracted from the YAML with a
failing gh on PATH:
| version | exit | $GITHUB_OUTPUT |
|---|---|---|
as pushed (6629d577) |
1 — step fails the job | (empty) |
| after this review | 0 | upload=true |
Fixed using the idiom this same file already uses twice for optional lookups
(lines 118–123, 139–148) — if candidate="$(gh …)"; then … else … fi, which
preserves gh's exit status rather than || true (the contract explicitly
forbids || true here). The fallback is the pre-change behaviour: analyze, and
emit a ::warning:: so the blip is diagnosable.
5. Added — regression lock in the control-plane contract
workflow-control-plane-contract.mjs asserts essentially every CodeQL
control-plane invariant, including previously fixed regressions of exactly this
shape (the merge-ref 404-body one, the || true one). The new ownership
re-check had none, so a later edit could silently drop it and reintroduce the
timed_out bug. Added five assertions covering: step ordering (after init,
before upload — the deliberate design point that widens the absorbed window),
the step's if, the analyze step's gate, the fail-open fallback, and the exact
analyze permission block.
6. Added — missing CHANGELOG entry
CHANGELOG.md states outright that control-plane CI changes are recorded there
because they never reach the product changelog. The sibling Lopu fix #432 added
one; this PR had none. Appended an ### Fixed entry. Placed at the end of the
section, matching 47b2fb20 ("avoid parallel changelog conflict") — verified
conflict-free: base only touched the top of ### Changed.
7. Residual, documented rather than engineered around
The re-check is per-matrix-job. If a PR is created in the narrow gap between the
actions job's re-check and the javascript-typescript job's re-check, one
branch-ref analysis can still land at the PR head and the symptom can recur.
That window is bounded by the skew between the two jobs' init durations
(tens of seconds) instead of the whole run, and it is never worse than the
current behaviour, so closing it would need cross-matrix coordination that is
not justified by the observed failure rate. Called out on the PR, not changed.
Changes made
| file | change |
|---|---|
.github/workflows/codeql-analysis.yml |
ownership lookup made fail-open, falling back to the prior analyze behaviour with a ::warning:: |
.github/scripts/workflow-control-plane-contract.mjs |
five assertions locking the new invariant |
CHANGELOG.md |
### Fixed entry for the control-plane behaviour change |
+58 / −6 across three files. No change to the PR's core approach, which is sound.
Validation
Run in the PR worktree:
- Full
control-plane-ci.ymlverify lane:node --checkover every
.github/scripts/*.mjs,bash -nover every.github/scripts/*.sh,
git diff --check— all clean. node .github/scripts/workflow-control-plane-contract.mjs --self-test→ OK.
Also green:build-all-branch,classify-claude-credential-failure,
electron-pr-release-contract,resolve-pr-conflicts-routing-contract.- Negative tests proving the new assertions are not vacuous — each mutation
applied to a throwaway copy of.github, each failing with its own message:
drop the ownership step → ordering assertion fails; move it before
Initialize CodeQL→ ordering assertion fails; remove theupload != 'false'
gate → gate assertion fails; removepull-requests: read→ permission
assertion fails; restore the fail-closed query → fallback assertion fails.
Unmodified worktree → self-test OK. - YAML parsed with
yaml.safe_load;analyzestep order, ids and everyif
expression read back programmatically and confirmed. bash -non the step body extracted from the YAML → OK.- Step body executed against live repository data:
claude/custom-audience-groups@ its live open-PR head →upload=false
(cedes, emits the notice); same branch at a superseded SHA →upload=true;
github-actions@HEAD, no open PR →upload=true. - Failing-
ghsimulation, table in finding 4. git merge-tree --write-treeagainst the current base tip0c6a2189→
clean merge, no conflicts.
Disposition
Approve the approach. Changes are left uncommitted in the worktree for the
trusted publisher to push to lopu/workflow-check-fix-33045330366. Because this
PR is the dedicated controller-fix PR already targeting github-actions, the
repair belongs on its own head branch; $GITHUB_WORKSPACE/trusted was not
touched and no lopu-workflow-fix.md was written.
Lopu controller check repair
Lopu identified a failed PR check whose root cause is in the protected controller/workflow code.
Lopu control-plane fix — CodeQL PR check times out when a push wins the race against PR creation
Symptom
On PR #427 (
codex/search-commander-overhaul, heade5bcc813) the GitHubAdvanced Security
CodeQLcheck run reported:conclusion: timed_outoutput.title: "No new alerts in code changed by this pull request"06:17:20Z→06:17:36Z)Both
Analyze (actions)andAnalyze (javascript-typescript)jobs succeeded inevery workflow run, and the merge-ref analyses uploaded cleanly. Nothing in the
product diff is implicated:
mergeStateStatuswasUNSTABLEpurely because ofthis check.
Root cause
.github/workflows/codeql-analysis.ymlelects a single analysis owner in thescopejob. Thepushbranch of that election asks, exactly once:open_prs="$(gh pr list --repo "$REPO" --state open --head "$GITHUB_REF_NAME" ... )"That sample is taken within a few seconds of the push. When a branch is pushed
first and its PR is opened moments later — the normal agent workflow in
this repository — the push run sees no PR, declares itself the owner, and
uploads a
refs/heads/<branch>analysis for a commit that has meanwhile becomean open PR head. The
pull_requestbranch of the same election has noreciprocal guard, so it always analyzes too.
Reconstructed timeline for PR #427 (all UTC, from the Actions + code-scanning
APIs):
33045284962created fore5bcc813Select one analysis ownerjob runs — no PR exists yet →analyze=truepull_requestrun33045304652created (no push-side dedupe) →analyze=true/language:actionsforrefs/heads/codex/search-commander-overhaul89528413157+ theCodeQLcheck on the PR headtimed_out— only theactionscategory exists for that head/language:actionsforrefs/pull/427/mergejavascript-typescriptanalyses uploadGHAS opened the PR's check against the stray branch-head analysis and gave up
before the PR's own merge-ref analyses arrived.
Differential evidence
Every healthy open PR shows the mirror image — the GHAS check starts within a
second of the merge-ref
actionsanalysis and passes in 1–3s, and none ofthem has a
refs/headsanalysis at the PR head:timed_out(16s), started 06:17:20success(1s), started 06:27:3533045887786correctly skipped (Analyzematrix job =skipped)success(3s), started 05:56:19failure(2s) — a genuine new alert, not this defectPR #427 later received two more commits (
42052bb2,e46a7fec) pushed whilethe PR already existed. That push run skipped analysis and the GHAS check on the
new head is no longer
timed_out— the same natural experiment, confirming therace is the trigger rather than anything in the PR's content.
Fix
.github/workflows/codeql-analysis.yml,analyzejob:Confirm this push still owns the analysisstep betweenInitialize CodeQLand the analyze/upload steps. It re-runs the sameownership query at the last moment before upload and sets
upload=falsewhen an open PR now owns
$GITHUB_SHA.Analyze the triggering revisiononsteps.ownership.outputs.upload != 'false'.pull-requests: readto theanalyzejob permissions (the step needsit for
gh pr list; thescopejob already had it).The check runs only for
pushevents on the branch-ref path(
needs.scope.outputs.analysis_ref == ''), sopull_requestruns and thecentrally dispatched merge-ref path are untouched — and when the step is
skipped, its empty output leaves the existing
ifsemantics unchanged.Placing it after
initrather than at job start was deliberate: it widens therace window it can absorb from a few seconds to the whole database-init period,
while still skipping the expensive query/upload phase. It never leaves a commit
unanalyzed — the PR's own run is the owner it defers to.
Validation
Run from this controller checkout:
python3 -c "yaml.safe_load(...)"on the edited workflow — parses; step orderand every
ifexpression verified programmatically:Confirm this push still owns the analysis→id: ownership,if: github.event_name == 'push' && needs.scope.outputs.analysis_ref == ''Analyze the triggering revision→if: needs.scope.outputs.analysis_ref == '' && steps.ownership.outputs.upload != 'false'analyzepermissions now includepull-requests: read.node .github/scripts/workflow-control-plane-contract.mjs --self-test→workflow control-plane contract: self-test OK.bash -non the extracted step script → syntax OK.codex/search-commander-overhaul@e46a7fec…(the current PR Overhaul web search and Commander results #427head) → emits the notice and writes
upload=false— correctly cedes to thePR run.
codex/search-commander-overhaul@e5bcc813…(a superseded SHA nolonger any PR's head) →
upload=true— still analyzes.github-actions@HEAD(no open PR) →upload=true— unaffected.Not changed
The stray branch-ref analysis already recorded for
e5bcc813is immutablehistory and needs no cleanup; PR #427's current head is analyzed correctly.
Source Lopu workflow run