CI Secret Access
How GitHub Actions reads secrets from GCP Secret Manager using Workload Identity Federation, plus the one GitHub Secret and build-time variable injection.
The CI pipeline accesses secrets differently depending on the job. The quality job uses hardcoded placeholders. The migrate-prod and build-push jobs authenticate to GCP and read real secrets from Secret Manager. This page covers each pattern.
Quality Job: Placeholders Only
The quality job (lint, typecheck, test, build) runs on every push and PR. It does not connect to GCP. Instead, it uses placeholder values declared directly in the workflow:
env:
DATABASE_URL: postgresql://trovella:trovella_dev@localhost:5433/trovella
REDIS_URL: redis://localhost:6379
BETTER_AUTH_SECRET: ci-test-secret-at-least-32-characters-long
BETTER_AUTH_URL: http://localhost:3000
GOOGLE_CLIENT_ID: ci-placeholder
GOOGLE_CLIENT_SECRET: ci-placeholder
ANTHROPIC_API_KEY: ci-placeholder
GOOGLE_AI_API_KEY: ci-placeholder
TYPESENSE_API_KEY: ci-test-key
TYPESENSE_URL: http://localhost:8108
These connect to ephemeral service containers (Postgres, Redis, Typesense) that are created and destroyed with each workflow run. The ci-placeholder values satisfy env var presence checks without making real API calls -- AI and OAuth tests are mocked.
GCP Authentication: Workload Identity Federation
The migrate-prod and build-push jobs run only on pushes to main. They authenticate to GCP using Workload Identity Federation (WIF):
- name: Authenticate to GCP
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ vars.WIF_PROVIDER }}
service_account: ${{ vars.WIF_SERVICE_ACCOUNT }}
The authentication flow:
- GitHub Actions mints an OIDC token for the workflow run
- The
google-github-actions/authaction exchanges this token with the GCP WIF pool - GCP issues a short-lived access token scoped to the
github-actions-ciservice account - Subsequent
gcloudcommands use this token automatically
The WIF provider and service account are stored as GitHub Actions variables (not secrets), configured in the repository settings:
| Variable | Value |
|---|---|
WIF_PROVIDER | projects/{number}/locations/global/workloadIdentityPools/github-actions/providers/github-oidc |
WIF_SERVICE_ACCOUNT | github-actions-ci@trovella-shared.iam.gserviceaccount.com |
These are not sensitive -- they identify the WIF pool and service account but cannot be used to authenticate without a valid OIDC token from the correct GitHub repository.
migrate-prod: Reading DATABASE_URL
The migration job reads the database connection URL from Secret Manager, then rewrites it for the Cloud SQL Auth Proxy:
- name: Read DATABASE_URL from Secret Manager
run: |
DB_URL=$(gcloud secrets versions access latest \
--secret=trovella-database-url \
--project=trovella-prod)
echo "::add-mask::$DB_URL"
PROXY_URL=$(echo "$DB_URL" | sed -E 's|@[^:/]+:[0-9]+/|@127.0.0.1:5432/|')
PROXY_URL="${PROXY_URL}?sslmode=disable"
echo "::add-mask::$PROXY_URL"
echo "DATABASE_URL=$PROXY_URL" >> "$GITHUB_ENV"
Key details:
::add-mask::tells GitHub Actions to redact the value from all subsequent log output- Host rewrite: The secret stores the Cloud SQL public IP; the CI step rewrites it to
127.0.0.1:5432where the Cloud SQL Auth Proxy listens sslmode=disable: The proxy handles TLS to Cloud SQL; client-side SSL would cause double-negotiation ECONNRESET errorsGITHUB_ENV: Makes the variable available to all subsequent steps in the job
For the full migration pipeline, including proxy setup and readiness checks, see Data & Storage -- CI Deployment.
build-push: Reading SENTRY_DSN
The Docker build job reads the Sentry DSN from Secret Manager to bake it into the image as a NEXT_PUBLIC_* build arg:
- name: Read SENTRY_DSN from Secret Manager
id: sentry
run: |
DSN=$(gcloud secrets versions access latest \
--secret=trovella-sentry-dsn \
--project=trovella-prod 2>/dev/null) || DSN=""
echo "::add-mask::$DSN"
echo "dsn=$DSN" >> "$GITHUB_OUTPUT"
The value is then passed to the Docker build:
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
build-args: |
NEXT_PUBLIC_BETTER_AUTH_URL=https://trovella.ai
NEXT_PUBLIC_SENTRY_DSN=${{ steps.sentry.outputs.dsn }}
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
Three build args are injected:
| Arg | Source | Purpose |
|---|---|---|
NEXT_PUBLIC_BETTER_AUTH_URL | Hardcoded in workflow | Auth callback base URL (inlined by Next.js) |
NEXT_PUBLIC_SENTRY_DSN | Secret Manager | Sentry DSN for client-side error reporting |
SENTRY_AUTH_TOKEN | GitHub Secret | Authenticates source map upload to Sentry |
The One GitHub Secret: SENTRY_AUTH_TOKEN
SENTRY_AUTH_TOKEN is the only value stored in GitHub repository secrets (Settings > Secrets and variables > Actions). It is used exclusively during the Docker build to upload source maps to Sentry. It is:
- Not a GCP secret -- it authenticates with Sentry's API, not GCP
- Not needed at runtime -- only used during
next buildfor source map upload - Not sent to the VM -- it exists only in the CI builder stage
Every other secret is in GCP Secret Manager.
Required IAM Permissions
The CI service account (github-actions-ci@trovella-shared.iam.gserviceaccount.com) needs these roles to access secrets:
| Role | Project | Purpose |
|---|---|---|
roles/secretmanager.secretAccessor | trovella-prod | Read secret values |
roles/cloudsql.client | trovella-prod | Connect via Cloud SQL Auth Proxy |
roles/iam.workloadIdentityUser | trovella-shared | WIF token exchange |
These are granted by the WIF Terraform module in infra/modules/wif/main.tf. See Infrastructure -- Cloud Resources -- Workload Identity for the full IAM setup.
Adding a New Secret to CI
If a new CI job needs to read a secret from Secret Manager:
- Ensure the secret exists (see Secret Manager -- Adding a New Secret)
- Add a
gcloud secrets versions accessstep after the GCP auth step - Use
::add-mask::to prevent the value from appearing in logs - Pass the value via
GITHUB_ENV(for subsequent steps) orGITHUB_OUTPUT(for other steps or jobs)
If the value is needed at Docker build time (for NEXT_PUBLIC_* variables), pass it as a build-args entry.