Skip to content

chore(qa): QA/staging environment seam (web+api+android internal) (#211) - #239

Merged
thomasluizon merged 2 commits into
mainfrom
chore/211-qa-staging-env
Jun 19, 2026
Merged

chore(qa): QA/staging environment seam (web+api+android internal) (#211)#239
thomasluizon merged 2 commits into
mainfrom
chore/211-qa-staging-env

Conversation

@thomasluizon

Copy link
Copy Markdown
Owner

What

Stands up the repo-side seam for a QA/staging environment that mirrors prod across web, api, and the Android internal track. The env-selection mechanism already exists on every surface; this PR feeds QA values into the one place that was missing it — the Android internal-track build — and makes QA a first-class build profile. Web and API need no code (both are env-driven); their QA instances are operator-provisioned (steps below).

Closes #211.

Code changes (this repo)

  • .github/workflows/android-release.yml — the core fix. The prebuild step never injected EXPO_PUBLIC_API_BASE, so an internal-track build silently baked in the prod API base (https://api.useorbit.org). New Resolve build target step derives prod-vs-QA from the release track:
    • track == internal ⇒ QA: EXPO_PUBLIC_API_BASE from vars.QA_API_BASE, EXPO_PUBLIC_ADMOB_USE_TEST_IDS=true, eas profile qa. Fails loudly if vars.QA_API_BASE is unset (no silent prod fallback).
    • any other track ⇒ prod: unchanged (prod base + real AdMob IDs).
    • The two prod-AdMob validation steps are now gated with if: inputs.track != 'internal' so a QA build using test AdMob IDs doesn't trip them.
  • apps/mobile/eas.json — new build.qa profile (apk / internal distribution / EXPO_PUBLIC_API_BASE=https://api-qa.useorbit.org / test AdMob), mirroring the preview shape, plus a submit.qa mapping to the Play internal track (for the direct eas build/submit --profile qa path).
  • .claude/skills/android-generate/SKILL.md — documents a QA switch: export EXPO_PUBLIC_API_BASE (+ test AdMob) before npm run android:apk -w @orbit/mobile; default invocation still builds prod.
  • apps/mobile/scripts/android-release-apk.js — intentionally unchanged. It already inherits process.env, so the skill setting the env var is sufficient; adding a guard would be speculative (plan marked it optional).

Target-derivation decision

The plan left open whether to derive the QA-vs-prod target from track (A) or add an explicit api_target input (B). Chose (A): track == internal ⇒ QA. No new workflow input; the internal track is already the pre-prod track, so this is the lowest-friction mapping. Easy to switch to an explicit input later if a non-internal QA target is ever needed.

Validation done

  • eas.json parses as valid JSON; qa build + submit.qa profiles present and mirror the existing profile shape.
  • android-release.yml parses as valid YAML; both prod-AdMob validation steps confirmed gated to non-internal tracks; the resolve + prebuild steps run unconditionally with the prebuild now reading the resolved env.
  • No TS/JS changed (script left as-is), so no install/test run was needed.

⚠️ Merge conflict with #232 (PR #233)

#232 also edits .github/workflows/android-release.yml. These edits will conflict at merge — resolve by hand so both changes survive: keep #232's changes and this PR's Resolve build target step + the if: inputs.track != 'internal' gates + the target-aware prebuild env block.


Operator steps (NOT executed here — no cloud provisioning was done)

The QA web + API + DB are dashboard-provisioned. ASPNETCORE_ENVIRONMENT=Production for the QA API is decided (faithful to prod; runs the strict validators), which means the full prod-grade secret set is required or the QA API won't boot.

  1. Supabase — create QA project (separate from prod). Capture project URL, anon key, and the session-pooler connection string.
  2. Supabase — apply migrations to QA: dotnet ef database update --connection "<QA pooler URI>" (the design-time factory reads prod appsettings.json, so pass --connection explicitly). Verify tables exist.
  3. Render — create QA API service from the same orbit-api repo + Dockerfile + branch. Note its public URL (https://<qa-api>.onrender.com).
  4. Render — create a QA env group (ASPNETCORE_ENVIRONMENT=Production) and attach to the QA API service. Required vars (ASP.NET Section__Key double-underscore):
    • Isolated (must differ from prod): ConnectionStrings__DefaultConnection (QA pooler), Jwt__SecretKey (new 64-char random), Encryption__Key (new; must NOT contain "REPLACE"), Supabase__Url / Supabase__AnonKey (QA), Stripe__SecretKey/Stripe__PublishableKey/Stripe__WebhookSecret/Stripe__*PriceId* (TEST mode sk_test_/pk_test_/whsec_ + test price IDs), Vapid__PublicKey/Vapid__PrivateKey (new QA pair).
    • Shared with prod (same value): Firebase__CredentialsJson (single FCM project orbit-11d4a), GooglePlay__ServiceAccountJson / GooglePlay__RtdnServiceAccountEmail (single Play app org.useorbit.app).
    • Prod-creds, separate budget-capped key: AI__ApiKey (OpenAI — no test mode), Resend__ApiKey (ideally sandbox domain).
    • QA-derived (backfilled in step 7): Cors__AllowedOrigins__0, Frontend__BaseUrl.
  5. Host — create QA web deploy of apps/web with env: API_BASE = QA API URL, NEXT_PUBLIC_SUPABASE_URL/_ANON_KEY = QA Supabase, NEXT_PUBLIC_VAPID_PUBLIC_KEY = QA VAPID public. Note its origin (e.g. qa.useorbit.org).
  6. Backfill Cors__AllowedOrigins__0 + Frontend__BaseUrl on the QA API env group with the QA web origin; redeploy QA API.
  7. Stripe (test mode) — webhook endpoint → <QA API> stripe path; capture whsec_ + test price IDs into the QA env group.
  8. VAPID — generate a QA keypair; private → QA API Vapid__PrivateKey, public → both QA API Vapid__PublicKey and QA web NEXT_PUBLIC_VAPID_PUBLIC_KEY.
  9. GitHub — add vars.QA_API_BASE = https://<qa-api>.onrender.com (or https://api-qa.useorbit.org) under repo Settings → Secrets and variables → Actions → Variables. The workflow injects this at prebuild; an internal run fails loudly if it's missing.
  10. Play Console — confirm the internal testing track exists for org.useorbit.app (same app/package; no new Play app).
  11. (Optional) DNSapi-qa.useorbit.org / qa.useorbit.org CNAMEs to the Render services (matches the public hostnames used in the eas qa profile + skill).

No new app-signing secrets — the internal track reuses the existing keystore/Play-SA secrets.

API repo

Per the plan, orbit-api needs 0 code — it's fully builder.Configuration-driven, so QA = a Render env group + QA Supabase (operator steps 1-8 above). No orbit-api PR is opened (no real file change there). A render.yaml blueprint-as-code was an explicit non-goal of this issue.

Unblocks

#227 (QA smoke suite) hooks into the QA API health endpoint + QA web origin + QA internal build stood up here.

🤖 Generated with Claude Code

)

Internal-track Android builds silently baked in the prod API base because
android-release.yml never injected EXPO_PUBLIC_API_BASE at prebuild. Derive
the build target from the release track (internal => QA, all other tracks =>
prod): inject EXPO_PUBLIC_API_BASE from vars.QA_API_BASE, force AdMob test IDs,
and gate the prod-AdMob validation steps off for the QA build. Add a first-class
eas qa build profile + submit.qa internal-track mapping, and a QA switch in the
android-generate skill.

QA web + API run on operator-provisioned infra (Render QA service + env group
with ASPNETCORE_ENVIRONMENT=Production, QA Supabase, QA web deploy, and
vars.QA_API_BASE); those are documented in the PR, not code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
orbit-ui-mobile-web Ignored Ignored Jun 19, 2026 8:46pm

@claude claude Bot 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.

Solid, focused fix for the silent-prod-bake bug on the internal track. The Resolve build target step is clean: it derives prod vs QA from the existing track input (no new workflow param), fails loudly when vars.QA_API_BASE is unset so there's no silent fallback, and writes resolved env vars cleanly through GITHUB_ENV. Gating the prod-AdMob validation steps off for internal builds is the correct call. The eas.json qa profiles mirror existing profile shapes exactly. Nothing to change here.

Combine #233's job-level AdMob env hoist with #239's QA build-target resolution: prebuild AND bundleRelease map the BUILD_* overrides, so the internal/QA track ships test IDs at bundle time (not just prebuild); gate #233's shipped-AAB real-ID guard to non-internal tracks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@claude claude Bot 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.

Code Review: PR #239

Scope: PR #239 — chore(qa): QA/staging environment seam (web+api+android internal) (#211)
Recommendation: NEEDS WORK

Summary

This PR stands up a QA build target for the Android internal-track workflow by injecting EXPO_PUBLIC_API_BASE at prebuild time and adding a qa profile to eas.json. The core intent is sound and the fail-loud guard on an unset QA_API_BASE is good defensive engineering. However, there are two Medium findings: a semantic mismatch in the submit step profile name, and debug echo statements that print the resolved API base URL in plain CI logs.


Findings

Critical

None

High

None

Medium

[MEDIUM] Submit step uses --profile internal instead of --profile qa for internal-track QA builds

  • dimension: Correctness (#1)
  • location: .github/workflows/android-release.yml:380-383
  • issue: The Submit build to Google Play step passes --profile "${RELEASE_TRACK}" where RELEASE_TRACK is the raw workflow input ("internal"). The PR adds a submit.qa profile in eas.json specifically to handle the QA submission path, but the workflow never selects it — it selects submit.internal instead. Today submit.internal and submit.qa are identical (both map to track: internal), so the immediate behavior is the same, but the newly-added submit.qa profile is dead and the intended coupling between the BUILD_EAS_PROFILE=qa build path and the qa submit profile is severed.
  • risk: Any future divergence between submit.internal and submit.qa (e.g., adding a releaseStatus: draft or a different rollout config to QA) will silently fail to apply for workflow-triggered internal builds. The added profile is misleading — it implies the workflow uses it when it does not.
  • fix: Replace --profile "${RELEASE_TRACK}" with --profile "${BUILD_EAS_PROFILE}" (already available in GITHUB_ENV from the Resolve step). For track=internal this resolves to qa; for all other tracks it resolves to production, which matches the existing submit.production profile. Update the step's env block:
    env:
      EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
      BUILD_EAS_PROFILE: ${{ env.BUILD_EAS_PROFILE }}
    run: |
      set -euo pipefail
      npx eas-cli@latest submit \
        --platform android \
        --profile "${BUILD_EAS_PROFILE}" \
        --path android/app/build/outputs/bundle/release/app-release.aab \
        --non-interactive
  • reference: CLAUDE.md rule 1 (root cause / no dead code coupling); CLAUDE.md rule 2 (submit.qa is unreachable from the workflow)

[MEDIUM] Debug echo statements print the resolved QA API base URL to plain CI log

  • dimension: Security (#12)
  • location: .github/workflows/android-release.yml:205, 239-240
  • issue: Line 205 prints "Build target: QA (api_base=${QA_API_BASE}, ...)" and lines 239-240 print "Prebuild API base: ${EXPO_PUBLIC_API_BASE}" unconditionally to the GitHub Actions log. QA_API_BASE is a repo variable (not a secret), so GitHub does not mask it — the URL appears in plaintext in every run log.
  • risk: Low immediate risk, but sets a precedent of printing infrastructure URLs to logs and could inadvertently expose the value if a future maintainer promotes it to a secret. The SKILL.md already documents the canonical QA URL, making the runtime log redundant.
  • fix: Remove the three diagnostic echo lines. If Actions UI visibility is needed, scope to a GitHub notice annotation:
    echo "::notice title=Build target::QA — test AdMob IDs enabled"
  • reference: CLAUDE.md rule 4 (no logging in production pipelines); Security dimension — data exposure

Low / Info

[INFO] submit.internal and submit.qa are now identical — one is dead code

  • dimension: Dead / stale code (#2)
  • location: apps/mobile/eas.json:53-66
  • issue: Both profiles map to track: internal, releaseStatus: completed. After applying the Medium fix (switch submit to --profile "${BUILD_EAS_PROFILE}"), submit.internal becomes dead code.
  • fix: Apply the Medium fix, then remove submit.internal from eas.json.
  • reference: CLAUDE.md rule 2

[INFO] preview and qa build profiles now serve distinct purposes

  • dimension: Correctness (#1)
  • location: apps/mobile/eas.json:19-37
  • issue: preview = prod API base + test AdMob; qa = QA API base + test AdMob. Not a bug, just worth noting so the profiles are not confused.
  • fix: No action required.

Subagents

Agent Verdict
parity-checker N/A — CI/CD and build config only; no app-code mirror required
i18n-syncer N/A — no user-facing strings changed
contract-aligner N/A — no packages/shared types or orbit-api DTOs changed
security-reviewer N/A — no orbit-api code changed

Validation

Check Result
Lint N/A (no TS/JS/CS changed)
Type check N/A
Tests N/A
Build (api) N/A
YAML/JSON parse PASS (per PR author validation)

What's good

  • Fail-loud guard on unset QA_API_BASE (exit 1 with a clear, actionable error message) is exactly right — no silent fallback to prod.
  • Gating both prod AdMob validation steps with if: inputs.track != 'internal' prevents false-positive failures on QA builds.
  • Environment injection in both the prebuild and Build signed AAB steps ensures EXPO_PUBLIC_API_BASE is baked in at both the Expo config and Gradle layers.
  • app.config.js guard (assertProductionAdMobConfig checks EAS_BUILD_PROFILE === "production") is respected: BUILD_EAS_PROFILE=qa correctly bypasses the production AdMob enforcement.
  • SKILL.md QA documentation is a good addition for local development.
  • The PR body is thorough: the A vs B derivation decision, the conflict with #232, and the operator runbook are all well documented.

Recommendation

Two items before merge:

  1. (Medium — fix before merge) Switch the submit step to --profile "${BUILD_EAS_PROFILE}" so the new submit.qa profile is actually used, then remove the dead submit.internal from eas.json.
  2. (Medium — fix or track) Remove the three diagnostic echo lines that print the resolved API URL and AdMob flag to the plain CI log.

Backward-compat guard is N/A: no packages/shared Zod schemas or orbit-api DTOs were touched.

🤖 Generated with Claude Code

@thomasluizon
thomasluizon merged commit 72fbd5e into main Jun 19, 2026
8 checks passed
@thomasluizon
thomasluizon deleted the chore/211-qa-staging-env branch June 19, 2026 20:52
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.

QA / staging environment (web + api + android internal)

1 participant