Skip to content

Speed up deploys with cached builds and worker-first rollout - #206

Merged
christophervoelpel merged 16 commits into
mainfrom
perf/deploy-speedup
Sep 25, 2026
Merged

christophervoelpel merged 16 commits into
mainfrom
perf/deploy-speedup

Conversation

@christophervoelpel

@christophervoelpel christophervoelpel commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

What changes

A deployment now builds one digest-pinned image, deploys the worker and its invoker grant first, then deploys the IAP-gated app, and only then seeds Firestore settings. A failed worker rollout therefore cannot promote a new app revision. Cloud Build uses the default machine pool on both cold and warm runs; the former image probe selected e2-highcpu-8 on every run and could crash under macOS Bash 3.2 when corrected.

The BuildKit Dockerfile and layer cache remain. A forced --no-build-cache run truly ignores prior layers but exports inline cache metadata for the next warm run. The script verifies its own successful Cloud Build result and deploys both services by that build's immutable image digest, so another :latest push cannot change either image during this rollout. The UI build always runs npm ci; background jobs and heartbeat are stopped on abort. The IAP capability probe, Docker Hub fallback, uv pin, and Dockerfile pinning test are also corrected.

Measured deployment trials

Two fresh projects in us-central1 were deployed under the same billing account and then unlinked/deleted. Times are the script's wall time; build queue and active times come from each run's Cloud Build receipt. Default-pool queueing stayed below one second in these runs.

Run Total Build queue Build active Context
Fresh project A / B 336 / 326 s 0.8 / 0.9 s 85 / 88 s Worker-first and default pool; before later digest/cache-metadata refinements
First warm A / B 165 / 158 s 0.6 / 0.7 s 87 / 81 s Code changes after a forced cold build that lacked inline-cache metadata
Cached code-change warm A / B 120 / 98 s 0.6 / 0.7 s 43 / 34 s Python / UI edits, immutable digest used by both services
Forced clean refresh B 197 s 0.6 s 96 s Final --no-cache branch, verified in the remote build receipt
Next ordinary warm B 158 s 0.6 s 52 s Cache imported; worker readiness alone took 61 s

The first-warm cache gap was found during these trials and fixed by exporting inline metadata even on forced clean builds. The final forced-clean receipt shows _USE_CACHE=0, Docker --no-cache, inline-cache export, a successful push, and the default pool. The subsequent build imported the cache. For comparison only, the independent review measured one main cold deploy at 426 s and warm redeploys at 132–153 s; these are separate runs, not a controlled paired benchmark. The initial cold trials precede the final digest/cache-metadata refinements, whose relevant paths were then exercised by the later B runs.

Safety and verification

Both fresh-project apps were IAP enabled and private, with service-level run.invoker for the IAP agent; both workers were private with service-level run.invoker for the runtime account. App and worker revisions in project A used the same verified digest. An injected build failure left the services and six captured Firestore documents unchanged. An injected worker failure on a warm project left the previous app revision serving 100% of traffic and its settings unchanged. After removing only the two services in test project B, an injected first-deploy worker failure created neither service. These are local CLI fault injections against live projects, not genuine Cloud Run startup failures.

Locally: 755 Python tests and 27 subtests, including 77 deployment-safety tests, passed; Bash syntax, ShellCheck at error severity, and whitespace checks passed. The fault and cache tests exercise macOS Bash 3.2 behavior.

The runtime and build accounts are separate, but both trial projects' default Compute/build accounts also inherited project roles/editor, as on main; the added build roles do not make that account least-privileged. The deployment is not transactional: an app failure can leave a new worker with the old app, and a settings-seed failure can leave new code with old or partly updated settings. Adjacent versions should remain compatible and the deploy can be rerun. Overlapping full deploys, older gcloud output formats, and authenticated browser/media/provider flows were not tested. OAuth consent/client setup and user grants were not completed in the disposable projects.

@gps-readability-bot

Copy link
Copy Markdown

Still need readability approvals from:

@gps-readability-bot

Copy link
Copy Markdown

Readability review is not required.

… and overlap infra/Firestore with Cloud Build
@gps-readability-bot

Copy link
Copy Markdown

Still need readability approvals from:

@christophervoelpel
christophervoelpel marked this pull request as ready for review September 23, 2026 15:50
@christophervoelpel christophervoelpel changed the title Speed up deploy.sh via lockfile-hash npm ci guard, IAM policy cache, and parallel Cloud Run rollout Speed up deploy.sh via parallel BuildKit Dockerfile, overlapped infra setup, IAM policy snapshot, and parallel Cloud Run rollout Sep 23, 2026
@christophervoelpel

Copy link
Copy Markdown
Collaborator Author

Review: security + deployment stability

I reviewed this against main and then actually ran it — 2 cold deploys into freshly-created GCP projects and 2 warm redeploys, us-central1, AUTH_MODE=iap, branch @ 5907cff.

Verdict: needs changes before merge. The optimizations are real and mostly well-built, but 1 of my 2 cold deploys failed, and the failure mode is a direct consequence of this PR's design. There's also a supply-chain hole that the PR's own headline claim says doesn't exist.

Test projects (deleted after the run): smrev206-de2xie5nr51jhkkuzsufc (P1), smrev206-4opaxchjdjf0n3g9n1kys (P2).

Run Project Result
Cold #1 P1 ✅ EXIT=0
Cold #2 P2 ❌ EXIT=1 — PERMISSION_DENIED on gcloud builds submit
Cold #2 (retry) P2 ✅ EXIT=0
Warm #1 P1 ✅ EXIT=0
Warm #2 P2 ✅ EXIT=0 (91 s total)

🔴 Blocking

1. You deleted the IAM propagation buffer in front of gcloud builds submit

This is the one that actually broke. Cold #2 died here:

ERROR: (gcloud.builds.submit) PERMISSION_DENIED ... could not resolve source:
  Get "https://storage.googleapis.com/storage/v1/b/smrev206-4opaxchjdjf0n3g9n1kys_cloudbuild/o/source%2F....tgz"
  : generic::permission_denied: IAM permission denied for service account
    932397919877-compute@developer.gserviceaccount.com

The Cloud Build SA had been granted roles/storage.objectUser moments earlier; the grant simply hadn't propagated.

Here's the thing — on main, a lot of wall-clock work sits between those IAM grants and builds submit: Cloud Tasks queues, the GCS bucket, two Firestore DBs + TTL, the custom role, Firestore seeding, and the foreground Angular build. That's roughly two minutes of accidental propagation buffer. This PR backgrounds or relocates essentially all of it, so builds submit now fires almost immediately after the grants.

I measured the gap on both runs:

Run grant → builds submit Result
Cold #1 ~75 s passed
Cold #2 ~24 s failed

The speedup is real, but it was partly load-bearing. _retry_iam_write doesn't help here — the write succeeded; it's the consumer (Cloud Build reading the source bucket) that got PERMISSION_DENIED.

Fix: make the dependency explicit rather than incidental — retry gcloud builds submit on PERMISSION_DENIED, or poll the Cloud Build SA's effective permissions before submitting. Please don't rely on "something else takes long enough".

2. No trap — an aborted deploy leaves the project half-built and throws away the evidence

Three background jobs are spawned (UI_BUILD_PID at deploy.sh:434, INFRA_SETUP_PID at :863, WORKER_DEPLOY_PID at :979), and there are 19 exit 1 paths between the spawns and their waits, with no trap anywhere in the script.

When Cold #2 aborted, P2 was left like this:

Resource State
Cloud Tasks queues ×3 ✅ created
GCS bucket ✅ created
Firestore scene-machine / scene-machine-ui ✅ created (15:59:05Z / 15:59:13Z)
SceneMachineUser custom role ❌ absent
Firestore config/global seed ❌ HTTP 404, never written

Note the second Firestore DB was created at 15:59:13Z — the parent had already failed. Depending on process-group semantics you either get background jobs still mutating a project the operator believes is dead, or (what I saw) they die mid-sequence. Both are bad.

And the part I'd consider most serious: $INFRA_SETUP_LOG is only cat-ed after a successful wait. On the abort path it is neither printed nor deleted. The entire transcript of a third of your provisioning was sitting in /tmp/tmp.kezrQ1ItGd — the operator saw only the Cloud Build error and had no idea the background phase had also stopped halfway.

Note

The mktemp files are mode 0600, not world-readable. Flagging that because it's easy to over-call.

Fix — and please use this version, not the obvious one. I tested the naive snippet and it crashes:

$ bash a.sh          # cleanup(){ kill "$UI_BUILD_PID" ... }; trap cleanup EXIT
a.sh: line 3: UI_BUILD_PID: unbound variable

Under set -euo pipefail the trap dereferences unset PIDs if you exit during pre-flight. And a bare kill "$PID" on the UI subshell does not reap npm/ng — I verified the grandchild survives. This version I did run successfully:

UI_BUILD_PID=""; INFRA_SETUP_PID=""; WORKER_DEPLOY_PID=""
UI_BUILD_LOG=""; INFRA_SETUP_LOG=""; WORKER_ERR_FILE=""

cleanup() {
  local pid
  for pid in "${UI_BUILD_PID:-}" "${INFRA_SETUP_PID:-}" "${WORKER_DEPLOY_PID:-}"; do
    [ -n "$pid" ] || continue
    kill -- "-${pid}" 2>/dev/null || kill "$pid" 2>/dev/null || true
  done
  for log in "${UI_BUILD_LOG:-}" "${INFRA_SETUP_LOG:-}" "${WORKER_ERR_FILE:-}"; do
    [ -n "$log" ] && [ -s "$log" ] && { echo "--- background log: $log ---" >&2; cat "$log" >&2; }
    rm -f "$log"
  done
}
trap cleanup EXIT

The important bit is that it dumps the background logs on the failure path. Right now a failed deploy is close to undebuggable.

3. # syntax=docker/dockerfile:1 is an unpinned Docker Hub image

The PR summary says "immutable @sha256: image digest pinning" and the new test claims to enforce it. But Dockerfile:1 adds:

# syntax=docker/dockerfile:1

cloudbuild.yaml sets DOCKER_BUILDKIT=1, so BuildKit resolves that directive as an OCI image pulled from docker.io/docker/dockerfile:1 at build time and runs it as the Dockerfile frontend. It is:

  • on a mutable floating tag, with no @sha256:;
  • not routed through mirror.gcr.io — so it re-introduces exactly the anonymous Docker Hub pull (and 429 exposure) that §1.1 says this PR eliminates;
  • the most privileged thing in the build — the frontend parses the Dockerfile and emits the build graph;
  • invisible to the new test, which only scans FROM and COPY --from=.

Worth noting: this Dockerfile uses no BuildKit-only syntax. Multi-stage parallelism works fine with the builtin frontend. The simplest fix is to delete line 1. If you want to keep it, pin it: # syntax=docker/dockerfile:1.7.0@sha256:....


🟠 Should fix

4. The new safety test is vacuous — 0/3 mutation score

I mutated the Dockerfile and re-ran test_dockerfile_external_images_are_digest_pinned_and_hash_verified for each:

Mutation Test result Verdict
--require-hashes demoted to a comment PASSED regression not caught
--only-binary :all: deleted PASSED never asserted at all
FROM runtime-base AS final → from alpine:latest as final PASSED unpinned external base slipped through

Three problems:

  1. assert "--require-hashes" in dockerfile is a substring check over the whole file — a comment satisfies it.
  2. --only-binary :all: is in the test's name but never in its assertions.
  3. ^FROM\s+\S+\s+AS\s+(\S+) is case-sensitive, and Docker accepts lowercase from/as.

A test that can't fail isn't protecting the property it's named after. Assert against the actual RUN uv pip install argument string, make the regexes case-insensitive, and add the # syntax= directive to the check.

5. _CACHED_PROJECT_IAM_POLICY appends; it does not invalidate

The description says the snapshot is "invalidat[ed] after any write". deploy/libs.sh:217 does:

_CACHED_PROJECT_IAM_POLICY="${_CACHED_PROJECT_IAM_POLICY}"$'\n'"${role}"$'\t'"${member}"

That's optimistic mutation, not invalidation. Two consequences:

  • if gcloud projects get-iam-policy fails, __UNAVAILABLE__ is cached under _CACHED_IAM_PROJECT for the whole run, so all 10 checks degrade to unconditional writes for the rest of the deploy;
  • add_run_invoker_binding now runs inside background subshells, so any cache mutation there is discarded when the subshell exits.

Neither is dangerous, but the documented behaviour and the real behaviour don't match. Either genuinely invalidate (_CACHED_IAM_PROJECT="") or reword the claim.

Note

The cache is blind to IAM conditions (--flatten drops bindings.condition), so a conditional binding reads as satisfied and the unconditional write is skipped. I checked origin/main — it used --filter="bindings.role=... AND bindings.members=...", which has the identical blind spot. Pre-existing, not a regression. Still worth a follow-up issue.

6. Phase timing output is now chronologically impossible

phase/close_phase mutate shell state and are called inside the INFRA_SETUP_PID subshell, whose log is dumped much later. Straight from my cold1.log:

16:00:52  + 146s  total 0h 04m 01s   Building the single container image (Cloud Build)...
15:58:11  +  38s  total 0h 01m 20s   Completing overlapped infrastructure & Firestore setup...
15:58:21  +  10s  total 0h 01m 30s   Setting up Cloud Tasks queues...

Time goes backwards, and the inherited phase gets closed twice (once in the subshell, once in the parent). Since this PR's whole value proposition is timing, the timing report should be trustworthy. Consider collapsing the background work into one parent phase.


🟡 Minor

7. WORKER_URL is not idempotent across deploys. First deploy has no worker, so deploy.sh:969 fabricates https://worker-${PROJECT_NUMBER}.${REGION}.run.app. Every later deploy reads status.url, which returns the legacy form. Measured on both projects:

Project after cold deploy after warm redeploy (no code change)
P1 worker-837324250369.us-central1.run.app / app-00001-544 worker-cnxtdszqxa-uc.a.run.app / app-00002-l8b
P2 worker-932397919877.us-central1.run.app / app-00001-72k worker-bkdpk23boa-uc.a.run.app / app-00002-hmv

So every first redeploy cuts a new app revision purely from the env-var churn. And because orchestrator.py:_validate_task_instance_url pins instance == WORKER_URL exactly, a Cloud Task enqueued before the redeploy and retried after it is rejected by the pin. Cheap fix: fabricate the URL, then after wait "$WORKER_DEPLOY_PID" compare against the real status.url and re-issue the app env var if they differ.

8. "100% sequential IAM binding writes" isn't accurate. add_run_invoker_binding worker runs in the WORKER_DEPLOY_PID subshell while the foreground runs add_run_invoker_binding app, and gcloud iam roles create/update SceneMachineUser runs in INFRA_SETUP_PID. Different resources, so no etag races — but the claim as written is wrong.

9. BUILD_MACHINE_ARGS keys off the wrong thing (deploy.sh:912). It sets e2-highcpu-8 only when the Artifact Registry repo is created. My Cold #2 retry hit exactly the gap: repo already existed from the failed run, image did not, so a genuinely cold build ran on the default pool. Key off image existence instead.

10. deploy.sh:984 prints "Deploying 'worker' and 'app' … in parallel" even under --app-only, when the worker is skipped entirely.

11. ADC_TOKEN hoisting. argv exposure via ps is unchanged from main. The only new exposure is that under bash -x the token would land in a mktemp log that isn't unlinked on failure. Low, but the trap in #2 fixes it for free.


✅ What I got wrong

In fairness — my initial read, and one of my reviewers, concluded that the fabricated WORKER_URL would break Cloud Tasks callbacks outright. That is false, and I want it on the record rather than buried.

Cloud Run serves both URL forms for the same service. Unauthenticated probes, with controls:

https://worker-837324250369.us-central1.run.app       -> 403   (fabricated form — real service)
https://worker-cnxtdszqxa-uc.a.run.app                -> 403   (status.url form)
https://nosuchsvc-837324250369.us-central1.run.app    -> 404   (control: no such service)
https://worker-999999999999.us-central1.run.app       -> 404   (control: no such project)

Also true on the long-lived scenemachine-ymbfo6a1w5uq40yu3 project. So the fabrication works; the residual defect is only the idempotency issue in #7.


✅ What's genuinely good

  • Base images are properly digest-pinned, and mirror.gcr.io + a digest is a sound combination — the digest makes the mirror untrusted-but-verified.
  • --only-binary :all: is a real supply-chain improvement: no more arbitrary setup.py execution at build time. I checked requirements.txt and nothing needs an sdist. Worth calling out explicitly in the description, because it also removes the compiler toolchain rationale for the old full-fat builder stage.
  • Dropping /bin/uv from the final image is the right call.
  • The cmp -s lockfile stamp is correct: the stamp is written after npm ci and before ng build, so a failed ng build doesn't poison it.
  • wait "$UI_BUILD_PID" genuinely does gate gcloud builds submit, so there's no tar-while-writing race on ui/dist.
  • Warm redeploy measured 91 s on P2 — better than the 114 s in the table.

Notes on the description

  • "66 passed" is accurate but reads as if the PR added them: 65 pre-existed, this PR adds 1.
  • The parenthetical claims new tests verify "--only-binary :all: enforcement" and "runtime execution of the single-read IAM policy snapshot cache + invalidation". Neither exists — see Bump hono from 4.12.9 to 4.12.12 in /ui #4. There are no tests for the backgrounding logic, the fabricated WORKER_URL, or the IAM cache.
  • "Reviewed by pro Security & Simplicity Reviewer (Verdict: READY FOR MERGE)" — please drop this. It's an AI self-attestation formatted as a third-party security sign-off, and this review found a supply-chain gap and a reproducible cold-deploy failure that it missed.
  • The benchmark tables can't be reproduced from anything in the repo. Given timing is the entire point, consider committing the harness.

Happy to pair on #1 and #2 — those are the two I'd actually block on. #3 is a one-line deletion.

Reviewed with 4 live deploys (2 cold / 2 warm) into throwaway Cloudwerk projects, since deleted. Multi-agent review: two independent pro passes (security, stability) + a claims/mutation audit, then a red-team critique pass that killed one false headline finding and corrected four severities before posting.

@christophervoelpel christophervoelpel left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline notes to accompany my review comment above — the five spots I'd actually change.

Comment thread Dockerfile Outdated
Comment thread deploy.sh Outdated
Comment thread deploy.sh
Comment thread deploy/libs.sh Outdated
Comment thread test/test_deploy_safety.py Outdated
…T cleanup trap, remove unpinned # syntax=, reconcile WORKER_URL, and harden safety tests
@christophervoelpel

Copy link
Copy Markdown
Collaborator Author

Thank you for the empirical cold/warm deploy testing and the thorough review — every single blocking, should-fix, minor, and PR description point has been addressed in commit ed61ed6:

🔴 Blocking Fixes (ed61ed6)

  1. Explicit IAM Propagation Retry Around gcloud builds submit (deploy.sh:951-972):
    • Replaced the single-shot gcloud builds submit with a bounded retry loop (BUILD_MAX_ATTEMPTS=4, 15s / 30s / 45s backoff) that inspects the captured build submission output and retries automatically when gcloud builds submit hits PERMISSION_DENIED / permission_denied / 403 / does not have storage.objects while BUILD_SA's roles/storage.objectUser binding is still propagating on a brand-new project.
  2. trap cleanup EXIT for Background Subshells & Logs (deploy.sh:410-429):
    • Added cleanup() registered with trap cleanup EXIT right after SCRIPT_START (UI_BUILD_PID="", INFRA_SETUP_PID="", WORKER_DEPLOY_PID="", UI_BUILD_LOG="", INFRA_SETUP_LOG="", WORKER_ERR_FILE="" initialized upfront so set -u never fails on pre-flight aborts).
    • Reaps child/grandchild processes via pkill -P "$pid" + kill -- "-${pid}" || kill "$pid", dumps any non-empty background log files to stderr with --- background log: $log ---, and unlinks all temp files on abort. Cleared each variable after normal wait completion so happy-path runs do not double-print logs.
    • Added runtime execution test test_deploy_cleanup_trap_dumps_logs_and_unlinks_on_abort in test/test_deploy_safety.py.
  3. Removed Unpinned # syntax=docker/dockerfile:1 (Dockerfile:1):
    • Deleted line 1 from Dockerfile so BuildKit uses its built-in frontend under DOCKER_BUILDKIT=1 without pulling docker.io/docker/dockerfile:1 from Docker Hub.

🟠 Should-Fix & 🟡 Minor Fixes (ed61ed6)

  1. Non-Vacuous Dockerfile Security Test (test/test_deploy_safety.py:774-849):
    • Extracted _verify_dockerfile_security_invariants(dockerfile) which checks # syntax= directives for @sha256:, strips # comment lines, joins backslash line continuations, parses FROM ... AS ... and COPY --from=... with re.IGNORECASE, and asserts both --require-hashes and --only-binary :all: directly on the RUN ... uv pip install instruction.
    • Added 4 self-verifying mutation assertions (with pytest.raises(AssertionError)) inside test_dockerfile_external_images_are_digest_pinned_and_hash_verified covering all 3 mutations from the review + unpinned # syntax=.
  2. Non-Poisoning IAM Policy Snapshot Cache (deploy/libs.sh:202-218) + Runtime Test:
    • Only sets _CACHED_IAM_PROJECT="$project" when gcloud projects get-iam-policy succeeds (leaving _CACHED_IAM_PROJECT="" on transient failure instead of caching __UNAVAILABLE__ for the rest of the run).
    • Added runtime execution test test_add_iam_binding_caches_policy_and_recovers_after_fetch_error in test/test_deploy_safety.py.
  3. Chronological Phase Timing (deploy.sh:704-828):
    • Replaced the 5 phase "..." calls inside the background INFRA_SETUP_PID subshell with echo "[>] ..." so the background subshell never mutates the parent's CURRENT_PHASE_NAME or prints out-of-order [t] phase timestamps when its log is printed after Cloud Build.
  4. Canonical WORKER_URL Reconciliation Across Deploys (deploy.sh:1041-1046):
    • After wait "$WORKER_DEPLOY_PID", reads ACTUAL_WORKER_URL (status.url) and if WORKER_URL != ACTUAL_WORKER_URL (on the initial cold deploy), updates app's WORKER_URL env var to ACTUAL_WORKER_URL immediately so subsequent warm redeploys see the exact same status.url and never cut a spurious app-00002 revision.
  5. BUILD_MACHINE_ARGS Keyed Off Image Existence (deploy.sh:925-928) & --app-only Phase Header (deploy.sh:994-998):
    • Keyed BUILD_MACHINE_ARGS=(--machine-type=e2-highcpu-8) off ! gcloud artifacts docker images describe "${IMAGE}:latest" (in addition to --no-build-cache), so a retried cold deploy after ARTIFACT_REPO creation still uses e2-highcpu-8.
    • Fixed the --app-only phase message to print Deploying 'app' Cloud Run service instead of Deploying 'worker' and 'app' Cloud Run services in parallel.
  6. PR Description Updates:
    • Updated the PR description to remove the self-attestation line, accurately state 68 passed (65 pre-existing + 3 new tests added in this PR), call out --only-binary :all: eliminating setup.py sdist execution at build time, and accurately describe sequential project-level IAM writes and post-write snapshot updates.

@christophervoelpel

Copy link
Copy Markdown
Collaborator Author

Re-review of ed61ed6: live verification (2 cold + 2 warm deploys) & residual findings

Thanks for the fast turnaround in ed61ed6. I re-reviewed the full branch (origin/main...ed61ed6) and the 5907cff..ed61ed6 delta against all 11 items and 5 inline comments from my previous review, then ran 4 more live deployments (2 cold + 2 warm, us-central1, AUTH_MODE=iap) into two fresh Cloudwerk projects and deleted both projects afterward:

  • P1: sm206r2-tts56460tt9kqo4kjvi32h (projects/553835241540) — DELETE_REQUESTED
  • P2: sm206r2-mkb0d8oq6lobipprejnjib (projects/289773784132) — DELETE_REQUESTED
Run Project Result Automated Provisioning Time Cloud Run Revisions After Run
Cold #1 P1 ✅ EXIT=0 326 s (0h 05m 26s) worker-00001-kqq, app-00001-qw4 $\rightarrow$ app-00002-57j
Cold #2 (attempt 1) P2 ❌ EXIT=1 (transient ECPProxyError on gcloud storage buckets create in INFRA_SETUP_PID) aborted at 4m 27s cleanup() trap worked: dumped --- background log: /tmp/tmp.XKrPvXOz87 --- to stderr and unlinked it
Cold #2 (attempt 2) P2 ✅ EXIT=0 195 s (0h 03m 15s) worker-00001-q9q, app-00001 $\rightarrow$ app-00002-wvg
Warm #1 P1 ✅ EXIT=0 152 s (0h 02m 32s) worker-00002-kcc, app-00003-ck6 (single app rollout; no WORKER_URL churn)
Warm #2 P2 ✅ EXIT=0 147 s (0h 02m 27s) worker-00002-79n, app-00003-rbq (single app rollout; no WORKER_URL churn)

✅ What ed61ed6 resolved from my prior review

All 3 blocking items (#1, #2, #3), all 3 should-fix items (#4, #5, #6), all 5 minor items (#7–#11), all 5 inline comments, and the PR description inaccuracies ("68 passed (65 pre-existing + 3 new)", removal of the AI self-attestation line, and accurate description of IAM snapshot append semantics) were addressed:

  1. Adding the image_converter action. #1 (Cloud Build IAM propagation race): Fixed via the 4-attempt retry loop (deploy.sh:961-982). Both P1 and P2 cold Cloud Builds succeeded (1m 10s and 1m 18s).
  2. Migrate non-application files #2 (trap cleanup EXIT): Fixed at deploy.sh:410-429. When Cold Migrate non-application files #2 attempt 1 hit an internal gcloud proxy error creating the GCS bucket inside INFRA_SETUP_PID, the new EXIT trap caught it, printed --- background log: /tmp/tmp.XKrPvXOz87 --- with the exact failure traceback to stderr, and deleted the temp file.
  3. Add Front-end / editor configuration files. #3 (Unpinned # syntax=docker/dockerfile:1): Removed from Dockerfile:1, and _verify_dockerfile_security_invariants now enforces @sha256: if # syntax= is ever re-added.
  4. Bump hono from 4.12.9 to 4.12.12 in /ui #4 (Safety test mutation score): _verify_dockerfile_security_invariants now strips comments, normalizes line continuations, uses re.IGNORECASE, asserts both --require-hashes and --only-binary :all: on the RUN ... uv pip install command, and runs 4 self-verifying mutation assertions.
  5. Bump @hono/node-server from 1.19.11 to 1.19.14 in /ui #5 (_CACHED_PROJECT_IAM_POLICY error recovery): Fixed in deploy/libs.sh:221-249 (no longer caches __UNAVAILABLE__ on get-iam-policy failure) and tested via test_add_iam_binding_caches_policy_and_recovers_after_fetch_error.
  6. Bump lodash from 4.17.23 to 4.18.1 in /ui #6 (phase timing inversion): Fixed by replacing phase calls inside INFRA_SETUP_PID with echo "[>] ...". All 4 deploy transcripts now have strictly monotonic timestamps.
  7. Add base front-end files. #7–Bump pillow from 12.1.1 to 12.2.0 #11 & PR description: BUILD_MACHINE_ARGS now keys off --no-build-cache or missing ${IMAGE}:latest (deploy.sh:945), --app-only prints the right phase header (deploy.sh:1023), and warm redeploys no longer churn WORKER_URL.

🟠 3 small follow-ups worth tightening in ed61ed6

1. Unanchored 403 in BUILD_SUBMIT_LOG retry regex (deploy.sh:973) + BUILD_SUBMIT_LOG missing from cleanup() (deploy.sh:420)

deploy.sh:965-973 tees the entire gcloud builds submit stdout/stderr stream (including Docker BuildKit layer progress and 64-hex-char sha256:... digests) into BUILD_SUBMIT_LOG and checks:

grep -qiE 'PERMISSION_DENIED|permission_denied|403|does not have storage\.objects' "$BUILD_SUBMIT_LOG"

Because 403 is unanchored, any hexadecimal SHA256 digest, tarball size, or build log line containing the 3 digits 403 (across ~12 layer digests, $1 - (15/16)^{62} \approx 98%$ probability per build) will match 403. If gcloud builds submit fails for a legitimate non-IAM reason (e.g. a Dockerfile syntax error or a hash mismatch in requirements.txt), the loop will misclassify it as an IAM propagation delay and burn 3 extra retries (15s + 30s + 45s sleep + 3 full Cloud Builds).

Also, BUILD_SUBMIT_LOG=$(mktemp) at deploy.sh:965 is not registered in cleanup() (deploy.sh:420), so hitting Ctrl-C during gcloud builds submit leaves BUILD_SUBMIT_LOG in /tmp/.

Suggested fix (deploy.sh):

  • Add BUILD_SUBMIT_LOG="" at line 411 and include "${BUILD_SUBMIT_LOG:-}" in cleanup()'s for log in ... loop (setting BUILD_SUBMIT_LOG="" after each rm -f "$BUILD_SUBMIT_LOG").
  • Replace bare 403 in the regex with HTTPError 403|HTTP[[:space:]/:]+403|status[[:space:]:=]+403 (or drop 403 altogether, since gcloud builds submit bucket ACL failures always emit PERMISSION_DENIED / permission_denied / does not have storage.objects).

2. Cold deploy cuts two consecutive app revisions (app-00001 $\rightarrow$ app-00002) due to WORKER_URL reconciliation (deploy.sh:1008, 1075-1080)

On both of my cold deploys (P1 cold1.log:304-313 and P2 warm2.log), app deployed twice in a row:

  1. deploy.sh:1008 set WORKER_URL="https://worker-${PROJECT_NUMBER}.${REGION}.run.app" (deterministic regional URL) because worker did not exist yet, and deployed app-00001-qw4.
  2. After wait "$WORKER_DEPLOY_PID", deploy.sh:1075 fetched ACTUAL_WORKER_URL via status.url, which returns the legacy https://worker-ccwtwnrw7q-uc.a.run.app form.
  3. Because the two strings always differ on a fresh project, line 1078 immediately ran gcloud run services update app --update-env-vars="WORKER_URL=${WORKER_URL}", cutting app-00002-57j synchronously (~15 s penalty on every cold deploy).

Since Cloud Run serves the deterministic https://worker-${PROJECT_NUMBER}.${REGION}.run.app URL on both cold and warm deploys (and lists both URLs in metadata.annotations."run.googleapis.com/urls"), you can avoid the second cold-deploy app rollout completely while staying 100% idempotent on warm deploys by always using WORKER_URL="https://worker-${PROJECT_NUMBER}.${REGION}.run.app" (or by checking whether WORKER_URL is already present in worker's run.googleapis.com/urls annotation before calling gcloud run services update app).


3. _verify_dockerfile_security_invariants FROM regex vs. --platform= flags + 2 lines $> 80$ cols (test/test_deploy_safety.py:799, 827, 903)

  1. r"^\s*FROM\s+(\S+)" (test/test_deploy_safety.py:799) captures the first non-whitespace token after FROM. If a future Dockerfile change adds FROM --platform=linux/amd64 image@sha256:..., from_refs captures --platform=linux/amd64 (breaking valid multi-arch syntax), while FROM --platform=linux/amd64@sha256:0000 unpinned:latest satisfies assert "@sha256:" in ref without inspecting unpinned:latest. Skipping optional --flag=... tokens fixes both:
    raw_from_matches = re.findall(
        r"^\s*FROM\s+(?:--\S+\s+)*(\S+)(?:\s+AS\s+(\S+))?",
        normalized,
        re.MULTILINE | re.IGNORECASE,
    )
    (Same for COPY --from=: r"COPY\s+(?:--(?!from=)\S+\s+)*--from=(\S+)".)
  2. Line length (pyproject.toml [tool.pyink] line-length = 80):
    • test/test_deploy_safety.py:827 is 82 chars ("""Every external image in Dockerfile must be @sha256-pinned and uv verified.""")
    • test/test_deploy_safety.py:903 is 81 chars ("""The EXIT trap in deploy.sh dumps background logs and reaps jobs on abort.""")
      Wrapping both docstrings will keep pyink / gps-readability-bot happy.
  3. Minor note on cleanup() grandchild reaping (deploy.sh:417): In non-interactive bash (set +m), background subshells share the parent's PGID, so kill -- "-${pid}" is a no-op (ESRCH) and pkill -P "$pid" only signals depth-1 children (( cd ui && npm ci )), leaving depth-2+ children (npm/ng) alive if the script aborts during UI_BUILD_PID. A tiny recursive helper (kill_tree() { local p=$1 c; for c in $(pgrep -P "$p" 2>/dev/null || true); do kill_tree "$c"; done; kill "$p" 2>/dev/null || true; }) reaps the full subtree cleanly.

@christophervoelpel christophervoelpel left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline follow-up notes on ed61ed6 for the 3 points in my summary comment above.

Comment thread deploy.sh Outdated
Comment thread deploy.sh Outdated
Comment thread test/test_deploy_safety.py Outdated
…cold/warm deploys, and skip --platform= in FROM regex
@christophervoelpel

Copy link
Copy Markdown
Collaborator Author

Thanks for the live 4-deploy verification on ed61ed6! All 3 follow-ups from your re-review have been pushed in commit a9aeb77:

  1. Anchored HTTP 403 in BUILD_SUBMIT_LOG Retry Regex + BUILD_SUBMIT_LOG & Recursive kill_tree() in cleanup() (deploy.sh:411-435, 975-990):
    • Replaced bare 403 with HTTPError 403|HTTP[[:space:]/:]+403|status[[:space:]:=]+403 so SHA256 layer digests containing 403 never falsely trigger retries.
    • Added BUILD_SUBMIT_LOG="" to cleanup() ([ -n "${BUILD_SUBMIT_LOG:-}" ] && rm -f "$BUILD_SUBMIT_LOG" || true) and added a recursive kill_tree() helper so depth-2+ grandchild processes (npm/ng) are reaped on abort.
  2. Single app Revision on Both Cold and Warm Deploys (deploy.sh:1002-1018, 1080):
    • Set WORKER_URL="https://worker-${PROJECT_NUMBER}.${REGION}.run.app" consistently on both cold and warm deploys (since Cloud Run serves the deterministic regional URL across all deploys and lists it in metadata.annotations."run.googleapis.com/urls"), removing the post-deploy gcloud run services update app step so cold deploys only roll out app-00001 once and warm redeploys stay 100% idempotent.
  3. FROM / COPY --from= --platform= Flag Handling + <= 80 Column Formatting (test/test_deploy_safety.py:791-855, 898):
    • Updated _verify_dockerfile_security_invariants to skip optional --\S+ flags on FROM (r"^\s*FROM\s+(?:--\S+\s+)*(\S+)(?:\s+AS\s+(\S+))?") and COPY --from= (r"COPY\s+(?:--(?!from=)\S+\s+)*--from=(\S+)"), added a 5th self-verifying mutation assertion (FROM --platform=linux/amd64@sha256:0000 unpinned:latest AS final), and wrapped all added lines to <= 80 columns.

@christophervoelpel christophervoelpel changed the title Speed up deploy.sh via parallel BuildKit Dockerfile, overlapped infra setup, IAM policy snapshot, and parallel Cloud Run rollout Speed up deploys with cached builds and worker-first rollout Sep 23, 2026
@christophervoelpel

Copy link
Copy Markdown
Collaborator Author

Re-review of 4cd63be: live verification on 3 fresh projects

Thanks for the thorough round. I re-reviewed 4cd63be against main (1ef6fd8) and the previous head (a9aeb77) in two parts:

  • Offline: control flow and failure paths, shell portability and signals, the build and digest path, security and supply chain, tests (including 12 single-line mutations), cross-file seams, and the PR text.
  • Live: 41 deploy runs on three fresh projects: 40 under macOS /bin/bash 3.2 and one under bash 5.3. Two of the 41 were invalid first attempts of harness steps, which I fixed and reran. All test projects are deleted again.

Verdict: every finding from the previous round is fixed, and I found nothing blocking. Notes 1 and 2 below are one-liners worth doing before merge; 3 to 5 are optional. CI is green on 4cd63be.

Speed (same region, same time window, PR and main side by side)

Case This PR main
Cold deploy, fresh project 316 s, 309 s 409 s
Warm redeploy, no code change 81 to 137 s (5 runs, 3 projects) 122 s, 197 s
Warm redeploy, Python change 88 s, 101 s, 114 s 129 s, 137 s
Warm redeploy, UI change 145 s (81 s with --app-only) 153 s
First PR deploy on a main-built project 154 s (full image rebuild, expected once)
--no-build-cache, then a Python change 179 s, then 88 s (cache import works after a clean build)

Every build ran on the default pool with 0 to 2 s of queue time, except one 20 s wait on a main build. The worker deploy step varied from 12 to 78 s on both branches (on the same project: PR 14 to 25 s, main 12 to 46 s), so differences under about 30 s are Cloud Run noise, not the code.

Behaviour verified live

Check Result
Image pinning On every successful run, worker and app ran exactly the digest from that run's own build receipt. With --app-only, only the app moved, as intended.
Worker deploy fails (warm) Exit 1, app not touched.
Worker deploy fails on a first deploy (both services deleted first) Exit 1, neither service created, settings unchanged. The next normal run created both.
App deploy fails (warm) Exit 1, worker on a new revision, app on the previous one (the documented trade-off).
Seeding order I added a marker field to one creative template before the fault runs. After a worker, app or build failure the stored template still lacked it; the next successful deploy wrote it. Without a content change, Firestore keeps updateTime on identical writes, so this marker is what makes the check meaningful.
Build failure / one transient permission error Exit 1 before any service change / one retry, then success with the correct digest.
Seed request fails after both deploys Exit 1 after both services updated (the documented trade-off).
Ctrl-C during the build, Ctrl-C during the background UI and infra jobs, SIGTERM early (run under a real pseudo-terminal) Exit 130, 130 and 143. No process was left running 20 s later. After a Ctrl-C during the build, the remote Cloud Build still finishes and pushes :latest; that is harmless now that services deploy the pinned digest.
Security posture Project IAM, service accounts, service-level run.invoker, IAP, ingress, bucket IAM and anonymous access (app 502 at IAP, worker 403) are identical to main once project ids are normalized.
Tests 754 passed plus 27 subtests; 76 deploy-safety tests passed. Of 12 single mutations, the deploy-safety tests caught 8 (see note 4).

Notes (none blocking; details inline)

  1. Background jobs and stdin. Under set -m, the UI and infra jobs no longer get /dev/null as stdin. Nothing reads stdin today, but adding </dev/null to both jobs removes a silent-hang trap for future edits. (deploy.sh:484, deploy.sh:861)
  2. Formatting of deploy/resolve_build_image.py. pyink with the repo config would reformat it (lines over 80 columns). Docstrings and dict[str, Any] would help readability review.
  3. Build id taken from gcloud's text output. It works on gcloud 568.0.0 but is not a documented format. A comment naming the verified version and a clearer error would make a future break self-explaining.
  4. Test gaps found by mutation. Not caught: the worker deploying $IMAGE (:latest) instead of $DEPLOY_IMAGE, the resolver accepting two Created [...] lines, and re.fullmatch loosened to re.match for the digest. The IAP flag check is also untested, as it was before this PR.
  5. Build output shown only at the end. main streamed it live; now the direct build link appears after the build finishes. Optional.

Pre-existing and out of scope for this PR: the ADC token is passed as a curl -H argument (visible to other local users in ps); --app-only checks for the worker only after building the image; the Firestore TTL check still uses grep -q (not reproducible at that command's output size).

Earlier conversations

All eight earlier review threads on this PR are fixed or no longer apply at 4cd63be, so I have resolved them.

@christophervoelpel christophervoelpel left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline notes for the re-review comment above (none blocking).

Comment thread deploy.sh Outdated
Comment thread deploy/resolve_build_image.py
Comment thread test/test_deploy_safety.py
Comment thread test/test_deploy_safety.py
Comment thread deploy.sh
@christophervoelpel

Copy link
Copy Markdown
Collaborator Author

Follow-up to the latest review: commit 033808b addresses all five open notes on the prior head.

  • The UI and infrastructure background jobs now explicitly read from /dev/null under Bash job control, avoiding a future terminal-read stop.
  • The build-image helper is Pyink formatted, typed and documented. Its error identifies the verified gcloud output format, fails closed if the build ID is ambiguous, and recommends a safe rerun; manual recovery requires confirming all interrupted infrastructure steps and the exact successful build digest before following the ordered rollout.
  • The worker failure test now inspects the recorded --image argument and rejects a mutable :latest image. Separate cases reject duplicate Created [...] lines and digests with extra suffixes. All three reported mutations were replayed in isolated copies and caused their targeted tests to fail.
  • The Cloud Build log link is printed while the build is running, while the complete output is still replayed afterward. The existing process-tree cleanup test and a new early-link test pass.

Verification on this head: locally, 755 Python tests and 27 subtests passed, including 77 deployment-safety tests; Bash syntax, ShellCheck, Pyink on the new helper, and whitespace checks passed. A real PTY stand-in verified the background stdin redirect under macOS Bash. At the time of this comment, the new-head GitHub Actions jobs are still queued without a runner; only CLA has passed, so I am not claiming application CI is green. No new cloud deployment was run for this follow-up; the earlier live deployment findings and their version boundaries are in the PR description.

I resolved the five review threads after verifying the fixes locally and independently. No review threads remain open. The code is ready for Victor's review; updated-head CI still needs to finish.

Comment thread deploy.sh
Comment on lines +493 to +504
if [ "$SKIP_UI_BUILD" != "1" ]; then
UI_BUILD_LOG=$(mktemp)
# A separate job-control process group lets EXIT cleanup stop npm/ng children.
set -m
(
export NG_CLI_ANALYTICS=ci
( cd ui && npm ci )
( cd ui && npx ng build --configuration production )
) </dev/null >"$UI_BUILD_LOG" 2>&1 &
UI_BUILD_PID=$!
set +m
fi

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving if [ "$SKIP_UI_BUILD" != "1" ]; then up to SCRIPT_START (lines 493–504) while leaving the SKIP_UI_BUILD = 1 validation ([ ! -d ui/dist ] and grep -rqs 'controlPlaneMode:"none"' ui/dist) at lines 888–902 means that if --skip-ui-build is passed with a missing or local-dev ui/dist, the script runs API enablement and IAM setup, launches INFRA_SETUP_PID in the background at line 879, and then immediately exits at line 892/901—causing cleanup() to send SIGTERM/SIGKILL to INFRA_SETUP_PID mid-flight while it is creating or updating Cloud Tasks queues, GCS buckets, or Firestore databases. Validating ui/dist right here at SCRIPT_START fails fast before any cloud mutations or background jobs begin.

Suggested change
if [ "$SKIP_UI_BUILD" != "1" ]; then
UI_BUILD_LOG=$(mktemp)
# A separate job-control process group lets EXIT cleanup stop npm/ng children.
set -m
(
export NG_CLI_ANALYTICS=ci
( cd ui && npm ci )
( cd ui && npx ng build --configuration production )
) </dev/null >"$UI_BUILD_LOG" 2>&1 &
UI_BUILD_PID=$!
set +m
fi
if [ "$SKIP_UI_BUILD" != "1" ]; then
UI_BUILD_LOG=$(mktemp)
# A separate job-control process group lets EXIT cleanup stop npm/ng children.
set -m
(
export NG_CLI_ANALYTICS=ci
( cd ui && npm ci )
( cd ui && npx ng build --configuration production )
) </dev/null >"$UI_BUILD_LOG" 2>&1 &
UI_BUILD_PID=$!
set +m
elif [ ! -d ui/dist ]; then
echo "ERROR: --skip-ui-build given but ui/dist does not exist." >&2
echo " Run a normal deploy once (or 'cd ui && npx ng build') first." >&2
exit 1
elif grep -rqs 'controlPlaneMode:"none"' ui/dist || grep -rqs "controlPlaneMode:'none'" ui/dist; then
echo "ERROR: the existing ui/dist was built for local dev (controlPlaneMode 'none'," >&2
echo " sign-in disabled). Refusing to deploy it. Drop --skip-ui-build and run a" >&2
echo " normal deploy to rebuild the UI first." >&2
exit 1
fi

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed and applied. When --skip-ui-build is passed, deploy.sh now validates ui/dist existence and checks for controlPlaneMode:"none" / controlPlaneMode:'none' right at SCRIPT_START (deploy.sh:504-512), failing fast before any cloud API enablement, IAM changes, or background INFRA_SETUP_PID jobs begin.

Added test_skip_ui_build_fails_fast_and_build_id_failure_clears_log in test/test_deploy_safety.py, which executes the SCRIPT_START block under bash -c (set -euo pipefail) and verifies missing ui/dist and local-dev ui/dist both exit 1 immediately. Mutation-tested against pre-fix deploy.sh (fails 1/1, passes with fix).

Comment thread deploy/libs.sh
Comment on lines 220 to +224
_retry_iam_write "$label" "$propagating_runtime_sa" \
gcloud projects add-iam-policy-binding "$@"
if [ "${_CACHED_IAM_PROJECT:-}" = "$project" ] && [ -n "$role" ] && [ -n "$member" ]; then
_CACHED_PROJECT_IAM_POLICY="${_CACHED_PROJECT_IAM_POLICY}"$'\n'"${role}"$'\t'"${member}"
fi

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When add_iam_binding is invoked in a conditional context (if add_iam_binding ..., add_iam_binding || ...), Bash disables set -e inside add_iam_binding and _retry_iam_write. Because the new if [ "${_CACHED_IAM_PROJECT:-}" = "$project" ] ... block follows _retry_iam_write, a failed _retry_iam_write (return 1) falls through into the if block, appends the ungranted ${role}\t${member} binding into _CACHED_PROJECT_IAM_POLICY (poisoning the cache), and causes add_iam_binding to return 0 instead of 1. Propagating the failure with || return $? preserves both the return code and cache integrity.

Suggested change
_retry_iam_write "$label" "$propagating_runtime_sa" \
gcloud projects add-iam-policy-binding "$@"
if [ "${_CACHED_IAM_PROJECT:-}" = "$project" ] && [ -n "$role" ] && [ -n "$member" ]; then
_CACHED_PROJECT_IAM_POLICY="${_CACHED_PROJECT_IAM_POLICY}"$'\n'"${role}"$'\t'"${member}"
fi
_retry_iam_write "$label" "$propagating_runtime_sa" \
gcloud projects add-iam-policy-binding "$@" || return $?
if [ "${_CACHED_IAM_PROJECT:-}" = "$project" ] && [ -n "$role" ] && [ -n "$member" ]; then
_CACHED_PROJECT_IAM_POLICY="${_CACHED_PROJECT_IAM_POLICY}"$'\n'"${role}"$'\t'"${member}"
fi

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch — in a conditional context (if add_iam_binding ...), Bash disables set -e inside the function, so a failed _retry_iam_write previously fell through, appended the ungranted binding into _CACHED_PROJECT_IAM_POLICY, and returned 0.

Applied || return $? after _retry_iam_write (deploy/libs.sh:221) and extended test_add_iam_binding_caches_policy_and_recovers_after_fetch_error to invoke if add_iam_binding ... against a failing add-iam-policy-binding call, asserting both non-zero return status and that _CACHED_PROJECT_IAM_POLICY is not poisoned. Mutation-tested against pre-fix deploy/libs.sh (fails 1/1, passes with fix).

Comment thread deploy.sh
Comment on lines +966 to +970
if ! BUILD_ID=$(python3 deploy/resolve_build_image.py build-id \
"$PROJECT" "$REGION" "$BUILD_SUBMIT_LOG"); then
echo "ERROR: Cloud Build succeeded but its ID could not be verified; refusing to deploy a mutable image tag." >&2
exit 1
fi

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When run_with_heartbeat returns 0 at line 961, it has already replayed the full $BUILD_SUBMIT_LOG to stdout via cat "$log_file" (deploy.sh:152). If resolve_build_image.py build-id subsequently fails at line 966, calling exit 1 without first unlinking and clearing BUILD_SUBMIT_LOG causes the EXIT trap (cleanup()) to dump the entire Cloud Build log a second time to stderr after the ERROR: message, scrolling the diagnostic message off the screen (unlike lines 977–978 and 984–985, which clear BUILD_SUBMIT_LOG before continuing/exiting).

Suggested change
if ! BUILD_ID=$(python3 deploy/resolve_build_image.py build-id \
"$PROJECT" "$REGION" "$BUILD_SUBMIT_LOG"); then
echo "ERROR: Cloud Build succeeded but its ID could not be verified; refusing to deploy a mutable image tag." >&2
exit 1
fi
if ! BUILD_ID=$(python3 deploy/resolve_build_image.py build-id \
"$PROJECT" "$REGION" "$BUILD_SUBMIT_LOG"); then
rm -f "$BUILD_SUBMIT_LOG"
BUILD_SUBMIT_LOG=""
echo "ERROR: Cloud Build succeeded but its ID could not be verified; refusing to deploy a mutable image tag." >&2
exit 1
fi

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed and applied. BUILD_SUBMIT_LOG is now unlinked and cleared (rm -f "$BUILD_SUBMIT_LOG"; BUILD_SUBMIT_LOG="") before exit 1 when resolve_build_image.py build-id fails (deploy.sh:977-978), preventing cleanup() from dumping the full Cloud Build log a second time after run_with_heartbeat already printed it.

Verified in test_skip_ui_build_fails_fast_and_build_id_failure_clears_log (--- background log: is absent on build-id verification failure; fails when reverted).

Comment on lines +78 to +84
matches = [
entry.get("digest", "") for entry in images if entry.get("name") == image
]
if len(matches) != 1 or not re.fullmatch(r"sha256:[0-9a-f]{64}", matches[0]):
raise ValueError(
"expected exactly one valid digest for the requested image"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a Cloud Build image entry contains "digest": null (or a non-string value), entry.get("digest", "") returns None rather than "", causing re.fullmatch(r"sha256:[0-9a-f]{64}", matches[0]) to raise TypeError: expected string or bytes-like object instead of the descriptive ValueError("expected exactly one valid digest for the requested image"). Checking isinstance(matches[0], str) ensures a consistent ValueError diagnostic.

Suggested change
matches = [
entry.get("digest", "") for entry in images if entry.get("name") == image
]
if len(matches) != 1 or not re.fullmatch(r"sha256:[0-9a-f]{64}", matches[0]):
raise ValueError(
"expected exactly one valid digest for the requested image"
)
matches = [
entry.get("digest", "") for entry in images if entry.get("name") == image
]
if (
len(matches) != 1
or not isinstance(matches[0], str)
or not re.fullmatch(r"sha256:[0-9a-f]{64}", matches[0])
):
raise ValueError(
"expected exactly one valid digest for the requested image"
)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed and applied (deploy/resolve_build_image.py:81-85). digest() now checks isinstance(matches[0], str) before re.fullmatch(...) so "digest": null or non-string values raise ValueError("expected exactly one valid digest for the requested image") instead of TypeError.

Added {'name': image, 'digest': None} and {'name': image, 'digest': 123} cases to test_build_receipt_resolves_only_its_own_successful_image (asserting 'expected string or bytes-like' not in rejected.stderr). Mutation-tested against pre-fix resolve_build_image.py (fails 1/1, passes with fix).

Comment on lines +962 to +983
abort_run = subprocess.run(
[
"bash",
"-c",
f'set -euo pipefail\n{trap_block}\nINFRA_SETUP_LOG="{bg_log}"\n'
'set -m\n'
f'( sleep 30 & echo "$!" > "{child_pid_file}"; wait ) &\n'
'INFRA_SETUP_PID=$!\nset +m\n'
f'while [ ! -f "{child_pid_file}" ]; do sleep 0.01; done\n'
'exit 1\n',
],
capture_output=True,
text=True,
check=False,
)
assert abort_run.returncode == 1
assert "--- background log:" in abort_run.stderr
assert "Seed failed HTTP 404" in abort_run.stderr
assert not bg_log.exists(), "Expected cleanup trap to unlink background log"
assert not _pid_exists(int(child_pid_file.read_text())), (
'Expected cleanup trap to stop the infra job child'
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike deploy.sh () </dev/null >"$INFRA_SETUP_LOG" 2>&1 &), ( sleep 30 & echo "$!" > "{child_pid_file}"; wait ) & inherits subprocess.run's open stdout/stderr pipes. Because subprocess.run(..., capture_output=True) blocks until EOF on both pipes, if cleanup() ever regresses and fails to kill sleep 30, subprocess.run waits 30 seconds for sleep 30 to exit naturally and then passes assert not _pid_exists(...) as a false positive. Redirecting stdio to {bg_log} (matching deploy.sh) and adding timeout=10 ensures a broken cleanup trap fails immediately.

Suggested change
abort_run = subprocess.run(
[
"bash",
"-c",
f'set -euo pipefail\n{trap_block}\nINFRA_SETUP_LOG="{bg_log}"\n'
'set -m\n'
f'( sleep 30 & echo "$!" > "{child_pid_file}"; wait ) &\n'
'INFRA_SETUP_PID=$!\nset +m\n'
f'while [ ! -f "{child_pid_file}" ]; do sleep 0.01; done\n'
'exit 1\n',
],
capture_output=True,
text=True,
check=False,
)
assert abort_run.returncode == 1
assert "--- background log:" in abort_run.stderr
assert "Seed failed HTTP 404" in abort_run.stderr
assert not bg_log.exists(), "Expected cleanup trap to unlink background log"
assert not _pid_exists(int(child_pid_file.read_text())), (
'Expected cleanup trap to stop the infra job child'
)
abort_run = subprocess.run(
[
"bash",
"-c",
f'set -euo pipefail\n{trap_block}\nINFRA_SETUP_LOG="{bg_log}"\n'
'set -m\n'
f'( sleep 30 & echo "$!" > "{child_pid_file}"; wait ) </dev/null >>"{bg_log}" 2>&1 &\n'
'INFRA_SETUP_PID=$!\nset +m\n'
f'while [ ! -s "{child_pid_file}" ]; do sleep 0.01; done\n'
'exit 1\n',
],
capture_output=True,
text=True,
check=False,
timeout=10,
)
assert abort_run.returncode == 1
assert "--- background log:" in abort_run.stderr
assert "Seed failed HTTP 404" in abort_run.stderr
assert not bg_log.exists(), "Expected cleanup trap to unlink background log"
assert not _pid_exists(int(child_pid_file.read_text())), (
'Expected cleanup trap to stop the infra job child'
)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed and applied (test/test_deploy_safety.py:983-992). Redirecting background job stdio (</dev/null >>"{bg_log}" 2>&1 &), polling [ ! -s "{child_pid_file}" ], and adding timeout=10 ensures subprocess.run(..., capture_output=True) does not block on inherited pipe descriptors if cleanup() fails to kill sleep 30.

Mutation-verified by disabling kill -TERM / kill -KILL in cleanup(): the test now fails in 1.17s on assert not _pid_exists(...) instead of waiting 30s for sleep 30 to exit and passing vacuously.

…build receipt checks

- Validate ui/dist existence and reject local-dev controlPlaneMode:'none'
  builds at SCRIPT_START when --skip-ui-build is set, before any cloud API
  enablement, IAM updates, or background INFRA_SETUP_PID jobs start.
- Propagate _retry_iam_write failures with `|| return $?` in add_iam_binding
  so conditional callers receive the non-zero status and do not poison
  _CACHED_PROJECT_IAM_POLICY with ungranted bindings.
- Unlink and clear BUILD_SUBMIT_LOG before exiting when
  resolve_build_image.py build-id fails so cleanup() does not dump the
  Cloud Build log a second time.
- Check isinstance(matches[0], str) in resolve_build_image.digest() so a
  null or non-string digest raises ValueError instead of TypeError.
- Redirect background job stdio and add timeout=10 in
  test_deploy_cleanup_trap_dumps_logs_and_unlinks_on_abort, plus add
  runtime and mutation-verified tests for each fix.

Addresses review feedback from victor-paunescu on #206.
@christophervoelpel
christophervoelpel merged commit 9f3cbb9 into main Sep 25, 2026
13 checks passed
@christophervoelpel
christophervoelpel deleted the perf/deploy-speedup branch September 25, 2026 11:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants