Trovella Wiki

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:

  1. GitHub Actions mints an OIDC token for the workflow run
  2. The google-github-actions/auth action exchanges this token with the GCP WIF pool
  3. GCP issues a short-lived access token scoped to the github-actions-ci service account
  4. Subsequent gcloud commands use this token automatically

The WIF provider and service account are stored as GitHub Actions variables (not secrets), configured in the repository settings:

VariableValue
WIF_PROVIDERprojects/{number}/locations/global/workloadIdentityPools/github-actions/providers/github-oidc
WIF_SERVICE_ACCOUNTgithub-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:5432 where the Cloud SQL Auth Proxy listens
  • sslmode=disable: The proxy handles TLS to Cloud SQL; client-side SSL would cause double-negotiation ECONNRESET errors
  • GITHUB_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:

ArgSourcePurpose
NEXT_PUBLIC_BETTER_AUTH_URLHardcoded in workflowAuth callback base URL (inlined by Next.js)
NEXT_PUBLIC_SENTRY_DSNSecret ManagerSentry DSN for client-side error reporting
SENTRY_AUTH_TOKENGitHub SecretAuthenticates 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 build for 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:

RoleProjectPurpose
roles/secretmanager.secretAccessortrovella-prodRead secret values
roles/cloudsql.clienttrovella-prodConnect via Cloud SQL Auth Proxy
roles/iam.workloadIdentityUsertrovella-sharedWIF 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:

  1. Ensure the secret exists (see Secret Manager -- Adding a New Secret)
  2. Add a gcloud secrets versions access step after the GCP auth step
  3. Use ::add-mask:: to prevent the value from appearing in logs
  4. Pass the value via GITHUB_ENV (for subsequent steps) or GITHUB_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.

On this page