Repository navigation
Speed up deploys with cached builds and worker-first rollout - #206
Conversation
…and parallel Cloud Run rollout
|
Still need readability approvals from:
|
…ace worker stderr
|
Readability review is not required. |
… and overlap infra/Firestore with Cloud Build
…fe-io, uv, and E2_HIGHCPU_8
…ool on cached warm builds
|
Still need readability approvals from:
|
Review: security + deployment stabilityI reviewed this against 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):
🔴 Blocking1. You deleted the IAM propagation buffer in front of
|
| 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 EXITThe 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:1cloudbuild.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 (and429exposure) 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
FROMandCOPY --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:
assert "--require-hashes" in dockerfileis a substring check over the whole file — a comment satisfies it.--only-binary :all:is in the test's name but never in its assertions.^FROM\s+\S+\s+AS\s+(\S+)is case-sensitive, and Docker accepts lowercasefrom/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-policyfails,__UNAVAILABLE__is cached under_CACHED_IAM_PROJECTfor the whole run, so all 10 checks degrade to unconditional writes for the rest of the deploy; add_run_invoker_bindingnow 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 arbitrarysetup.pyexecution at build time. I checkedrequirements.txtand 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/uvfrom the final image is the right call. - The
cmp -slockfile stamp is correct: the stamp is written afternpm ciand beforeng build, so a failedng builddoesn't poison it. wait "$UI_BUILD_PID"genuinely does gategcloud builds submit, so there's no tar-while-writing race onui/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 fabricatedWORKER_URL, or the IAM cache. - "Reviewed by
proSecurity & 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
left a comment
There was a problem hiding this comment.
Inline notes to accompany my review comment above — the five spots I'd actually change.
…T cleanup trap, remove unpinned # syntax=, reconcile WORKER_URL, and harden safety tests
|
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 🔴 Blocking Fixes (
|
Re-review of
|
| 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 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 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:
- 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 10sand1m 18s). - Migrate non-application files #2 (
trap cleanup EXIT): Fixed atdeploy.sh:410-429. When Cold Migrate non-application files #2 attempt 1 hit an internalgcloudproxy error creating the GCS bucket insideINFRA_SETUP_PID, the newEXITtrap caught it, printed--- background log: /tmp/tmp.XKrPvXOz87 ---with the exact failure traceback to stderr, and deleted the temp file. - Add Front-end / editor configuration files. #3 (Unpinned
# syntax=docker/dockerfile:1): Removed fromDockerfile:1, and_verify_dockerfile_security_invariantsnow enforces@sha256:if# syntax=is ever re-added. - Bump hono from 4.12.9 to 4.12.12 in /ui #4 (Safety test mutation score):
_verify_dockerfile_security_invariantsnow strips comments, normalizes line continuations, usesre.IGNORECASE, asserts both--require-hashesand--only-binary :all:on theRUN ... uv pip installcommand, and runs 4 self-verifying mutation assertions. - Bump @hono/node-server from 1.19.11 to 1.19.14 in /ui #5 (
_CACHED_PROJECT_IAM_POLICYerror recovery): Fixed indeploy/libs.sh:221-249(no longer caches__UNAVAILABLE__onget-iam-policyfailure) and tested viatest_add_iam_binding_caches_policy_and_recovers_after_fetch_error. - Bump lodash from 4.17.23 to 4.18.1 in /ui #6 (
phasetiming inversion): Fixed by replacingphasecalls insideINFRA_SETUP_PIDwithecho "[>] ...". All 4 deploy transcripts now have strictly monotonic timestamps. - Add base front-end files. #7–Bump pillow from 12.1.1 to 12.2.0 #11 & PR description:
BUILD_MACHINE_ARGSnow keys off--no-build-cacheor missing${IMAGE}:latest(deploy.sh:945),--app-onlyprints the right phase header (deploy.sh:1023), and warm redeploys no longer churnWORKER_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, 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:-}"incleanup()'sfor log in ...loop (settingBUILD_SUBMIT_LOG=""after eachrm -f "$BUILD_SUBMIT_LOG"). - Replace bare
403in the regex withHTTPError 403|HTTP[[:space:]/:]+403|status[[:space:]:=]+403(or drop403altogether, sincegcloud builds submitbucket ACL failures always emitPERMISSION_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:
-
deploy.sh:1008setWORKER_URL="https://worker-${PROJECT_NUMBER}.${REGION}.run.app"(deterministic regional URL) becauseworkerdid not exist yet, and deployedapp-00001-qw4. - After
wait "$WORKER_DEPLOY_PID",deploy.sh:1075fetchedACTUAL_WORKER_URLviastatus.url, which returns the legacyhttps://worker-ccwtwnrw7q-uc.a.run.appform. - 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}", cuttingapp-00002-57jsynchronously (~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)
-
r"^\s*FROM\s+(\S+)"(test/test_deploy_safety.py:799) captures the first non-whitespace token afterFROM. If a futureDockerfilechange addsFROM --platform=linux/amd64 image@sha256:...,from_refscaptures--platform=linux/amd64(breaking valid multi-arch syntax), whileFROM --platform=linux/amd64@sha256:0000 unpinned:latestsatisfiesassert "@sha256:" in refwithout inspectingunpinned:latest. Skipping optional--flag=...tokens fixes both:(Same forraw_from_matches = re.findall( r"^\s*FROM\s+(?:--\S+\s+)*(\S+)(?:\s+AS\s+(\S+))?", normalized, re.MULTILINE | re.IGNORECASE, )
COPY --from=:r"COPY\s+(?:--(?!from=)\S+\s+)*--from=(\S+)".) -
Line length (
pyproject.toml[tool.pyink] line-length = 80):-
test/test_deploy_safety.py:827is 82 chars ("""Every external image in Dockerfile must be @sha256-pinned and uv verified.""") -
test/test_deploy_safety.py:903is 81 chars ("""The EXIT trap in deploy.sh dumps background logs and reaps jobs on abort.""")
Wrapping both docstrings will keeppyink/gps-readability-bothappy.
-
-
Minor note on
cleanup()grandchild reaping (deploy.sh:417): In non-interactive bash (set +m), background subshells share the parent's PGID, sokill -- "-${pid}"is a no-op (ESRCH) andpkill -P "$pid"only signals depth-1 children (( cd ui && npm ci )), leaving depth-2+ children (npm/ng) alive if the script aborts duringUI_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
left a comment
There was a problem hiding this comment.
Inline follow-up notes on ed61ed6 for the 3 points in my summary comment above.
…cold/warm deploys, and skip --platform= in FROM regex
|
Thanks for the live 4-deploy verification on
|
Re-review of
|
| 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)
- Background jobs and stdin. Under
set -m, the UI and infra jobs no longer get/dev/nullas stdin. Nothing reads stdin today, but adding</dev/nullto both jobs removes a silent-hang trap for future edits. (deploy.sh:484, deploy.sh:861) - Formatting of
deploy/resolve_build_image.py.pyinkwith the repo config would reformat it (lines over 80 columns). Docstrings anddict[str, Any]would help readability review. - 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.
- Test gaps found by mutation. Not caught: the worker deploying
$IMAGE(:latest) instead of$DEPLOY_IMAGE, the resolver accepting twoCreated [...]lines, andre.fullmatchloosened tore.matchfor the digest. The IAP flag check is also untested, as it was before this PR. - Build output shown only at the end.
mainstreamed 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
left a comment
There was a problem hiding this comment.
Inline notes for the re-review comment above (none blocking).
|
Follow-up to the latest review: commit
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. |
| 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 |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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).
| _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 |
There was a problem hiding this comment.
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.
| _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 |
There was a problem hiding this comment.
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).
| 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 |
There was a problem hiding this comment.
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).
| 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 |
There was a problem hiding this comment.
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).
| 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" | ||
| ) |
There was a problem hiding this comment.
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.
| 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" | |
| ) |
There was a problem hiding this comment.
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).
| 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' | ||
| ) |
There was a problem hiding this comment.
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.
| 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' | |
| ) |
There was a problem hiding this comment.
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.
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-8on every run and could crash under macOS Bash 3.2 when corrected.The BuildKit Dockerfile and layer cache remain. A forced
--no-build-cacherun 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:latestpush cannot change either image during this rollout. The UI build always runsnpm 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-central1were 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.--no-cachebranch, verified in the remote build receiptThe 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 onemaincold 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.invokerfor the IAP agent; both workers were private with service-levelrun.invokerfor 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 onmain; 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.