Skip to content

chore: deploy the SPA to Cloud Run, matching the sibling repos - #158

Open
henokteixeira wants to merge 8 commits into
mainfrom
chore/deploy-cloud-run
Open

chore: deploy the SPA to Cloud Run, matching the sibling repos#158
henokteixeira wants to merge 8 commits into
mainfrom
chore/deploy-cloud-run

Conversation

@henokteixeira

@henokteixeira henokteixeira commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Draft on purpose. Merging this PR is the first deploydeploy.yml fires on push to main.

What "the org pattern" actually is

There are three, and they do not fully agree. docs/deploy-gcloud.md §0 has the detail.

  1. Live in five app repos (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 runs gcloud run deploy on push to main. GCP resources created by hand.
  2. Auth is mid-migration. Four of the five use a static service-account JSON key. obt-mentor-companion uses Workload Identity Federation, and the pool github-actions-pool is ACTIVE in the project. WIF is the direction, not a proposal — so this repo starts there.
  3. The written-down target is Terraform, in the private shemaobt/shema-infra: one environments/<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-28environments/ never landed on main, 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). An environments/sound_necklace/ cannot be written yet: it would reference data.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

File Role
Dockerfile node:22-alpine + corepack pnpm builds dist/; nginx:stable-alpine serves it on 8080.
nginx.conf Template — ${BACKEND_URL} substituted at container start.
security-headers.conf CSP + HSTS + nosniff + Referrer-Policy + X-Frame-Options, included per static location.
docker-entrypoint.sh envsubst, then exec nginx. Fails fast if BACKEND_URL is unset.
.dockerignore Keeps node_modules/, dist/, worktrees and env files out of the context.
.github/workflows/deploy.yml WIF auth → build → push → read the backend URL from Secret Manager → gcloud run deploy.
docs/deploy-gcloud.md The decisions, the verification, and the one-time GCP setup.

ci.yml keeps 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 without BACKEND_URL. It exists because the merge is the first deploy, so without it a broken Dockerfile would only surface after merging.

The decision worth arguing about

VITE_API_BASE_URL is deliberately not set at build time. Unset, ui/app/api-config.ts falls back to the relative /api, and nginx proxies that to whatever BACKEND_URL the 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=real is the single build-time decision.

client_max_body_size is 16m: voice answers upload through PUT /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 / → 200 text/html; GET /dashboard/<anything> → 200 (SPA fallback).
  • index.htmlno-cache; hashed asset → a single public, max-age=31536000, immutable, with Content-Encoding: gzip (729 721 bytes uncompressed).
  • GET /api/auth/me through the proxy returns byte-identical output to the same call made directly, including when BACKEND_URL carries a trailing slash.
  • GET /api/nao-existe.js returns the backend's JSON 404 rather than HTML off disk — the ^~ precedence fix working.
  • VITE_API_MODE=real is genuinely baked: the same build with fixture produces a different bundle hash. A silently-unbaked mode gives an app that looks healthy until someone tries to log in.
  • No BACKEND_URL → the container exits immediately with a named error. No error lines in the nginx log.
  • The CSP does not break the app. The repo's own e2e suite was pointed at the running container (a fixture-mode build of the same image, same nginx): 11 of 13 specs pass, covering the full cycle — necklace, segmentation, interview, voice recording. The two that fail call __cds.seedForeignLock, a seam gated behind import.meta.env.DEV and 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-deployer with the three roles shema-infra's github_deployer module defines, the WIF binding, the sound_necklace_backend_url secret, and the three GitHub secrets. The soundnecklace.shemaywam.com domain mapping exists and is parked in Ready: False / Retry: True until the first deploy creates the service. The bucket CORS has been narrowed from origin: ["*"] 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.com is correct by code but untested end to end.

Unrelated correction, folded in

README.md and docs.md both 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.

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.
Copilot AI review requested due to automatic review settings July 31, 2026 02:18

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…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.
@henokteixeira

henokteixeira commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Fresh-eyes review applied. Six real findings, each verified against a rebuilt image rather than reasoned about:

  • location /api was a bare prefix, and a regex location beats a prefix one — any API route ending in one of the asset extensions would have been served off disk. Now ^~, and json left 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 was effectively off: 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 now explicit, confirmed by Content-Encoding: gzip.
  • 120s proxy timeouts that Cloud Run's --timeout=60 cuts short. Both are 60s now.
  • ci.yml gained a non-required docker-build job. The deploy fires on push to main, so the merge is the first deploy: without it, a broken Dockerfile would only surface after merging. The job builds, starts the container, and asserts both that the SPA is served and that it refuses to start without BACKEND_URL.
  • A trailing slash in BACKEND_URL would have produced //api/.... The entrypoint strips it.
  • client_max_body_size 64m → 16m: Cloud Run already caps a request at 32 MiB, and nginx buffers bodies to RAM against --memory=256Mi.

One review suggestion failed its test: excluding tests/ from the build context breaks the build, because adapters/audio/fixture.ts imports makePcm from tests/golden/pcm. Production code depending on a test helper is odd, but fixing that is not in scope here. Only docs/ is excluded, and .dockerignore says why.

I also corrected the doc: 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 really is baked — the fixture build produces a different bundle hash. It was the one item nobody had verified, and it is exactly the failure that looks healthy until someone tries to log in.

Copilot AI review requested due to automatic review settings July 31, 2026 02:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.
Copilot AI review requested due to automatic review settings July 31, 2026 02:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…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.
Copilot AI review requested due to automatic review settings July 31, 2026 03:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.
Copilot AI review requested due to automatic review settings July 31, 2026 03:13

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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.
Copilot AI review requested due to automatic review settings July 31, 2026 03:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

… 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.
Copilot AI review requested due to automatic review settings July 31, 2026 11:52

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@henokteixeira henokteixeira self-assigned this Jul 31, 2026
@henokteixeira
henokteixeira marked this pull request as ready for review July 31, 2026 12:54

@joaocarvoli joaocarvoli left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

only a few comments

Comment thread docs/deploy-gcloud.md
@@ -0,0 +1,238 @@
# Deploy — Google Cloud Run

**Status:** proposed (2026-07-30). The files exist and the image was verified locally;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Comment thread Dockerfile
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh

RUN mkdir -p /var/cache/nginx /var/run /var/log/nginx && \

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants