Skip to content

GitHub Actions cache backend implements CacheBackend - #171

Merged
calvin-archastro merged 3 commits into
mainfrom
factory/tsk_156QmoMkK4dwwn5vfym0XW-e4325876-20ac-4909-bda1-
Oct 21, 2026
Merged

calvin-archastro merged 3 commits into
mainfrom
factory/tsk_156QmoMkK4dwwn5vfym0XW-e4325876-20ac-4909-bda1-

Conversation

@calvin-archastro

@calvin-archastro calvin-archastro commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Epic: Aster restores real build artifacts from a shared cache, including the GitHub Actions cache
Task: GitHub Actions cache backend implements CacheBackend

Local git hooks skipped: pre-push on the submission push and the publish push. CI is the gate for what those hooks check.

Problem and author intent

Aster already had a GitHub Actions cache v2 Twirp client, but it was protocol-only. gha was not a CacheBackend: selection kept none and gha local-only, and an unset ASTER_REMOTE_CACHE always resolved to aster-srv. A job could not publish or restore content-addressed build artifacts through the Actions cache.

A one-entry-per-blob mapping would also thrash that store. Keys are immutable, new entries are rate-limited, and the default repo quota is small with idle eviction. The intended design is one manifest receipt per target, one pack entry per run for blobs under 1 MiB, and a separate content-addressed entry for larger blobs, plus selection, an end-of-run flush, and a summary. Manifest schema stays compatible by adding an optional pack list.

What changed

Before: there was no GHA key layout, no pack membership on manifests, and no shared remote client across the per-target cache tiers the executor builds. Small outputs had nowhere to be bundled, find_missing could not be a lookup-only GHA operation, and runs printed no remote cache summary.

After, on the later revision:

  • GhaBackend implements CacheBackend over the existing client. Manifest keys are aster-v<schema>-ac-<os>-<arch>-<taskkey>.
  • put_blob zstd-compresses the payload and checks the digest. Uncompressed size of at least 1 MiB (PACK_THRESHOLD) is uploaded immediately as aster-v<schema>-cas-<digest>. Smaller frames are staged in memory.
  • Staged frames are uploaded once as an uncompressed ustar of <digest> members at aster-v<schema>-pack-<os>-<arch>-<sha256 of the member digests>. The member set is sealed when the first manifest is stored; a small blob staged after that seal is its own cas entry. finish() uploads a non-empty stage and is a no-op when the stage is empty or writes have stopped.
  • Manifest gains optional packs: Vec<PackRef> (serde default, omitted when empty). Older manifests still decode. Schema version is unchanged. put_manifest records a pack ref only for digests in the sealed member set, then uploads the manifest. It does not upload the pack first, despite the finish() comment that says the pack is uploaded before a manifest so a reader never sees a dangling pack key.
  • find_missing only looks keys up. It never calls CreateCacheEntry. A create that reports the key already exists, or that has no upload URL, does not upload or finalize.
  • One 429 that survives the client retry budget (RATE_LIMITS_BEFORE_STOP = 1) stops later remote writes for the process, logs one warning with the 429 count and entries created, and those writes return success without another request. Detection is a substring match on the error text. The same path runs for lookup failures, so a 429 on download also stops writes.
  • ASTER_REMOTE_CACHE=gha selects this backend when GhaBackend::from_env() can build a client (ACTIONS_RESULTS_URL must be https://, plus ACTIONS_RUNTIME_TOKEN). If either is missing, one warning is printed and the run stays local-only. If the variable is unset, GITHUB_ACTIONS=true, and both ACTIONS_RESULTS_URL and ACTIONS_RUNTIME_TOKEN are non-empty, selection is gha instead of aster-srv. Outside that runtime, unset still means aster-srv.
  • The backend is process-wide (OnceLock). The first install_gha_backend wins so every tier shares the staged pack. The executor calls finish() at end of run (failure is a warning) and prints aster cache summary: remote_hits=N remote_misses=N entries_created=N rate_limited=N via CacheSummary's Display. JSON execution output nests the same object at summary.cache_summary when a GHA backend is installed; the field is omitted otherwise.
  • .github/workflows/ci.yml adds --test cache_gha to a named integration-test invocation. The job name is not in the hunk. Author notes say this is so the macOS job, which lists tests by name, does not skip the file.

An earlier snapshot of the same base had the backend and selection, but not the workflow line, CacheSummary Display, the unset-on-Actions selection assertion, or the JSON pointer check.

File-header comments still say the threshold is zstd frame size. put_blob compares uncompressed length. The large-blob test uses an incompressible payload over 1 MiB, so it does not distinguish those two rules.

Scope

Later revision: 17 paths, 45 hunks, +1481/−27, including .github/workflows/ci.yml. Earlier snapshot: 16 paths, 44 hunks, +1428/−27, same base, no workflow file. Supplied diffs report no omitted hunks or paths. The overall payload and check-log excerpts are marked truncated, so this is not a claim of evidence beyond those excerpts.

In scope: src/cache/gha/backend.rs, gha module exports, GhaCacheClient::for_test (http only for tests; from_env still rejects non-https so a runtime token is not sent in plaintext outside tests), remote selection and the shared slot, PackRef, executor flush and summary, JSON cache_summary, mechanical packs: Vec::new() updates in existing manifest fixtures, tests/cache_gha.rs, and the CI list line.

Out of scope: aster-srv and HTTP cache protocol behavior (fixture field only), a manifest schema bump, and changes to the Twirp retry budget.

Risk assessment

Medium. Packing is what keeps a many-target run from creating one Actions entry per small output, but blobs at least 1 MiB uncompressed, and small blobs staged after the first manifest seals the pack, each become their own entry. Quota and the ~200 new entries/min limit remain real.

Auto-select changes the default remote on GitHub Actions jobs that previously fell through to aster-srv whenever the runtime env is present. Explicit gha without that env degrades to local-only instead of failing the build.

Manifests can be published before the pack exists. A crash, or a finish() failure that is only a warning, leaves receipts pointing at a missing key. After a 429, remote writes return success without uploading, so a rate-limited run can look green while new artifacts exist only locally.

find_missing treats a digest as present if this process has already remembered its pack. It does not re-fetch the pack. A cold process that has not loaded the manifest only looks up the cas key, so a packed blob looks missing until the manifest is read.

The pack is a hand-rolled ustar (member names must be ≤ 100 bytes; digest hex fits). Staged frames stay in memory until finish(). The process-wide slot means the first GHA backend installed in the process is the one every later tier sees, including across tests in one process.

User impact

On a GitHub Actions job with both cache runtime variables set, an unset ASTER_REMOTE_CACHE now uses the Actions cache instead of aster-srv. Set the variable explicitly to keep another backend. ASTER_REMOTE_CACHE=gha without ACTIONS_RESULTS_URL or ACTIONS_RUNTIME_TOKEN warns once and uses only the local cache.

Runs that installed the GHA backend print one summary line. aster run --json includes summary.cache_summary (remote_hits, remote_misses, entries_created, rate_limited) only in that case. Readers of older manifests are unaffected. Small outputs are restored from the pack key recorded on the manifest; large outputs use their own cas entry. After one surviving 429, further remote writes in that process are skipped.

Testing

Canonical tests live in tests/cache_gha.rs and talk to an in-process fake Twirp and blob server, not the live Actions API:

  • upload_from_a_hits_from_b — workspace A uploads; workspace B with an empty local cache hits, sees a pack ref, and restores the blob, with no cas entry for that small blob. The downloader runs only after finish().
  • existing_key_is_not_overwritten — a second store of the same manifest key leaves the first decoded blob in place. The create-count assert only checks the counter did not go backwards.
  • repeated_429_stops_writes — creates that return 429 stop later creates and increment rate_limited.
  • fifty_small_outputs_bundle_into_one_pack — 50 small outputs stay at ≤ 51 entries with bundling on versus ≥ 100 with bundling off. Blobs are staged before any manifest so the pack is not sealed around one blob.
  • large_blob_is_its_own_entry — an incompressible blob over 1 MiB is a cas entry and does not open a pack.
  • gha_selected_when_runtime_env_present — explicit gha selects a backend when the runtime env is https plus a token; unset ASTER_REMOTE_CACHE on GITHUB_ACTIONS=true selects gha; outside Actions, unset stays aster-srv. The rewritten URL is not expected to answer.
  • summary_line_and_json_object — summary line prefix and summary.cache_summary JSON nesting via build_execution_output_with_cache.

The CI workflow hunk adds --test cache_gha to a named test list. Other jobs are not in the excerpt, so this does not show that every CI platform runs the file.

Pipeline checks format, lint, and test are reported succeeded with exit code 0. Those excerpts are truncated and do not show cache_gha results, so reported pipeline success is not evidence that the new tests ran. Author notes claim the canonical tests passed; that is advisory and not confirmed by the truncated logs. The patch is evidence of the test code, not of a green run. No live Actions round trip is in the supplied evidence.

Follow-ups and known issues

  • Upload the sealed pack before publishing a manifest that references it, or treat a skipped finish() as a failed remote publish. Today put_manifest uploads the receipt first.
  • Align the module header and the large-blob test comment with the uncompressed 1 MiB threshold.
  • Comments and the acceptance wording say writes stop after repeated 429s; the constant is 1. Confirm that one surviving 429 is intentional, and that lookup 429s should stop writes.
  • find_missing does not resolve pack membership from the remote unless this process has already read the manifest.
  • Writes after the stop return Ok(()), so callers cannot tell uploaded from dropped.
  • Process-global backend state can leak across tests that call select_remote or install_gha_backend in one process.
  • The hand-rolled ustar reader/writer has no coverage here for truncated archives, bad checksums, or names over 100 bytes beyond the bail in write_ustar.

@calvin-archastro
calvin-archastro requested a review from a team October 21, 2026 00:06
@calvin-archastro

calvin-archastro commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor Author

ArchDev is watching this pull request. State: settled (cycle 2 of 2 for head 377227b).

when trigger decision result
2026-10-21T00:16:02.599Z review feedback enqueue job_db9bb37ac404accee701f54e published 377227b
2026-10-21T00:36:13.849Z review feedback enqueue job_93a44a6008780fdb6b507b90 no change

Paused by the no-autofix label or a draft; never resolves reviewer threads. Jobs: job_db9bb37ac404accee701f54e, job_93a44a6008780fdb6b507b90.

Archdev-Job: job_db9bb37ac404accee701f54e
Co-Authored-By: ArchDev <archdev@archastro.ai>
Archdev-Model: anthropic/claude-opus-5.5
@calvin-archastro
calvin-archastro merged commit 7ec9a79 into main Oct 21, 2026
12 checks passed
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