fix(ci): one writer to the buildcache ref #3
No reviewers
Labels
No labels
waiting-on-julian
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
jlxq0/hevy-mcp!3
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/one-writer-to-buildcache"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
This repository's last
mainpush has read red since 2026-08-17 and the causeis not the code.
Merging a pull request and pushing the release tag behind it are two pushes
seconds apart, so two
dockerjobs run at once, and both exportedmode=maxtothe same unqualified
:buildcacheref.refs/heads/main80c2d25refs/tags/v0.3.080c2d25Same commit, same image, one red. v0.2.0 did the same: 12293 (main) failed while
12292 (tag) succeeded, started 14 seconds apart. v0.1.0 and v0.2.1 pushed tag and
main 80 to 90 seconds apart and both passed.
The fix
The tag build imports the cache and no longer exports it. A tag build is the same
commit as the
mainbuild that preceded it — the branches differ only inTAGSand
PUSH, which are output settings rather than build inputs — so the twocaches would have been byte-identical. Exporting twice was pure duplication, and
the duplication was the race.
This follows
jlxq0/jmap-mcp#11, which closedjlxq0/m365-mcp#4the same wayafter reading the failure off a log this instance will not give us:
on m365-mcp runs 49 and 50, failing half a second apart, after the image had
already been pushed. caldav-mcp, typst-mcp and m365-mcp have converged on the
same shape, so this is a fifth copy of a fix rather than a new invention.
Not chosen
A
concurrency:group. Forgejo job-levelconcurrency:is unverified on thisinstance and an unsupported workflow key is ignored in silence, so it would look
applied and do nothing — which is the failure mode this whole class is about.
jlxq0/m365-mcp#7is where verifying it belongs.A per-ref cache. It gives every tag build a cold cache, which for a Rust image is
the expensive path, and it accumulates refs on a registry that is already the
fleet's disk bottleneck.
What is and is not verified
Not verified by running, and it cannot be from one push. Reproducing the race
needs a tag and a
mainpush landing together, which is the next release. Thered this fixes is also the inferred cause here rather than the observed one:
job logs 404 on this instance over both the API and the web path, so a red job is
diagnosed by shape and by reproduction, never by reading it. What transfers from
m365-mcp is the error and the mechanism; what is measured here is the timing.
Checkable now, and checked:
bash -ngrep -c export-cache .forgejo/workflows/ci.ymlis 1GITHUB_REFrefs/tags/v9.9.9refs/heads/mainrefs/pull/3/headAGENTS.mdrecords the one-writer invariant, thatgrep -c export-cacheshouldstay 1, and that adding a
schedule:trigger would reintroduce a second writer,since a cron run's
GITHUB_REFisrefs/heads/main— which isjlxq0/m365-mcp#7's first surviving path, and the reason this repository iscurrently unaffected by it.
d1c5fbb666009586b9fdRebased onto
mainafter #4, so itscargojob now has the h2 fix under it and should go green. Two additions since the first push.What the real verification is, and what I will look for. This cannot be proven from a pull request: reproducing the race needs a tag push and a
mainpush landing together, which is the next release. At that release, both runs should appear as docker tasks and both should besuccess— specifically themainone, which is now the only exporter. If themaindocker job fails again inside a minute while the tag one succeeds, this fix was wrong and the writer is not what serialises them. I will read it offGET actions/tasksrather than off the commit statuses, for the reason below.Two ways of misreading this CI, both of which I hit today, now in
AGENTS.md.A
dockerjob skipped becauseneeds: cargofailed postssuccessto the commit status. PR #2 showsfailure CI / cargoat 05:23:03Z andsuccess CI / dockerat 05:23:04Z, one second later, with no docker task in the run list at all. PR #3 shows the same pair, 05:34:52Z and 05:34:53Z. I read both of those green ticks as builds and reported them as such before checking, which is why it is written down rather than remembered.matrix-mcpandm365-mcphave the same fault.A job that ends within seconds of starting never ran. Run 16913 lasted two seconds; the retry of the same branch built in 63. The runner is capacity 1 and shared by every repository in the fleet, so that is contention, and the answer is to push again rather than to read the diff. A genuine failure in this workflow costs at least as long as the step that failed, which is why the buildcache race took 42 to 58 seconds — the image had to build before the export could lose.
Durations from
actions/tasksneed the same care: several 2026-08-17 runs carry anupdated_attwo days after theirrun_started_at, which is a backfill rather than a two-day build.The first release since this merged is out, and the result is green but does not verify the fix. Saying so because the green is easy to read as verification and I set this pull request up to be tested by exactly this release.
All four jobs on
v0.4.0succeeded, and all four exist as tasks rather than ticks:Compare with the two releases that failed:
The two
dockerjobs did not overlap this time.mainfinished at 02:04:15Z and the tag started at 02:05:39Z, 84 seconds later. The runner has capacity 1 and serialised them, so there were never two writers to:buildcacheregardless of what this branch changed. The v0.3.0 pair overlapped for nearly a minute; this pair did not overlap at all.So the result is consistent with the fix and uninformative about it. An unpatched workflow would have passed this run too.
What did work as intended, and is checkable rather than inferred: the tag build imported the cache and did not export,
grep -c export-cache .forgejo/workflows/ci.ymlis still 1, and the tagdockerjob took 27 seconds againstmain's 70, which is the import-only path hitting a cache themainbuild had just written.What would actually verify it is a release where the two
dockerjobs overlap, and that is not something to arrange by hand: pushing a tag faster does not help, because the queue decides. The honest position is that this stays unverified until a release happens to land while the runner has capacity for both, and the thing to record then is whether their windows overlap, not whether they are green.I have added nothing to
AGENTS.mdclaiming otherwise. The bullet there already says the real verification is the next release, and it should now say the next release with an overlap.Verified.
v0.4.1produced the overlap, and both jobs passed.Both started in the same second and ran concurrently for twelve seconds, which is the condition this branch was written for and the one
v0.4.0did not produce. Compare the two releases that failed, where themainjob died with the image already built:Under the workflow this replaced, both of those concurrent jobs would have exported
mode=maxto the same unqualified:buildcacheref, which is what costv0.2.0andv0.3.0a redmain. Only themainjob exports now, and the pair came through clean.The durations are consistent with that being the mechanism rather than luck: the tag job took 12 seconds importing only, the
mainjob 30 seconds importing and exporting.This is the check
AGENTS.mdnames, arrived at by waiting for a release that happened to overlap rather than by arranging one.#9's note that a green release is not verification stands as written, and this release is the exception it describes.