Skip to main content

CI/CD Pipeline

Drop CI/CD Pipeline

Last Updated:updated: 2026-03-0402-13 Source: .github/workflows/src/drop-app/package.json, (11Dockerfile, workflowfly.toml, files)vitest.config.ts, Decision: ADR-012 — AWS App Runnerplaywright.config.ts


OverviewCurrent State

Drop usesis in MVP/pre-production stage. Core CI/CD infrastructure exists including a GitHub Actions forworkflow.

CI/CD.

What Allexists:

pipeline
    logic
  • GitHub livesActions inCI workflow (.github/workflows/ci.yml.) Therewith are5 11jobs: workflowlint-and-typecheck, filestest, coveringbuild, qualitye2e, gates,docker-build
  • staging
  • Dockerfile with multi-stage build (Dockerfile:1-63)
  • docker-compose for local and production deployment,(docker-compose.yml, hotfixdocker-compose.production.yml)
  • path,
  • Fly.io mobiledeployment CI/OTA/storeconfig releases,(fly.toml)
  • and
  • Vitest Terraformunit/integration infrastructure.

    test framework (vitest.config.ts)
  • Playwright E2E test framework (playwright.config.ts)
  • Health check endpoint (/api/health)
  • QA report generation via scripts/qa-report.js (automated in CI)

DeploymentWhat targets:does not exist yet:

  • Automated deployment pipeline (CI builds but does not deploy)
  • Container registry integration
  • Automated security scanning (npm audit, Snyk)
  • Test coverage reporting
  • Staging environment (Fly.io config exists but not deployed)

Build Pipeline

Step 1: Install Dependencies

npm ci

Installs exact versions from package-lock.json.

Step 2: Lint

npm run lint     # eslint

Step 3: Type Check

npx tsc --noEmit

Step 4: Unit + Integration Tests

npm test         # vitest run

Runs all tests in tests/**/*.test.ts (from vitest.config.ts:7). Test setup: tests/setup.ts sets NODE_ENV=test.

Step 5: Build

npm run build    # next build

Produces standalone output for Docker deployment.

Step 6: Docker Build

docker build -t drop-app .

Multi-stage build: deps -> builder -> runner.

Step 7: E2E Tests (requires running server)

npx playwright test

Requires dev server on http://localhost:3000. Playwright auto-starts it via webServer config.


Test Framework Configuration

Vitest (Unit + Integration)

Config: src/drop-app/vitest.config.ts:1-15

@
ServiceSetting TargetRegistryValue
drop-app (Next.js BFF)Environment AWS App Runner (eu-west-1node)Amazon ECR
drop-api (Hono mobile API)Include AWS App Runner (eu-west-1tests/**/*.test.ts)Amazon ECR
drop-mobile (Expo React Native)Setup Expo EAS (OTA + store builds)EAS / App Store / Play Storetests/setup.ts
LandingPath pagealias Vercel -> ./src

Playwright

Workflow(E2E)

Files

Config: src/drop-app/playwright.config.ts:1-39

--toProduction to
FileSetting NameTriggerValue
ci.ymlTest dir CI — Quality Gatepush/PR to main, develop./tests/e2e
deploy-staging.ymlParallel Deployfalse to(serial Staging pushrate tolimiter main,is manual dispatchshared)
deploy.ymlWorkers Deploy1
Retries (CI) push2
Timeout30,000ms
Base URLmainhttp://localhost:3000
hotfix.ymlReporter Hotfix Deploymanual dispatch onlyHTML
dast.ymlTrace DAST — OWASP ZAP Security Scanschedule (Sun 02:00 UTC), manual dispatch
mobile-ci.ymlon-first-retryMobile CI — Lint + Testpush/PR to main, develop (paths: src/drop-mobile/**)
mobile-deploy.ymlMobile Deploy — OTA Updatepush to main (paths: src/drop-mobile/**)
mobile-release.ymlMobile Release — Store Build + Submittag push (mobile-v*)
terraform-ci.ymlTerraform PlanPR touching infrastructure/terraform/**, manual dispatch
terraform-plan.ymlTerraform PlanPR touching infrastructure/terraform/**, manual dispatch
terraform-drift.ymlTerraform Drift Detectionschedule (Mon 06:00 UTC), manual dispatch

CI Quality Gate (ci.yml)

Triggers on push or PR to main or develop. Uses path filtering (dorny/paths-filter) so jobs only run when their package has changed (or always on main).

Jobs

1. detect-changes Detects which packages changed: drop-app, drop-api, drop-mobile, infrastructure. Outputs boolean flags used by downstream jobs.

2. drop-app-test — Lint + Test + Coverage

  • npm ci in src/drop-app
  • ESLint (npm run lint)
  • TypeScript type-check (npx tsc --noEmit)
  • Vitest with coverage (npx vitest run --coverage --reporter=verbose)
  • Coverage threshold gate (HARD FAIL if below): statements 80%, branches 70%, functions 80%, lines 80%
  • Coverage ratchet on PRs: blocks merge if coverage drops below main baseline
  • PR comment with coverage table
  • npm audit --omit=dev --audit-level=high

3. drop-app-e2e — Playwright E2E (depends on drop-app-test)

  • Builds app (npm run build)
  • Installs Playwright with Chromium
  • Runs all E2E tests (npx playwright test --reporter=html)
  • Uploads playwright-report/ artifact on failure (14-day retention)

4. drop-app-mutation — Stryker Mutation Testing (PR only, depends on drop-app-test)

  • Runs only on files changed in the PR (not test files)
  • Mutation score warning threshold: 60%

5. drop-api-test — Lint + Test for src/drop-api

  • TypeScript type-check
  • npm run test

6. drop-mobile-test — Lint + Test for src/drop-mobile

  • npx jest --passWithNoTests --ci
  • Fails if no Jest config found (tests required before production launch per PSD2 requirement)

7. docker-scan-app — Docker Build + Trivy Scan for drop-app (depends on drop-app-test + drop-app-e2e)

  • Builds Docker image tagged with commit SHA
  • Trivy scan: exits 1 on HIGH/CRITICAL unfixed vulnerabilities
  • Uploads SARIF results to GitHub Security tab

8. docker-scan-api — Docker Build + Trivy Scan for drop-api (depends on drop-api-test)

  • Builds drop-api Docker image
  • Trivy scan: exits 1 on HIGH/CRITICAL unfixed vulnerabilities

9. accessibility — Axe-core WCAG 2.1 AA audit (depends on drop-app-test)

  • Builds app and runs Playwright accessibility tests
  • Uses tests/accessibility/ config

10. sonarcloud — SonarCloud static analysis (depends on drop-app-test + drop-api-test)

  • Runs after any test job succeeds
  • Requires SONAR_TOKEN secret

11. security — Snyk dependency scan (runs always)

  • Scans all projects (--all-projects)
  • Severity threshold: high
  • Requires SNYK_TOKEN secret

11b. semgrep — Semgrep SAST

  • Runs on push to main and PRs targeting main
  • Uploads SARIF to GitHub Security tab
  • Requires SEMGREP_APP_TOKEN secret

11c. gitleaks — Secrets detection

  • Scans full git history (fetch-depth: 0)
  • Requires GITLEAKS_LICENSE secret

12. lighthouse — Lighthouse CI (depends on drop-app-test)

  • Builds app and runs npx lhci autorun
  • Requires LHCI_GITHUB_APP_TOKEN secret

13. quality-gate — Required status check (depends on all above)

  • Aggregates results from all jobs
  • Fails the pipeline if any required job failed
  • This job is configured as the required branch protection status check — PRs cannot merge unless it passes

Coverage Thresholds

MetricThreshold
Statements80%
Branches70%
Functions80%
Lines80%

CoverageTest is enforced by a hard-fail inline script in drop-app-test. PRs also run the ratchet checkprojects:: coverage cannot decrease below the main branch baseline.


Staging Deployment (deploy-staging.yml)

Triggers on push to main or manual dispatch. Runs concurrently with (and independently of) the production deployment.

Flow

  1. Builduser-flows +-- PushBasic user journey tests (staging) — Authenticate to AWS via OIDC, login to ECR, build both images with staging secrets, push with staging-{sha}user-flows.spec.ts tag and staging-latest floating tag.)
  2. Deploy to Staging App Runner — aws apprunner update-servicefull-flows pointing-- toComplete newfeature image,journeys then (aws apprunner start-deploymentfull-flows.spec.ts for both drop-app and drop-api staging services.)
  3. Healthinput-chaos check-- Malicious/edge-case Pollsinput testing (input-chaos.spec.ts). Depends on $STAGING_APP_URL/api/healthuser-flows every 10 seconds, up to 30 attempts (5 minutes). Fails the deployment if health check does not return HTTP 200.

ECRWeb imageserver tags:config: Auto-starts staging-{sha}npm run dev andfor staging-latest

E2E

Requiredtests. secrets:Reuses AWS_ACCOUNT_ID,existing AWS_ROLE_ARN,server APP_RUNNER_STAGING_APP_ARN,if APP_RUNNER_STAGING_API_ARN,running. STAGING_JWT_SECRET,30s STAGING_APP_URLtimeout.


Production Deployment (deploy.yml)Targets

Triggers on push to main. Does NOT cancel in-progress deploys

Fly.io (cancel-in-progress: false). Runs in the production GitHub environment (can require manual approvals if configured).

Flow

  1. Build + Push

    • Authenticate to AWS via OIDC (no long-lived credentials)
    • Record previous ECR image tags (for rollback)
    • Build drop-app with production secrets — Docker build physically includes test stage, so test failure blocks push
    • Build drop-api
    • Push both images to ECR with {sha} and latest tags
    • Sign images with cosign (Sigstore)
  2. Deploy to App Runner

    • aws apprunner start-deployment for both drop-app and drop-api services
  3. Health check + Automatic rollback

    • Polls $NEXT_PUBLIC_APP_URL/api/health every 10 seconds, up to 30 attempts (5 minutes)
    • On health check failure: automatically runs aws apprunner update-service with previous image tag and triggers redeployment of both services
    • Pipeline exits with failure regardless (alerts on failure)
  4. k6 Smoke Test (post-deploy, depends on successful deploy)

    • Installs k6 and runs tests/k6/smoke.js against the production API

ECR image tags: {sha} and latest

Required secrets: AWS_ACCOUNT_ID, AWS_ROLE_ARN, APP_RUNNER_APP_ARN, APP_RUNNER_API_ARN, JWT_SECRET, NEXT_PUBLIC_APP_URL


Full Pipeline: Staging then Production

Both deploy-staging.yml and deploy.yml trigger on push to main. They run in parallel. The staging workflow is designed to catch issues before production traffic is affected.

git push main
  │
  ├── ci.yml (Quality Gate)
  │   └── detect-changes → tests → docker scan → security scans → lighthouse → quality-gate
  │
  ├── deploy-staging.yml
  │   └── build-and-push → deploy staging → health check
  │
  └── deploy.yml
      └── build-and-push (with cosign signing)
          → deploy production
          → health check (+ auto-rollback on failure)
          → k6 smoke test

Hotfix Path (hotfix.yml)

Manual dispatch only. Used when a critical fix must bypass the staging step.

Inputs

InputRequiredDescription
reasonYesReason for hotfix (min 10 chars, logged permanently in Actions log)
skip_e2eNoSkip E2E tests — emergency only, requires Alem approval

Flow

  1. audit-log — Logs actor, SHA, branch, timestamp, reason. Fails if reason is fewer than 10 characters.
  2. test (depends on audit-log, cannot skip) — Lint, type-check, Vitest unit tests
  3. e2e (depends on test, skippable via skip_e2e input) — Full Playwright E2E suite
  4. build-and-push (depends on test + e2e, runs if e2e was success or skipped) — Builds both images with hotfix-{sha} tag, pushes to ECR, signs with cosign
  5. deploy — Updates App Runner services directly (no staging step), health check with automatic rollback on failure

DAST — OWASP ZAP (dast.yml)

Scheduled weekly (Sunday 02:00 UTC) and available for manual dispatch.

  • Runs OWASP ZAP full scan against $STAGING_URL (staging, not production)
  • Uses .zap/rules.tsv for custom rules
  • Uploads HTML report artifact (30-day retention)
  • Uploads SARIF to GitHub Security tab

Mobile CI (mobile-ci.yml)

Triggers on push or PR to main / develop when src/drop-mobile/** changes.

JobsStaging)

lint-and-test

  • Jest tests with coverage threshold enforcement (70%)
  • Posts PR comment with test results

maestro-e2e-ios (runs on macos-14)

  • Builds Expo dev client via npx expo prebuild --platform ios
  • Builds iOS app with xcodebuild targeting iPhone 15 simulator
  • Runs Maestro E2E flows from src/drop-mobile/e2e/flows/
  • Uploads JUnit XML results artifact (14-day retention)

maestro-e2e-android (runs on ubuntu-latest)

  • Builds Expo dev client via npx expo prebuild --platform android
  • Runs in Android emulator (API 33, x86_64, Pixel 6 profile)
  • Runs Maestro E2E flows
  • Uploads JUnit XML results artifact (14-day retention)

Mobile OTA Deploy (mobile-deploy.yml)

Triggers on push to main when src/drop-mobile/** changes.

Flow

  1. Auto-bumps patch version in app.json and package.json (e.g. 1.0.5 → 1.0.6)
  2. Commits version bump with [skip ci] to avoid re-trigger
  3. Runs eas update --channel production — pushes JavaScript bundle OTA to all installed apps on the production channel

Required secrets:Config: EXPO_TOKENfly.toml:1-28


Mobile Store Release (mobile-release.yml)

Triggers on tag push matching mobile-v* (e.g. mobile-v2.0.0).

Flow

  1. Extracts version from tag name
  2. Updates app.json version
  3. eas build --platform all --profile production — builds native iOS and Android binaries via EAS Build
  4. eas submit --platform ios — submits to Apple TestFlight (continues on error if Apple Developer not enrolled)
  5. eas submit --platform android — submits to Google Play Internal Track (continues on error if Play Console not set up)
  6. Updates src/drop-app/src/config/app-versions.json with new version numbers
  7. Creates GitHub Release with summary of submission status

Required secrets: EXPO_TOKEN, APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD


Terraform — Infrastructure (terraform-ci.yml, terraform-plan.yml, terraform-drift.yml)

terraform-ci.yml and terraform-plan.yml

Both files trigger on PRs touching infrastructure/terraform/** and on manual dispatch. They run the same steps:

  1. Terraform format check (terraform fmt -check -recursive, non-blocking)
  2. terraform init
  3. terraform validate
  4. terraform plan -var-file=environments/production/terraform.tfvars
  5. Posts plan output as PR comment (format/init/validate/plan status table + collapsible plan output)
  6. Fails if plan exits with error

Note: terraform-ci.yml and terraform-plan.yml are effectively duplicates. Both are currently active and will both comment on infrastructure PRs. This should be consolidated to a single file.

Required secrets: AWS_ROLE_ARN Terraform version: 1.7.0 Working directory: infrastructure/terraform

terraform-drift.yml

Scheduled weekly (Monday 06:00 UTC) and available for manual dispatch. Detects drift between Terraform state and actual AWS resources.

  • terraform init --backend-config="key=drop/production/terraform.tfstate"
  • terraform plan -detailed-exitcode — exit code 2 means drift was detected
  • On drift: creates a GitHub Issue titled [Drift] Infrastructure drift detected — YYYY-MM-DD tagged infrastructure, drift, with plan output and cc @alem
  • On no drift: logs "No infrastructure drift detected."

Security Checks Summary

CheckToolWhenFails Pipeline
Dependency auditnpm auditEvery CI runYes (high severity)
Dependency scanSnykEvery CI runYes (high severity)
SASTSemgrepPush to main + PRs to mainYes
Container scanTrivyEvery CI run (after tests pass)Yes (HIGH/CRITICAL unfixed)
Secrets detectionGitLeaksEvery CI runYes
DASTOWASP ZAPWeekly (Sunday)No (report only)
Code qualitySonarCloudAfter tests passVia quality-gate job

Branch Protection / Required Status Checks

The quality-gate job in ci.yml is the single required status check for merging PRs to main. It aggregates:

  • drop-app-test (lint, type-check, unit tests, coverage)
  • drop-app-e2e (Playwright)
  • drop-api-test
  • docker-scan-app (Trivy)
  • sonarcloud
  • security (Snyk)
  • lighthouse
  • semgrep
  • gitleaks

A PR cannot merge to main if any of these fail.


Required GitHub Secrets

SecretUsed by
AWS_ACCOUNT_IDECR registry URL construction
AWS_ROLE_ARNOIDC authentication to AWS
APP_RUNNER_APP_ARNProduction drop-app App Runner service
APP_RUNNER_API_ARNProduction drop-api App Runner service
APP_RUNNER_STAGING_APP_ARNStaging drop-app App Runner service
APP_RUNNER_STAGING_API_ARNStaging drop-api App Runner service
JWT_SECRETProduction JWT signing key
STAGING_JWT_SECRETStaging JWT signing key
NEXT_PUBLIC_APP_URLProduction app URL (health check + env)
STAGING_APP_URLStaging app URL (health check)
STAGING_URLDAST scan target
SONAR_TOKENSonarCloud analysis
SNYK_TOKENSnyk dependency scan
SEMGREP_APP_TOKENSemgrep SAST
GITLEAKS_LICENSEGitLeaks secrets detection
LHCI_GITHUB_APP_TOKENLighthouse CI
EXPO_TOKENEAS CLI authentication
APPLE_IDApple App Store submission
APPLE_APP_SPECIFIC_PASSWORDApple App Store submission

Local Development

# StartDeploy to Fly.io staging
fly deploy

# Set secrets
fly secrets set JWT_SECRET="your-secret"
fly secrets set NEXT_PUBLIC_SERVICE_MODE="mock"

Region: arn (Stockholm) Auto-scaling: Scales to 0 when idle, auto-starts on request.

Docker (Self-hosted)

# Local dev (PostgreSQL 16 via DockerDocker)
docker compose up -d

# Apply schema
make db-push
#
Run
unit

Existing testsGitHub cdActions src/drop-appCI &&Workflow

File: .github/workflows/ci.yml

Triggers on push/PR to main or master:

Jobs:
  1. lint-and-typecheck — npm ci, npm run lint, tsc --noEmit
  2. test — npm ci, npm test # Run E2E tests--if-present (startsdepends devon serverlint-and-typecheck)
  automatically)3. cdbuild src/drop-app &&npm ci, npm run build with JWT_SECRET placeholder (depends on lint-and-typecheck)
  4. e2e — npm ci, npx playwright install chromium, npm run build, npm run start (production mode), npx playwright test #user-flows Lint+ cdfull-flows, src/drop-appgenerate &&QA npmreport, runupload lintartifacts #(depends Type-checkon cdbuild)
  src/drop-app5. &&docker-build npx tscdocker build --noEmitt drop-app:ci (depends on test + build + e2e)

Artifacts

References

uploaded:

Not yet implemented:

  • Security scan (npm audit, Snyk)
  • Deploy to staging (fly deploy)
  • Deploy to production (manual approval gate)

Status: Full CI pipeline including E2E tests in place. CD deployment tracked in security hardening checklist (security/hardening-checklist.md:120-126).