pr-media: read the existing file's sha with git, not the contents API
Order (fanout-infra-3's coordinator): "since this morning the glaeda-gh shim refuses source reads over the API, and that breaks pr-media.py's existence check (gh api repos/manaflow-ai/cmux/contents/...?ref=pr-media). So cmux-ci capture --pr and pr-media uploads fail for every lane (seen on #28064, #28043 and #27839). Fix it with either a git-based existence check in pr-media.py (ls-remote / fetch of the pr-media ref) or a shim exemption for the pr-media ref, whichever is right; independent review; land."
What was failing
cmux-ci capture --pr (HQ build-fleet/pr-capture) runs scripts/pr-media.py from feat-cmux-next on the lane's host. Before each contents PUT, existing_sha() read repos/O/R/contents/<pr>/<name>?ref=pr-media for the sha the PUT needs. On an agent host, glaeda-gh refuses an API GET of contents/ for a repository checked out there (it spends the shared 5000/h quota on bytes git already has). That refusal exits 2 without a 404, so gh_read raised MediaError and the upload stopped. The PUT itself is a write and passes the shim.
What changed
existing_sha() now reads the branch with git when a clone can answer it cheaply:
media_checkout()picks the first candidate among$PR_MEDIA_CHECKOUT, the current directory's clone and~/Projects/cmuxwhoseoriginis github.com's REPO, that isn't shallow, and that is a partial (blob:none) clone or already tracks the branch. A full clone that never fetchedpr-mediais skipped, because fetching it would pull every media blob.git_blob_sha()gets the tip withgit ls-remote origin refs/heads/pr-media. When the tip isn't local, it fetches that sha (--no-tags --no-write-fetch-head, so it writes no ref and no FETCH_HEAD and sessions sharing a clone don't race on a lock). Then it runsgit ls-tree -z <tip> -- <path>and takes a blob entry's sha (the same sha the contents API returns), or None. The tip is kept for the rest of the process. A PUT that lost a race re-reads it (fresh=True), so another writer's new file still gets its current sha.- With no such clone (a shallow Actions checkout, a host with no cmux clone), or when git fails, the API read runs as before with
GLAEDA_GH_ALLOW_API_SOURCE=1on that one call. That is the shim's own escape "for a path git really cannot answer". Outside the shim (Actions) the variable does nothing.
No shim exemption: the guard is glaeda's general rule, and its message already tells callers to use git. No new timer or loop. Each git call has a timeout (20 s for local reads, 60 s for ls-remote, 120 s for the fetch).
scripts/ci/pr_media.py published() (main only) makes the same kind of read, but it runs only in the pr-media.yml Actions job, where there is no shim, so it is left alone. pr_media_gate.py, pr_media_privacy.py and prune_pr_media.py make no contents reads (the last two use GraphQL).
Tests
python3 tests/test_pr_media.py: 76 tests OK on the head. The new GitExistenceTests and ApiFallbackTests use a local bare repository as origin and cover:
- the git path returning the blob sha with no gh call;
- a missing path, and a folder path, returning None with no gh call;
- a fresh read seeing another writer's push;
- a lost-race PUT re-reading the sha with git;
- a git failure, and having no clone at all, both falling back to the API with
GLAEDA_GH_ALLOW_API_SOURCE=1; - origin URL matching;
- a shallow clone, or a full clone that never fetched the branch, being passed over.
On the base the file fails at import (module 'pr_media' has no attribute 'media_checkout').
Observed / Not run
- Observed on an agent host against the real repository: the git path read
28064/cap-51810e73-steps-55d3d0318c-dark.pngas751f14e7ā¦and a missing name as None, in 1.65 s with no API call. With no clone, the fallback call passed the shim and got a 404, so it returned None instead of the refusal. - Not run: a full
cmux-ci capture --prupload. That follows after merge, since pr-capture runsfeat-cmux-next's copy of the script.
Changelog
none
risk: normal (CI tooling, no app code)
Agent session: agent session
š¤ Generated with Claude Code