The shape every Versable MVP should use: a manual path for development, an
automatic path for shipping, and a kit release that nobody performs by hand.
Written from walmart-mvp, which is the reference implementation. Speedway
predates it and uses scripts/release.sh.
Audience: an agent (or person) setting up or debugging a deploy.
The organising idea is that credentials rot and humans forget, so anything routine has to work without either. Local gcloud auth on this account expires roughly daily, which is enough to block a deploy at exactly the wrong moment.
Branches map to environments#
push to main ──▶ preview/sandbox (every push)merge to release ──▶ productiontag deploy-prod-* ──▶ production (pin a commit, or hotfix)Give the team's existing reflex somewhere safe to land. Before this mapping, pushing to main and then running the deploy script by hand was the habit, and production moved whenever someone felt like it. Previewing main to a sandbox stack means that reflex now produces something useful, and promotion becomes a merge that is visible in the git graph.
Production should never move as a side effect of merging a pull request. A
release branch makes the promotion an act someone performs and someone reviews.
| Path | When | Needs |
|---|---|---|
./deploy.sh [target] | developing, or the triggers are down | live gcloud auth, local Docker |
| push to main | previewing | git only |
| merge to release | shipping | git only |
| kit publish workflow | releasing the UI kit | a version bump and a push |
Give every trigger its own verify URL rather than skipping the check for non-production. A preview that deploys unverified is a preview you cannot trust.
Do not let two environments share one image tag. Both targets originally
pushed walmart-app:latest, so a preview build overwrote the artifact a
production deploy would reuse with --skip-build. Tag images by commit instead:
the artifact becomes immutable, and a rollback names a real thing.
The tag path#
git tag deploy-prod-$(date +%Y%m%d)-1git push origin deploy-prod-$(date +%Y%m%d)-1A Cloud Build trigger watches deploy-prod-*. A tag, not a branch push. The
standing rule is one deploy per batch, and a push-triggered deploy fires on every
commit. A tag is the explicit statement that a batch is ready.
Tags push separately from commits. git push alone leaves the tag local and
nothing fires; Cloud Build never sees it. Verify with
git ls-remote --tags origin.
Make the deploy verifiable without credentials#
Expose a public, unauthenticated endpoint reporting the commit the running instance was built from:
@app.get("/api/build-info")async def build_info() -> dict[str, str]: return {"sha": s.app_build_sha, "environment": s.app_env}The SHA arrives as a Docker build arg (ARG GIT_SHA, then ENV APP_BUILD_SHA)
and Cloud Build passes $COMMIT_SHA. It carries a SHA and an environment name,
nothing sensitive, and it means confirming a deploy is a curl rather than a
credential.
Then make the build assert it:
served=$(curl -fsSL --max-time 10 "$url" | sed 's/.*"sha":"\([^"]*\)".*/\1/')Without this step a build goes green on a deploy that silently kept the old revision, which this stack has done before.
Four traps, all of which cost a real build#
The trigger's build config is validated before the repo is fetched. A
2nd-gen trigger must name a service account, and a spec referenced by filename:
is invisible at validation time. So a logging option living in the repo's
cloudbuild.yaml does not count, and the build is rejected with a message about
build.service_account needing logs_bucket or CLOUD_LOGGING_ONLY. The tell
is a failed build with no steps and no substitutions, meaning never-read
rather than read-and-rejected. Store the build spec inline on the trigger.
Dropping the service account is not an escape; the API refuses a 2nd-gen trigger
without one.
Three different service accounts are involved, and they are easy to confuse. Check which one actually runs builds rather than copying a recipe:
gcloud builds list --limit 1 --format="value(serviceAccount)"The build SA needs run.admin and iam.serviceAccountUser to deploy Cloud Run.
The Cloud Build service agent (service-<num>@gcp-sa-cloudbuild...) separately
needs Secret Manager rights to create the connection's token secret, and without
it the connection cannot be created at all.
Installing the GitHub App is not the same as connecting the repo. The app can be installed org-wide with access to every repo while a given project still holds no connection. Creating one is an OAuth handshake, so it needs a browser once.
A verifier that fails closed is worse than none. Ours polled an http:// URL
without -L, got an empty body from the redirect twenty times, and failed a
deploy that had actually succeeded. A guard that reddens on working deploys
teaches people to ignore it.
Verify with the command the build uses#
The build runs the project's own build script. Anything weaker disagrees with it:
tsc -b resolves project references that tsc --noEmit skips, and the two give
different answers. Run npm run build locally before tagging.
Related: never hand-copy kit source into a consumer's node_modules. The package
then claims one version while carrying another's types, and every local check
validates against types the build will never install. Bump the dependency and
reinstall.
Releasing the kit#
Bump packages/ui/package.json and push. A workflow publishes any version the
registry lacks, and a [skip kit] commit message suppresses it.
Do not publish from a laptop. A hand publish races the workflow, which then finds the version already present and skips, so the automation looks broken while being correct. Two kit versions were released this way before anyone noticed the workflow had never actually run.
Related#
walmart-mvp/docs/technical/04-deploying.md, the concrete instance with project ids and the one-time setup.00-data-loading.md, including why a cached payload can be older than the code reading it, which is a deploy-shaped bug.
A fresh GCP project's first build fails on its own source#
The Cloud Build identity in a new project is the compute default service account and it starts with zero roles, so the very first build 403s reading the source it just uploaded. Grant it the build roles once per project before diagnosing anything else (observed on versable-forge-v6, 2026-08-20; the error reads like a bucket problem and is an IAM one).