pr-media: read the existing file's sha with git, not the contents API

Closed
TE
5d ago
+834-310

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/cmux whose origin is 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 fetched pr-media is skipped, because fetching it would pull every media blob.
  • git_blob_sha() gets the tip with git 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 runs git 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=1 on 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.png as 751f14e7… 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 --pr upload. That follows after merge, since pr-capture runs feat-cmux-next's copy of the script.

Changelog

none

risk: normal (CI tooling, no app code)

Agent session: agent session

šŸ¤– Generated with Claude Code