Skip to content

Cut the deploy over to the artifact store: image fetch, deployed markers, prewarm retired#653

Merged
anth-volk merged 7 commits into
mainfrom
feat/artifact-fetch-cutover
Jul 24, 2026
Merged

Cut the deploy over to the artifact store: image fetch, deployed markers, prewarm retired#653
anth-volk merged 7 commits into
mainfrom
feat/artifact-fetch-cutover

Conversation

@anth-volk

@anth-volk anth-volk commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Fixes #652

Phase 3 of the baseline artifact pipeline (phase 1: #644, phase 2: #649). The store that phases 1–2 fill and verify finally gets its consumer: the image fetches precomputed artifacts instead of building datasets, and the deploy records what it shipped.

What changed

Commit 1 — self-contained fetch_artifacts (inert). New image-layer function in _image_setup.py: downloads every manifest-listed artifact into POLICYENGINE_DATA_FOLDER, with a freshness gate (manifest receipt versions vs the image's env versions, plus the installed bundle receipt's data version when present), one loud error listing all missing store objects before any download, unconditional overwrites, and GCS credentials built in memory from the secret blob (service_account.Credentials.from_service_account_info passed straight to storage.Client — nothing is ever written to disk, since a run_function layer commits its filesystem into the image; reworked in the follow-up commit after review). Zero callers in this commit.

Commit 2 — the atomic swap. app.py resolves the deploy manifest on the runner at modal.is_local() time from the new POLICYENGINE_MANIFEST_DIGEST env var (threaded from needs.precompute.outputs.manifest_digest, alongside the bucket var and GCP_CREDENTIALS_JSON) and passes bucket + manifest content as the fetch layer's args — the layer cache follows the artifact set exactly, retiring the force_build ritual. A digest-less import stays safe (the precompute and smoke apps import app.py where no digest can exist) via a None-manifest sentinel that fails at build time, never import time. The prebuild layer, PREBUILD_DATASET_YEARS, and prewarm_app.py are deleted; retired tests are replaced per a migration mapping (fetch layer ordering + sentinel + years-cover-default; the writer==reader filename coupling was already locked by the phase-1 precompute tests).

Commit 3 — deployed markers. A "Record deployment marker" step runs on both legs after the health check, writing deployed/<environment>.json (manifest digest, versions, run identity) via the first caller of the phase-1 marker helpers — the liveness signal for phase-4 GC. A failed marker write fails the deploy job by design.

Commit 4 — docs. Executor README describes the pipeline as it now is (fetch layer, freshness gate, local-deploy inputs, markers, the inherited bundle-install staleness residual); all remaining prebuild/prewarm prose is gone.

Why the merge is atomic

Once app.py expects the manifest digest, a deploy without the precompute output fails. The producer already flows from phase 2, so this PR adds only the consumer — there is no intermediate merge state where a deploy half-consumes the store.

Verification

  • Full executor suite green after every commit (391 tests at head; the six retired prebuild tests are accounted for in the migration mapping in the plan).
  • Workflow YAML parses; the deploy job's diff is exactly three env lines plus the marker step; residue grep for prebuild/prewarm is clean repo-wide.
  • Not run at PR time (by design — nothing precomputes or deploys from PRs): the real proof is the post-merge deploy. Gate: both legs green with fetch-in-seconds image builds, baseline_artifact=hit on a beta economy request with p50 down, deployed/beta.json + deployed/prod.json in the bucket, then a fault drill (delete one baseline object → local fetch fails loudly → plain precompute re-run heals with an unchanged manifest digest).

Rollback

One revert restores the prebuild layer, prewarm app, workflow, and README verbatim; phases 1–2 keep running harmlessly underneath. Markers go stale after a revert (last-writer-wins, no more writers) — the phase-4 GC design must treat marker staleness conservatively.

🤖 Generated with Claude Code

anth-volk and others added 7 commits July 24, 2026 22:58
fetch_artifacts downloads every manifest-listed artifact from the GCS
store into POLICYENGINE_DATA_FOLDER, with a freshness gate comparing the
manifest's version receipt against the image env (and the installed
bundle receipt's data version when present), a loud one-error existence
pass before any download, unconditional overwrites, and an inline GCP
credentials shim whose temp file is deleted before the layer commits.
Inert in this commit: nothing calls it yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The deploy resolves the precompute job's manifest digest on the runner
(modal.is_local time) and passes bucket + manifest content as the fetch
layer's args, so the layer cache follows the artifact set exactly. A
digest-less import stays safe (precompute and smoke apps import app.py
where no digest can exist) via a None-manifest sentinel that fails the
build, not the import. The prebuild layer, its years constant, and the
prewarm app are deleted; retired coupling tests are replaced per the
migration mapping (fetch layer ordering, sentinel, years-cover-default).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A new "Record deployment marker" step runs on both legs after the health
check, writing deployed/<environment>.json (last-writer-wins) with the
manifest digest, versions, and run identity — the first caller of the
Phase-1 marker helpers, and the liveness signal the artifact GC will
rely on. A failed marker write fails the deploy job by design: a deploy
that cannot record itself must not pass silently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The executor README loses the retired prebuild/prewarm section and gains
the consumer half of the pipeline: the fetch layer and what busts its
cache, the freshness gate, local-deploy inputs, the deployed markers,
and the inherited bundle-install staleness residual. Remaining prose
mentions of the prebuild (root README, smoke app and artifact_keys
docstrings, one test name) are updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review feedback on the credentials shim: instead of writing the secret
blob to a temp file and pointing GOOGLE_APPLICATION_CREDENTIALS at it,
parse it and hand google.oauth2.service_account credentials straight to
storage.Client — the standard google-auth pattern for credentials that
arrive as data. No file ever exists, so the risk of committing a key
into the image layer is gone structurally, and the cleanup block goes
with it. A source-level regression test keeps tempfile out of the
module; the ADC fallback for local drill runs is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st inertness

The custom-data folder redirect is permanent under the artifact
pipeline, not a bridge pending issue #596 — its comment (and the
matching test's) now states the constraint on its own terms. The
marker step's env wiring is asserted line-by-line, since the payload
builder maps missing env vars to empty strings and a silent rename
would otherwise empty the marker's fields with green tests. The
container-entrypoint test now sets a manifest digest and asserts the
fetch layer args stay inert, locking the is_local-first ordering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dary

DeployedMarker joins the strict precompute schemas: the marker is a
persisted wire shape GC will read back, so it must not live as a bare
dict. record_deployment builds the model and dumps it at the store call
site (the publish_manifest_impl discipline), and app.py now validates
what it reads from the store as ArtifactManifest before handing the
canonical payload to the fetch layer — a corrupt or foreign manifest
fails the deploy on the runner, not mid-image-build. Test manifests are
derived from the model so the fakes cannot drift off the wire shape;
the dict survives only at the import-restricted layer boundary, where
it belongs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@anth-volk
anth-volk marked this pull request as ready for review July 24, 2026 21:53
@anth-volk
anth-volk merged commit 0d30c1d into main Jul 24, 2026
8 checks passed
@anth-volk
anth-volk deleted the feat/artifact-fetch-cutover branch July 24, 2026 21:53
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.

Baseline artifact pipeline phase 3: deploy consumes the store (image fetch, deployed markers, prewarm retired)

1 participant