CI/CD Pipeline
Drop CI/CD Pipeline
Last Updated: 2026-03-04
Source: .github/workflows/ (11 workflow files)
Decision: ADR-012 — AWS App Runner
Overview
Drop uses GitHub Actions for CI/CD. All pipeline logic lives in .github/workflows/. There are 11 workflow files covering quality gates, staging and production deployment, hotfix path, mobile CI/OTA/store releases, and Terraform infrastructure.
Deployment targets:
| Service | Target | Registry |
|---|---|---|
drop-app (Next.js BFF) |
AWS App Runner (eu-west-1) |
Amazon ECR |
drop-api (Hono mobile API) |
AWS App Runner (eu-west-1) |
Amazon ECR |
drop-mobile (Expo React Native) |
Expo EAS (OTA + store builds) | EAS / App Store / Play Store |
| Landing page | Vercel | — |
Workflow Files
| File | Name | Trigger |
|---|---|---|
ci.yml |
CI — Quality Gate | push/PR to main, develop |
deploy-staging.yml |
Deploy to Staging | push to main, manual dispatch |
deploy.yml |
Deploy to Production | push to main |
hotfix.yml |
Hotfix Deploy | manual dispatch only |
dast.yml |
DAST — OWASP ZAP Security Scan | schedule (Sun 02:00 UTC), manual dispatch |
mobile-ci.yml |
Mobile CI — Lint + Test | push/PR to main, develop (paths: src/drop-mobile/**) |
mobile-deploy.yml |
Mobile Deploy — OTA Update | push to main (paths: src/drop-mobile/**) |
mobile-release.yml |
Mobile Release — Store Build + Submit | tag push (mobile-v*) |
terraform-ci.yml |
Terraform Plan | PR touching infrastructure/terraform/**, manual dispatch |
terraform-plan.yml |
Terraform Plan | PR touching infrastructure/terraform/**, manual dispatch |
terraform-drift.yml |
Terraform Drift Detection | schedule (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 ciinsrc/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
mainbaseline - 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-apiDocker 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_TOKENsecret
11. security — Snyk dependency scan (runs always)
- Scans all projects (
--all-projects) - Severity threshold: high
- Requires
SNYK_TOKENsecret
11b. semgrep — Semgrep SAST
- Runs on push to
mainand PRs targetingmain - Uploads SARIF to GitHub Security tab
- Requires
SEMGREP_APP_TOKENsecret
11c. gitleaks — Secrets detection
- Scans full git history (
fetch-depth: 0) - Requires
GITLEAKS_LICENSEsecret
12. lighthouse — Lighthouse CI (depends on drop-app-test)
- Builds app and runs
npx lhci autorun - Requires
LHCI_GITHUB_APP_TOKENsecret
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
| Metric | Threshold |
|---|---|
| Statements | 80% |
| Branches | 70% |
| Functions | 80% |
| Lines | 80% |
Coverage is enforced by a hard-fail inline script in drop-app-test. PRs also run the ratchet check: 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
- Build + Push (staging) — Authenticate to AWS via OIDC, login to ECR, build both images with staging secrets, push with
staging-{sha}tag andstaging-latestfloating tag. - Deploy to Staging App Runner —
aws apprunner update-servicepointing to new image, thenaws apprunner start-deploymentfor bothdrop-appanddrop-apistaging services. - Health check — Polls
$STAGING_APP_URL/api/healthevery 10 seconds, up to 30 attempts (5 minutes). Fails the deployment if health check does not return HTTP 200.
Required secrets: AWS_ACCOUNT_ID, AWS_ROLE_ARN, APP_RUNNER_STAGING_APP_ARN, APP_RUNNER_STAGING_API_ARN, STAGING_JWT_SECRET, STAGING_APP_URL
Production Deployment (deploy.yml)
Triggers on push to main. Does NOT cancel in-progress deploys (cancel-in-progress: false). Runs in the production GitHub environment (can require manual approvals if configured).
Flow
-
Build + Push
- Authenticate to AWS via OIDC (no long-lived credentials)
- Record previous ECR image tags (for rollback)
- Build
drop-appwith production secrets — Docker build physically includes test stage, so test failure blocks push - Build
drop-api - Push both images to ECR with
{sha}andlatesttags - Sign images with cosign (Sigstore)
-
Deploy to App Runner
aws apprunner start-deploymentfor bothdrop-appanddrop-apiservices
-
Health check + Automatic rollback
- Polls
$NEXT_PUBLIC_APP_URL/api/healthevery 10 seconds, up to 30 attempts (5 minutes) - On health check failure: automatically runs
aws apprunner update-servicewith previous image tag and triggers redeployment of both services - Pipeline exits with failure regardless (alerts on failure)
- Polls
-
k6 Smoke Test (post-deploy, depends on successful deploy)
- Installs k6 and runs
tests/k6/smoke.jsagainst the production API
- Installs k6 and runs
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
| Input | Required | Description |
|---|---|---|
reason |
Yes | Reason for hotfix (min 10 chars, logged permanently in Actions log) |
skip_e2e |
No | Skip E2E tests — emergency only, requires Alem approval |
Flow
- audit-log — Logs actor, SHA, branch, timestamp, reason. Fails if reason is fewer than 10 characters.
- test (depends on audit-log, cannot skip) — Lint, type-check, Vitest unit tests
- e2e (depends on test, skippable via
skip_e2einput) — Full Playwright E2E suite - 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 - 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.tsvfor 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.
Jobs
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
- Auto-bumps patch version in
app.jsonandpackage.json(e.g.1.0.5→1.0.6) - Commits version bump with
[skip ci]to avoid re-trigger - Runs
eas update --channel production— pushes JavaScript bundle OTA to all installed apps on the production channel
Required secrets: EXPO_TOKEN
Mobile Store Release (mobile-release.yml)
Triggers on tag push matching mobile-v* (e.g. mobile-v2.0.0).
Flow
- Extracts version from tag name
- Updates
app.jsonversion eas build --platform all --profile production— builds native iOS and Android binaries via EAS Buildeas submit --platform ios— submits to Apple TestFlight (continues on error if Apple Developer not enrolled)eas submit --platform android— submits to Google Play Internal Track (continues on error if Play Console not set up)- Updates
src/drop-app/src/config/app-versions.jsonwith new version numbers - 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:
- Terraform format check (
terraform fmt -check -recursive, non-blocking) terraform initterraform validateterraform plan -var-file=environments/production/terraform.tfvars- Posts plan output as PR comment (format/init/validate/plan status table + collapsible plan output)
- 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-DDtaggedinfrastructure,drift, with plan output andcc @alem - On no drift: logs "No infrastructure drift detected."
Security Checks Summary
| Check | Tool | When | Fails Pipeline |
|---|---|---|---|
| Dependency audit | npm audit | Every CI run | Yes (high severity) |
| Dependency scan | Snyk | Every CI run | Yes (high severity) |
| SAST | Semgrep | Push to main + PRs to main | Yes |
| Container scan | Trivy | Every CI run (after tests pass) | Yes (HIGH/CRITICAL unfixed) |
| Secrets detection | GitLeaks | Every CI run | Yes |
| DAST | OWASP ZAP | Weekly (Sunday) | No (report only) |
| Code quality | SonarCloud | After tests pass | Via 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-testdocker-scan-app(Trivy)sonarcloudsecurity(Snyk)lighthousesemgrepgitleaks
A PR cannot merge to main if any of these fail.
Required GitHub Secrets
| Secret | Used by |
|---|---|
AWS_ACCOUNT_ID |
ECR registry URL construction |
AWS_ROLE_ARN |
OIDC authentication to AWS |
APP_RUNNER_APP_ARN |
Production drop-app App Runner service |
APP_RUNNER_API_ARN |
Production drop-api App Runner service |
APP_RUNNER_STAGING_APP_ARN |
Staging drop-app App Runner service |
APP_RUNNER_STAGING_API_ARN |
Staging drop-api App Runner service |
JWT_SECRET |
Production JWT signing key |
STAGING_JWT_SECRET |
Staging JWT signing key |
NEXT_PUBLIC_APP_URL |
Production app URL (health check + env) |
STAGING_APP_URL |
Staging app URL (health check) |
STAGING_URL |
DAST scan target |
SONAR_TOKEN |
SonarCloud analysis |
SNYK_TOKEN |
Snyk dependency scan |
SEMGREP_APP_TOKEN |
Semgrep SAST |
GITLEAKS_LICENSE |
GitLeaks secrets detection |
LHCI_GITHUB_APP_TOKEN |
Lighthouse CI |
EXPO_TOKEN |
EAS CLI authentication |
APPLE_ID |
Apple App Store submission |
APPLE_APP_SPECIFIC_PASSWORD |
Apple App Store submission |
Local Development
# Start PostgreSQL via Docker
docker compose up -d
# Apply schema
make db-push
# Run unit tests
cd src/drop-app && npm test
# Run E2E tests (starts dev server automatically)
cd src/drop-app && npx playwright test
# Lint
cd src/drop-app && npm run lint
# Type-check
cd src/drop-app && npx tsc --noEmit