Agent docs

Deploying a Versable app on GCP

A manual path for development, an automatic path for shipping, on GCP.

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 ──▶ production
tag 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.

PathWhenNeeds
./deploy.sh [target]developing, or the triggers are downlive gcloud auth, local Docker
push to mainpreviewinggit only
merge to releaseshippinggit only
kit publish workflowreleasing the UI kita 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)-1
git push origin deploy-prod-$(date +%Y%m%d)-1

A 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.

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).

@versable-git/ui · reference, canon, and method, read in place