chore: deploy the SPA to Cloud Run, matching the sibling repos - #158
chore: deploy the SPA to Cloud Run, matching the sibling repos#158henokteixeira wants to merge 8 commits into
Conversation
The organization already runs three web apps the same way — tripod-console, meaning-map-ui and oral-collector all build a container, push it to Artifact Registry and serve it from Cloud Run in us-central1. This repository had no deploy path at all. It gets the same one. The one adaptation worth naming: VITE_API_BASE_URL is deliberately left unset, so ui/app/api-config.ts falls back to the relative /api and nginx proxies that to a backend resolved at container start. The backend can move without a rebuild, and the browser only ever talks to its own origin for the API. VITE_API_MODE=real is the single build-time decision. client_max_body_size is raised to 64m because voice answers upload through /api rather than a signed URL; nginx's 1m default would truncate a long one. Verified locally against the live backend: the image builds, the SPA fallback works, index.html is uncached while hashed assets are immutable, a call through the proxy returns byte-identical output to the same call made directly, and the container fails fast without BACKEND_URL. Nothing has been deployed — the one-time GCP setup is listed in docs/deploy-gcloud.md.
README.md and docs.md both still said the exported artifacts are PT-BR. That stopped being true with ENG-326/ENG-356 — every human-readable value and field name in the three artifacts is English now, and PT-BR survives only as story data. Corrected in both places while adding the deployment section.
…ild gate
Six real findings from the review, each verified against a rebuilt image rather
than reasoned about:
- `location /api` was a bare prefix, so a regex location beat it: any API route
ending in a static-asset extension would have been served off disk. Now `^~`,
and `json` leaves the extension list (the build emits no .json asset, so it
bought nothing and only set the trap). `GET /api/nao-existe.js` now returns the
backend's JSON 404 instead of HTML.
- `gzip on` alone compresses only text/html. The bundle was shipping raw — 730 kB
where 236 kB would do, on an app used in the field. `gzip_types` set explicitly.
- The nginx proxy timeouts promised 120s that Cloud Run's --timeout=60 cuts short.
Both are 60s now.
- ci.yml gains a non-required `docker-build` job. The deploy fires on push to
main, so the merge is the first deploy: without this, a broken Dockerfile or a
lockfile drift surfaces only after merging.
- A trailing slash in BACKEND_URL would have produced //api/... The entrypoint
strips it.
- client_max_body_size drops 64m to 16m: Cloud Run caps a request at 32 MiB
anyway, and nginx buffers bodies to RAM against --memory=256Mi.
Excluding tests/ from the build context turned out to break the build —
adapters/audio/fixture.ts imports makePcm from tests/golden/pcm. Only docs/ is
excluded, and the .dockerignore says why.
Doc corrections: the SPA reads a third variable (VITE_VOICE), the upload path is
/api/sound-necklace/sessions/{id}/resources, and nginx answers 413 rather than
truncating. The verification list now also proves VITE_API_MODE is really baked —
the fixture build produces a different bundle hash.
|
Fresh-eyes review applied. Six real findings, each verified against a rebuilt image rather than reasoned about:
One review suggestion failed its test: excluding I also corrected the doc: the SPA reads a third variable ( |
Li o estado do projeto com gcloud (só leitura) e duas coisas do §5 estavam erradas: - O repositório sound-necklace JÁ EXISTE no Artifact Registry, vazio. Era um passo manual a menos. - O aviso de que o CORS do bucket quebraria o áudio no primeiro deploy estava errado. O CORS real de gs://sound-necklace-private é origin ['*'] com GET+HEAD, que cobre a URL *.run.app. O risco é o inverso do que escrevi: o sound-necklace-cors.json versionado no tripod-api NÃO bate com o bucket, e aplicá-lo estreitaria as origens e quebraria o áudio. O §5 agora nomeia o projeto (gen-lang-client-0886209230), a service account a criar e os três papéis que a tripod-console-deployer tem hoje, em vez de dizer 'a service account de deploy' e deixar a pessoa procurar. E o segredo do backend URL ganhou o motivo de existir: o valor não é confidencial — está no .env.example deste repo, e é a mesma API que já serve os outros sistemas. O que o Secret Manager compra é repontar sem rebuild, e manter este workflow idêntico ao dos irmãos. Fixar no deploy.yml é alternativa legítima, e o doc diz isso.
…pos antigos fazem Eu tinha olhado quatro repos de app e concluído que o padrão era chave JSON de service account, e escrito no doc que 'nada disso é scriptado na organização'. As duas coisas estavam erradas. Existe um shemaobt/shema-infra privado com Terraform: módulos artifact_registry, github_deployer, cloud_run_service, secret e domain_mapping, mais um docs/adding-an-app.md que é literalmente o runbook. O módulo github_deployer provisiona Workload Identity Federation — nada de chave. A pool github-actions-pool está ACTIVE no projeto, e o obt-mentor-companion já autentica por ela. WIF não é proposta, é o padrão vigente que os quatro repos mais velhos ainda não seguiram. Então o deploy.yml passa a usar WIF, com os mesmos nomes de secret que o obt-mentor-companion usa (GCP_WORKLOAD_IDENTITY_PROVIDER e GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT). O GCP_SA_KEY sai. O doc ganhou uma seção 0 dizendo quais são os três padrões e onde eles discordam, incluindo o que eu não posso fazer agora: o environments/sound_necklace no shema-infra referenciaria o terraform_remote_state 'shared', e o env shared é exatamente o que o PR #4 de lá adiciona, ainda aberto desde abril. Mantive gcloud auth configure-docker em vez do docker/login-action do obt-mentor: quatro dos cinco repos fazem assim, e com WIF o gcloud já está autenticado.
Eu tinha trocado a autenticação para WIF mas mantido o `gcloud auth configure-docker`, argumentando 'menos peças'. Argumento fraco: o helper do gcloud contra credencial de conta externa é caminho que ninguém aqui exercitou, o job docker-build do CI não faz push, e o merge É o primeiro deploy — então um erro ali só apareceria em produção. Passa a fazer o que o obt-mentor-companion faz, que é o único WIF+Docker vivo na organização: token_format access_token no passo de auth, e docker/login-action com esse token. E o §1 ainda vendia 'os mesmos secrets GCP_PROJECT_ID/GCP_SA_KEY' como argumento de consistência, o que deixou de ser verdade quando este workflow parou de usar chave.
O oral-collector era o único repo daqui com security-headers.conf; as duas SPAs Vite não tinham nenhum. Este passa a ter: CSP, HSTS, nosniff, Referrer-Policy e X-Frame-Options, incluídos POR location — o nginx descarta add_header herdado em qualquer escopo que defina o seu, e as duas locations de estático definem Cache-Control. A CSP saiu do que o app REALMENTE busca, conferido no bundle construído, não de um modelo copiado: sem WebAssembly, sem Worker, sem eval, e o único host externo é storage.googleapis.com (o áudio da história, por URL assinada). O que sobra é same-origin, mais data:/blob: para o SVG do guia e o áudio decodificado. Uma CSP errada quebra em produção em silêncio, então não bastava conferir que o header aparece. Construí uma imagem em modo fixture com o MESMO nginx e apontei a suíte e2e do próprio repo para o container: 11 das 13 specs passam, cobrindo o ciclo inteiro — colar, segmentação, entrevista, gravação de voz. As 2 que falham são as de resilience.spec.ts que chamam __cds.seedForeignLock, um seam atrás de import.meta.env.DEV que o bundle de produção não carrega de propósito. Dirigindo o app com um listener de console: zero violações de CSP, zero erros de página, 45 fontes carregadas. Não mexi no playwright.config.ts — o reuseExistingServer: false está lá por causa de um verde falso real (ENG-279). Usei uma config descartável. O doc também acerta a ordem que eu tinha errado: o mapeamento de domínio só funciona DEPOIS do primeiro deploy (o comando falha com 'Route sound-necklace does not exist' enquanto o serviço não existe), e o CORS do bucket vem depois do domínio — com o comando pronto e sem OPTIONS no method, que não é método de requisição.
… não sobre a criação Eu tinha lido 'ERROR: Route sound-necklace does not exist' como 'o comando não fez nada, rode depois do deploy'. Errado: o comando CRIOU o mapeamento e só então reclamou do destino. O recurso está lá desde 2026-07-31T03:10:07, em Ready: False com uma condição Retry: True — estacionado, e reconcilia sozinho quando o primeiro deploy criar o serviço. Rodar de novo responde 'já existe', que é a resposta certa, não um problema. Também não é verdade que não dá para adiantar o DNS. O mapeamento só publica resourceRecords depois de reconciliar, mas os quatro domínios irmãos usam todos o mesmo registro (CNAME -> ghs.googlehosted.com.), então dá para criar antes.
| @@ -0,0 +1,238 @@ | |||
| # Deploy — Google Cloud Run | |||
|
|
|||
| **Status:** proposed (2026-07-30). The files exist and the image was verified locally; | |||
There was a problem hiding this comment.
Status: proposed (2026-07-30). […] The one-time GCP setup in §5 is still outstanding.
But the PR body says the SA, the WIF binding, the secret, the three GitHub secrets and the domain mapping are all created and verified, and §5.6 still describes the bucket CORS as origin: ["*"] while the body says it was already narrowed. Could you sync the doc with what actually exists? Since merging this IS the first deploy, whoever reads §5 right after will redo provisioning that is already done.
| COPY docker-entrypoint.sh /docker-entrypoint.sh | ||
| RUN chmod +x /docker-entrypoint.sh | ||
|
|
||
| RUN mkdir -p /var/cache/nginx /var/run /var/log/nginx && \ |
There was a problem hiding this comment.
The mkdir / chown -R nginx:nginx / touch nginx.pid block does nothing — there is no USER directive, so the container runs as root, and docs/deploy-gcloud.md §6 says that is knowingly accepted. I think it is better to just drop the block than to leave setup that suggests an unprivileged run that never happens. Not asking for nginx-unprivileged here, that is a separate task.
|
|
||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v4 |
There was a problem hiding this comment.
actions/checkout@v4 here, but every job in ci.yml — including the docker-build one this same PR adds — is on @v6. Just make sure here it is not intentional, and if not, align it.
Draft on purpose. Merging this PR is the first deploy —
deploy.ymlfires on push tomain.What "the org pattern" actually is
There are three, and they do not fully agree.
docs/deploy-gcloud.md§0 has the detail.tripod-api,tripod-console,meaning-map-ui,oral-collector,obt-mentor-companion): each builds its own image, pushes it to Artifact Registry tagged with the commit SHA, and runsgcloud run deployon push tomain. GCP resources created by hand.obt-mentor-companionuses Workload Identity Federation, and the poolgithub-actions-poolis ACTIVE in the project. WIF is the direction, not a proposal — so this repo starts there.shemaobt/shema-infra: oneenvironments/<app>/per app composing modules (artifact_registry,github_deployer,cloud_run_service,secret,domain_mapping). Its README is explicit that app-repo deploys stay in the app repos. Stalled since 2026-04-28 —environments/never landed onmain, and the plan/apply workflows are still open PRs (ENG-213 ui/tokens: Shemá design tokens, base styles, fonts, brand icon #4, docs: component library policy — selective Radix primitives #5, ENG-214 domain: bead grid, manifest hash (FNV-1a), ID allocators #6). Anenvironments/sound_necklace/cannot be written yet: it would referencedata.terraform_remote_state.shared, which is exactly what PR ENG-213 ui/tokens: Shemá design tokens, base styles, fonts, brand icon #4 adds.This PR does the app-repo half, identical under all three.
Files
Dockerfilenode:22-alpine+ corepack pnpm buildsdist/;nginx:stable-alpineserves it on 8080.nginx.conf${BACKEND_URL}substituted at container start.security-headers.confdocker-entrypoint.shenvsubst, thenexec nginx. Fails fast ifBACKEND_URLis unset..dockerignorenode_modules/,dist/, worktrees and env files out of the context..github/workflows/deploy.ymlgcloud run deploy.docs/deploy-gcloud.mdci.ymlkeeps its required checks and gains one additional, non-required job,docker-build: it builds this image, starts it, and asserts both that the SPA is served and that the container refuses to start withoutBACKEND_URL. It exists because the merge is the first deploy, so without it a brokenDockerfilewould only surface after merging.The decision worth arguing about
VITE_API_BASE_URLis deliberately not set at build time. Unset,ui/app/api-config.tsfalls back to the relative/api, and nginx proxies that to whateverBACKEND_URLthe container got at start. The backend can move without rebuilding the image, and the browser only ever talks to its own origin for the API.VITE_API_MODE=realis the single build-time decision.client_max_body_sizeis 16m: voice answers upload throughPUT /api/sound-necklace/sessions/{id}/resources— through this proxy, not via a signed URL — and nginx's 1m default would answer 413. Not higher, because Cloud Run caps a request at 32 MiB and nginx buffers bodies to RAM against--memory=256Mi.Google's own docs steer a pure static SPA to Firebase Hosting first, and it is both cheaper and simpler in the abstract. Cloud Run wins here on consistency with the siblings, not on cost. That trade-off is written down rather than hidden.
Verified locally, against the live backend
GET /→ 200text/html;GET /dashboard/<anything>→ 200 (SPA fallback).index.html→no-cache; hashed asset → a singlepublic, max-age=31536000, immutable, withContent-Encoding: gzip(729 721 bytes uncompressed).GET /api/auth/methrough the proxy returns byte-identical output to the same call made directly, including whenBACKEND_URLcarries a trailing slash.GET /api/nao-existe.jsreturns the backend's JSON 404 rather than HTML off disk — the^~precedence fix working.VITE_API_MODE=realis genuinely baked: the same build withfixtureproduces a different bundle hash. A silently-unbaked mode gives an app that looks healthy until someone tries to log in.BACKEND_URL→ the container exits immediately with a named error. No error lines in the nginx log.__cds.seedForeignLock, a seam gated behindimport.meta.env.DEVand therefore absent from a production bundle by design. Driving the app with a console listener recorded zero CSP violations and zero page errors.Not verified: the deploy itself.
GCP state
All provisioning is done and independently verified: Artifact Registry repo (pre-existing),
sound-necklace-github-deployerwith the three rolesshema-infra'sgithub_deployermodule defines, the WIF binding, thesound_necklace_backend_urlsecret, and the three GitHub secrets. Thesoundnecklace.shemaywam.comdomain mapping exists and is parked inReady: False/Retry: Trueuntil the first deploy creates the service. The bucket CORS has been narrowed fromorigin: ["*"]to the real origins.The one thing left unverified is the first real audio download: the CSP was exercised in fixture mode, where audio does not come from GCS, so
connect-src https://storage.googleapis.comis correct by code but untested end to end.Unrelated correction, folded in
README.mdanddocs.mdboth still claimed the exported artifacts are PT-BR. ENG-326/ENG-356 made them English. Corrected in both while adding the deployment section — happy to split it out.