# ============================================================================= # AI Software Factory — Release v1.0 (TASK-0135, Phase 15) # # 12-stage release pipeline. The TAG IS THE LAST ACT: nothing in this file is # triggered by a tag (the old `push: tags:` trigger is REMOVED — the tag is # created BY stage 12, so a tag push can never re-enter this workflow). # # Trigger ......... workflow_dispatch only: # from_stage 1–12 (default 1) — re-runnable per stage # release_version semver (default 1.0.0) # Stage gate ...... a single `gate` job outputs `from` (the start stage); # stage N runs iff `needs.gate.outputs.from == N`. Every # stage also `needs` every predecessor, so a stage can # never start on a red predecessor (spec rule). # Re-runnability .. `from_stage: K` re-runs stages K..12 only. Stage 12 is # idempotent: it re-pushes the same image tags/digests and # never moves an existing tag. # # Stages: # 1 install+lint+typecheck 7 smoke (Phase 0 smoke vs staging) # 2 unit 8 performance (4 k6/local suites, HARD) # 3 integration 9 backup-health (26h/10min/26h, HARD gate) # 4 security 10 final-e2e (tests/e2e/final-flow.spec.ts) # 5 build 11 docs-completeness (scripts/docs-completeness.mjs) # 6 deploy-staging 12 push+tag (GHCR × 4, annotated tag, # release notes — LAST) # # Permissions: contents: write (tag + release), packages: write (GHCR push), # actions: read (run link for the release notes). # ============================================================================= name: release on: workflow_dispatch: inputs: from_stage: description: "Stage to start at (1-12; re-runnable per stage)" required: false default: "1" type: string release_version: description: "Release version (semver, no leading v)" required: false default: "1.0.0" type: string # A release should never be canceled by a newer dispatch mid-publish. concurrency: group: release-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false permissions: contents: write packages: write actions: read env: REGISTRY: ghcr.io IMAGE_OWNER: ${{ github.repository }} PNPM_VERSION: "9.15.0" # keep in sync with "packageManager" in package.json jobs: # --------------------------------------------------------------------------- # gate — stage orchestrator (validates inputs, computes the start stage). # --------------------------------------------------------------------------- gate: name: "Gate: resolve version + start stage" runs-on: ubuntu-24.04 timeout-minutes: 5 outputs: version: ${{ steps.resolve.outputs.version }} is_patch: ${{ steps.resolve.outputs.is_patch }} from: ${{ steps.resolve.outputs.from }} steps: - name: Resolve version + start stage id: resolve env: RAW_VERSION: ${{ github.event.inputs.release_version }} RAW_FROM: ${{ github.event.inputs.from_stage }} run: | set -euo pipefail V="${RAW_VERSION:-1.0.0}" V="${V#v}" case "$V" in *[!0-9.]*|*..*|.*|*.*) echo "invalid semver version: ${RAW_VERSION:-1.0.0}" >&2; exit 1 ;; esac MAJOR="$(cut -d. -f1 <<<"$V")" MINOR="$(cut -d. -f2 <<<"$V")" PATCH="$(cut -d. -f3 <<<"$V")" if ! [[ "$MAJOR$MINOR$PATCH" =~ ^[0-9]+$ ]]; then echo "version components must be non-negative integers: $V" >&2; exit 1 fi # "Patch bump" = a release on the vX.0.0 line (minor=0 AND patch=0) # → those releases also publish the `latest` tag. if [ "$MINOR" = "0" ] && [ "$PATCH" = "0" ]; then IS_PATCH=true; else IS_PATCH=false; fi # from_stage: clamp into 1..12 (empty/invalid → 1 = full pipeline). F="${RAW_FROM:-1}" if ! [[ "$F" =~ ^[0-9]+$ ]] || [ "$F" -lt 1 ] || [ "$F" -gt 12 ]; then echo "from_stage must be 1..12 (got '${RAW_FROM:-1}') — using 1" >&2 F=1 fi echo "version=$V" >> "$GITHUB_OUTPUT" echo "is_patch=$IS_PATCH" >> "$GITHUB_OUTPUT" echo "from=$F" >> "$GITHUB_OUTPUT" echo "::notice::release=$V (latest-tag: $IS_PATCH) — running stages ${F}..12" # --------------------------------------------------------------------------- # Stage 1 — install+lint+typecheck (hard). # --------------------------------------------------------------------------- stage-1-lint-typecheck: name: "[1/12] install + lint + typecheck" runs-on: ubuntu-24.04 timeout-minutes: 30 needs: [gate] if: needs.gate.outputs.from == 1 steps: - name: Checkout uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: ${{ env.PNPM_VERSION }} - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: Install (frozen lockfile) run: pnpm install --frozen-lockfile - name: ESLint run: pnpm lint - name: Typecheck (tsc strict via turbo) run: pnpm typecheck # --------------------------------------------------------------------------- # Stage 2 — unit (hard): Vitest (packages) + Jest (api). # --------------------------------------------------------------------------- stage-2-unit: name: "[2/12] unit tests" runs-on: ubuntu-24.04 timeout-minutes: 30 needs: [gate, stage-1-lint-typecheck] if: needs.gate.outputs.from == 2 steps: - name: Checkout uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: ${{ env.PNPM_VERSION }} - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: Install (frozen lockfile) run: pnpm install --frozen-lockfile - name: "Unit tests (turbo: vitest packages + jest api)" run: pnpm test:unit # --------------------------------------------------------------------------- # Stage 3 — integration (hard): tests/integration against live services # (same service block + env as the CI integration job). # --------------------------------------------------------------------------- stage-3-integration: name: "[3/12] integration tests" runs-on: ubuntu-24.04 timeout-minutes: 45 needs: [gate, stage-1-lint-typecheck, stage-2-unit] if: needs.gate.outputs.from == 3 services: postgres: image: postgres:16-alpine env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: factory ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres -d factory" --health-interval 5s --health-timeout 5s --health-retries 12 redis: image: redis:7-alpine ports: - 6379:6379 options: >- --health-cmd "redis-cli ping" --health-interval 5s --health-timeout 3s --health-retries 12 minio: image: minio/minio command: server /data --console-address ":9001" env: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin ports: - 9000:9000 options: >- --health-cmd "curl -fsS http://localhost:9000/minio/health/live" --health-interval 5s --health-timeout 5s --health-retries 12 opensearch: image: opensearchproject/opensearch:2 env: discovery.type: single-node plugins.security.disabled: "true" OPENSEARCH_JAVA_OPTS: "-Xms512m -Xmx512m" ports: - 9200:9200 # OpenSearch is SLOW on GitHub runners — wait explicitly (below). env: DATABASE_URL: postgresql://postgres:postgres@localhost:5432/factory?schema=public REDIS_URL: redis://localhost:6379 SEARCH_URL: http://localhost:9200 SEARCH_USERNAME: "" SEARCH_PASSWORD: "" SEARCH_INDEX_PREFIX: factory S3_ENDPOINT: http://localhost:9000 S3_ACCESS_KEY: minioadmin S3_SECRET_KEY: minioadmin S3_BUCKET: factory-uploads S3_REGION: us-east-1 S3_FORCE_PATH_STYLE: "true" # Test-only secrets (never real values). JWT_SECRET: release-test-jwt-secret REFRESH_TOKEN_SECRET: release-test-refresh-secret SESSION_SECRET: release-test-session-secret MASTER_ENCRYPTION_KEY: "cmVsZWFzZS10ZXN0LW1hc3Rlci1rZXktMzJieXRlcyEh" # "release-test-master-key-32bytes!!" steps: - name: Checkout uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: ${{ env.PNPM_VERSION }} - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: Install (frozen lockfile) run: pnpm install --frozen-lockfile - name: Wait for OpenSearch run: | for i in $(seq 1 60); do if curl -sf http://localhost:9200/_cluster/health >/dev/null 2>&1; then echo "opensearch is up after ${i} attempts"; exit 0 fi sleep 5 done echo "opensearch did not start in 300s" >&2; exit 1 - name: Create MinIO bucket + OpenSearch index run: | aws s3api create-bucket \ --endpoint-url http://localhost:9000 \ --bucket factory-uploads \ --region us-east-1 || true curl -sf -X PUT "http://localhost:9200/factory-docs" \ -H 'Content-Type: application/json' -d '{}' || true curl -sf -X POST "http://localhost:9200/factory-docs/_refresh" >/dev/null || true - name: Prisma generate run: pnpm db:generate - name: Apply migrations run: pnpm db:deploy - name: Seed run: pnpm db:seed - name: Integration tests (turbo) run: pnpm test:integration # --------------------------------------------------------------------------- # Stage 4 — security (hard): TASK-0134's 10 suites + pnpm audit + gitleaks. # Mirrors the CI security job (live dev-mode stack: the suites assert dev # behavior — NODE_ENV must NOT be production). # --------------------------------------------------------------------------- stage-4-security: name: "[4/12] security scan" runs-on: ubuntu-24.04 timeout-minutes: 60 needs: [gate, stage-3-integration] if: needs.gate.outputs.from == 4 services: postgres: image: postgres:16-alpine env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: factory ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres -d factory" --health-interval 5s --health-timeout 5s --health-retries 12 redis: image: redis:7-alpine ports: - 6379:6379 options: >- --health-cmd "redis-cli ping" --health-interval 5s --health-timeout 3s --health-retries 12 minio: image: minio/minio command: server /data --console-address ":9001" env: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin ports: - 9000:9000 options: >- --health-cmd "curl -fsS http://localhost:9000/minio/health/live" --health-interval 5s --health-timeout 5s --health-retries 12 opensearch: image: opensearchproject/opensearch:2 env: discovery.type: single-node plugins.security.disabled: "true" OPENSEARCH_JAVA_OPTS: "-Xms512m -Xmx512m" ports: - 9200:9200 env: # Dev-mode stack (the live security suites assert dev behavior: no # HSTS, no Secure cookie on plain-HTTP :4000) — NODE_ENV stays dev. DATABASE_URL: postgresql://postgres:postgres@localhost:5432/factory?schema=public REDIS_URL: redis://localhost:6379 SEARCH_URL: http://localhost:9200 SEARCH_USERNAME: "" SEARCH_PASSWORD: "" SEARCH_INDEX_PREFIX: factory S3_ENDPOINT: http://localhost:9000 S3_ACCESS_KEY: minioadmin S3_SECRET_KEY: minioadmin S3_BUCKET: factory-uploads S3_REGION: us-east-1 S3_FORCE_PATH_STYLE: "true" JWT_SECRET: release-sec-jwt-secret REFRESH_TOKEN_SECRET: release-sec-refresh-secret SESSION_SECRET: release-sec-session-secret MASTER_ENCRYPTION_KEY: "cmVsZWFzZS1zZWMtbWFzdGVyLWtleS0zMmJ5dGVzISE=" INTERNAL_SERVICE_TOKEN: release-sec-internal-service-token INTERNAL_TEST_SEAM: "1" AI_PROVIDER: mock SEARCH_VECTOR_DIMENSION: "8" AI_MOCK_DELTA_DELAY_MS: "60" # Live-app security suites: a down stack FAILS the build (not skips). SEC_REQUIRE_LIVE: "1" # Global rate-limit buckets raised (as in CI) so the other suites don't # trip them; the two fixed buckets (webhook 60/min, form 5/min) are # env-independent and stay live-testable. RATE_LIMIT_API: "100000" RATE_LIMIT_AUTH: "10000" steps: - name: Checkout uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: ${{ env.PNPM_VERSION }} - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: Install (frozen lockfile) run: pnpm install --frozen-lockfile - name: Wait for OpenSearch run: | for i in $(seq 1 60); do if curl -sf http://localhost:9200/_cluster/health >/dev/null 2>&1; then echo "opensearch is up after ${i} attempts"; exit 0 fi sleep 5 done echo "opensearch did not start in 300s" >&2; exit 1 - name: Create MinIO bucket + OpenSearch index run: | aws s3api create-bucket \ --endpoint-url http://localhost:9000 \ --bucket factory-uploads \ --region us-east-1 || true curl -sf -X PUT "http://localhost:9200/factory-docs" \ -H 'Content-Type: application/json' -d '{}' || true - name: Prisma generate + migrate + seed run: | pnpm db:generate pnpm db:deploy pnpm db:seed - name: Start dev stack (background) run: | set -o pipefail nohup pnpm dev > /tmp/factory-dev.log 2>&1 & echo $! > /tmp/factory-dev.pid wait_for_port() { local port="$1" name="$2" for i in $(seq 1 120); do if (exec 3<>"/dev/tcp/127.0.0.1/${port}") 2>/dev/null; then exec 3>&- 3<&- || true echo "${name} is up on :${port} after ${i}s"; return 0 fi sleep 1 done echo "${name} did not come up on :${port} in 120s" >&2 tail -n 100 /tmp/factory-dev.log >&2 || true return 1 } wait_for_port 4000 api wait_for_port 3000 web wait_for_port 4001 realtime - name: pnpm audit (high+ vulnerabilities fail the build) run: pnpm audit --audit-level=high - name: Security suites (10 suites, live stack required) run: pnpm test:security - name: Stop dev stack if: always() run: | if [ -f /tmp/factory-dev.pid ]; then kill "$(cat /tmp/factory-dev.pid)" 2>/dev/null || true pkill -f "turbo" 2>/dev/null || true pkill -f "next dev" 2>/dev/null || true fi tail -n 50 /tmp/factory-dev.log || true - name: Install gitleaks (pinned 8.18.4) run: | curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v8.18.4/gitleaks_8.18.4_linux_x64.tar.gz" | tar -xz -C /usr/local/bin gitleaks gitleaks version - name: Gitleaks — full git history (new secret fails the build) # protect = full history. --baseline-path suppresses exactly the # pre-existing findings documented in gitleaks-baseline.json; ANY # other finding exits non-zero. run: | gitleaks protect \ --source=. \ --config gitleaks.toml \ --baseline-path gitleaks-baseline.json \ --report-format json \ --report-path gitleaks-report.json \ --exit-code 1 echo "full-history scan clean (baseline applied)" # --------------------------------------------------------------------------- # Stage 5 — build (hard): pnpm build (turbo) + the four Docker images built # (NOT yet pushed — stage 12 pushes). Build outputs artifact for stages # 4-artifact consumers and the release notes. # --------------------------------------------------------------------------- stage-5-build: name: "[5/12] build (turbo + docker images)" runs-on: ubuntu-24.04 timeout-minutes: 60 needs: [gate, stage-4-security] if: needs.gate.outputs.from == 5 strategy: fail-fast: false matrix: # Keep in sync with the apps/ directories and the kustomization # images list (infrastructure/kubernetes/kustomization.yaml). app: [api, web, worker, realtime] steps: - name: Checkout uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: ${{ env.PNPM_VERSION }} - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: Install (frozen lockfile) run: pnpm install --frozen-lockfile - name: pnpm build (turbo — all apps + packages) run: pnpm build if: matrix.app == 'api' # once per workflow run (matrix fans the docker part) - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build image (not pushed) uses: docker/build-push-action@v6 with: context: . file: apps/${{ matrix.app }}/Dockerfile push: false tags: ${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ matrix.app }}:${{ needs.gate.outputs.version }} cache-from: type=gha,scope=${{ matrix.app }} cache-to: type=gha,scope=${{ matrix.app }} labels: | org.opencontainers.image.source=${{ github.repositoryUrl }} org.opencontainers.image.revision=${{ github.sha }} org.opencontainers.image.version=${{ needs.gate.outputs.version }} - name: Image build digest run: echo "${{ env.REGISTRY }}/${{ env.IMAGE_OWNER }}/${{ matrix.app }}:${{ needs.gate.outputs.version }} built (push happens in stage 12)" - name: Upload build outputs if: matrix.app == 'api' uses: actions/upload-artifact@v4 with: name: build-outputs path: | apps/api/dist apps/worker/dist apps/realtime/dist if-no-files-found: warn # --------------------------------------------------------------------------- # Stage 6 — deploy-staging (hard: apply + readiness). # # PLACEHOLDER — wire to your cluster before a real release. To activate: # set the repo variable DEPLOY_STAGING=true and a KUBECONFIG (or ArgoCD # token) on the "staging" environment. Until then the step is an explicit # no-op (and fails closed if DEPLOY_STAGING=true but no credentials exist). # --------------------------------------------------------------------------- stage-6-deploy-staging: name: "[6/12] deploy staging (placeholder)" runs-on: ubuntu-24.04 timeout-minutes: 15 needs: [gate, stage-5-build] if: needs.gate.outputs.from == 6 environment: name: staging env: DEPLOY_STAGING: ${{ vars.DEPLOY_STAGING }} steps: - name: Checkout uses: actions/checkout@v4 - name: Check deployment wiring run: | if [ "$DEPLOY_STAGING" != "true" ]; then echo "DEPLOY_STAGING is not 'true' — skipping deployment (placeholder)." echo "To enable: set the repo variable DEPLOY_STAGING=true, add a KUBECONFIG" echo "(or ArgoCD token) to the 'staging' environment, and wire the deploy step." exit 0 fi if [ -z "${KUBECONFIG:-}" ] && [ -z "${ARGOCD_TOKEN:-}" ]; then echo "DEPLOY_STAGING=true but no KUBECONFIG/ARGOCD_TOKEN secret found." >&2 echo "Refusing to guess: add cluster credentials to the staging environment." >&2 exit 1 fi # ── Wire your deployment here ───────────────────────────────── # Example (kustomize): bump the tag and apply: # kustomize edit set image ghcr.io//api=${{ needs.gate.outputs.version }} \ # infrastructure/kubernetes/ # kubectl apply -k infrastructure/kubernetes/ # Example (Argo CD): # argocd app sync factory-staging --force # ───────────────────────────────────────────────────────────── echo "Deployment step not yet wired — see release.yml header. (no-op)" - name: Wait for rollout (10 min budget) # Runs only once deployment is wired: stages 7/8/10 read staging # AFTER the rollout is Ready (spec rule). if: env.DEPLOY_STAGING == 'true' run: | for i in $(seq 1 60); do if kubectl -n factory get deploy api web worker realtime \ -o jsonpath='{range .items[*]}{.status.readyReplicas}/{.status.replicas}{"\n"}{end}' \ | awk -F/ '$1 != $2' | grep -q . ; then sleep 10 else echo "all deployments ready after ${i}0s"; exit 0 fi done echo "rollout not ready in 600s" >&2; exit 1 # --------------------------------------------------------------------------- # Stage 7 — smoke (hard): the Phase 0 smoke suite against staging — # register → login → create page → search finds it. Budget: < 2 min. # --------------------------------------------------------------------------- stage-7-smoke: name: "[7/12] smoke (staging)" runs-on: ubuntu-24.04 timeout-minutes: 10 needs: [gate, stage-6-deploy-staging] if: needs.gate.outputs.from == 7 environment: name: staging env: # Staging base URLs (the smoke hits the deployed stack, not CI services). E2E_BASE_URL: ${{ vars.STAGING_WEB_URL }} E2E_API_URL: ${{ vars.STAGING_API_URL }} steps: - name: Checkout uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: ${{ env.PNPM_VERSION }} - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: Install (frozen lockfile) run: pnpm install --frozen-lockfile - name: "Smoke: login + page create + search (< 2 min)" env: DEPLOY_STAGING: ${{ vars.DEPLOY_STAGING }} run: | set -euo pipefail API="${E2E_API_URL:-http://staging.example.com/api}" echo "smoke target: api=$API" if [ "${DEPLOY_STAGING:-}" != "true" ]; then echo "SKIPPED (loud): staging is not wired (DEPLOY_STAGING != true) — smoke cannot run." exit 1 fi START=$(date +%s) RUN_ID="$(date +%s)-$RANDOM" EMAIL="smoke-${RUN_ID}@factory.local" PASSW0RD='Smoke!2345' jqget() { node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const j=JSON.parse(d);const v=eval('j$1');console.log(v??'')}catch{console.log('')}})"; } # 1 — register (201: user + personal workspace + tokens; httpOnly refresh cookie) REG=$(curl -fsS -c /tmp/smoke.cookies -X POST "${API}/v1/auth/register" \ -H 'content-type: application/json' \ -d "{\"name\":\"Smoke ${RUN_ID}\",\"email\":\"${EMAIL}\",\"password\":\"${PASSW0RD}\"}") WS_ID="$(printf '%s' "$REG" | jqget '.personalWorkspace.id')" [ -n "$WS_ID" ] || { echo "register response missing personalWorkspace.id: ${REG}" >&2; exit 1; } # 2 — login (fresh session + fresh cookie) LOG=$(curl -fsS -c /tmp/smoke.cookies -X POST "${API}/v1/auth/login" \ -H 'content-type: application/json' \ -d "{\"email\":\"${EMAIL}\",\"password\":\"${PASSW0RD}\"}") printf '%s' "$LOG" | grep -q 'accessToken' || { echo "login did not return a token" >&2; exit 1; } # 3 — create a page in the auto-created personal workspace PAGE=$(curl -fsS -b /tmp/smoke.cookies -X POST "${API}/v1/workspaces/${WS_ID}/pages" \ -H 'content-type: application/json' -d "{\"title\":\"Smoke page ${RUN_ID}\"}") PAGE_ID="$(printf '%s' "$PAGE" | jqget '.id')" [ -n "$PAGE_ID" ] || { echo "page create failed: ${PAGE}" >&2; exit 1; } # 4 — search finds the page (GET /search/query = the command-palette # page search, a bare array of {id,type,title}; allow indexing # lag: 3 retries × 5 s) FOUND="" for i in 1 2 3; do sleep 5 FOUND=$(curl -fsS -b /tmp/smoke.cookies "${API}/v1/search/query?q=Smoke+page+${RUN_ID}" || true) printf '%s' "$FOUND" | grep -q "Smoke page ${RUN_ID}" && break done printf '%s' "$FOUND" | grep -q "Smoke page ${RUN_ID}" || { echo "search did not find the page: ${FOUND}" >&2; exit 1; } END=$(date +%s) ELAPSED=$((END - START)) echo "smoke GREEN in ${ELAPSED}s (budget 120s)" [ "$ELAPSED" -lt 120 ] || { echo "smoke exceeded the 2-minute budget (${ELAPSED}s)" >&2; exit 1; } # --------------------------------------------------------------------------- # Stage 8 — performance (hard thresholds): the 4 TASK-0135 suites # (api-load, database-load, realtime-load, editor-local) + the 100k seed. # The dev/PR run of the same suites is report-only; HERE thresholds are # hard (K6_STRICT=1) — a breach blocks the release. # --------------------------------------------------------------------------- stage-8-performance: name: "[8/12] performance (hard)" runs-on: ubuntu-24.04 timeout-minutes: 180 needs: [gate, stage-7-smoke] if: needs.gate.outputs.from == 8 environment: name: staging env: DEPLOY_STAGING: ${{ vars.DEPLOY_STAGING }} # Staging load target (the perf suites run against the deployed stack). K6_API_URL: ${{ vars.STAGING_API_URL }} K6_REALTIME_URL: ${{ vars.STAGING_WS_URL }} K6_EMAIL: demo@factory.local K6_PASSWORD: Factory!2345 # Hard thresholds: a breach FAILS the run (dev/PR is report-only). K6_STRICT: "1" # Seed fixture connects to staging Postgres (least-priv seed identity). DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }} steps: - name: Checkout uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: ${{ env.PNPM_VERSION }} - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: Install (frozen lockfile) run: pnpm install --frozen-lockfile - name: Install k6 (pinned) run: | K6_VERSION="0.50.0" curl -sSfL "https://github.com/grafana/k6/releases/download/v${K6_VERSION}/k6-v${K6_VERSION}-linux-amd64.tar.gz" \ | tar -xz -C /usr/local/bin k6 k6 version - name: Resolve perf fixtures (workspace / page / db ids) if: env.DEPLOY_STAGING == 'true' run: | echo "Perf fixture ids come from the staging seed (demo user's workspace," echo "the seeded 100k database, and the 200 smoke pages). Exported:" echo " K6_WORKSPACE_ID=${{ vars.STAGING_PERF_WORKSPACE_ID }}" echo " K6_DB_ID=${{ vars.STAGING_PERF_DB_ID }}" echo " K6_PAGE_IDS=${{ vars.STAGING_PERF_PAGE_IDS }}" - name: Seed the 100k fixture (idempotent — marker table test_seeds) if: env.DEPLOY_STAGING == 'true' run: pnpm --dir tests/performance seed:100k - name: api-load (1k rps, p95 < 300 ms, errors < 0.1%) if: env.DEPLOY_STAGING == 'true' run: pnpm --dir tests/performance api-load - name: database-load (100k records, table p95 < 500 ms, 100 rps creates) if: env.DEPLOY_STAGING == 'true' run: pnpm --dir tests/performance database-load - name: realtime-load (10k conns / 200 msg/s, update p95 < 200 ms) if: env.DEPLOY_STAGING == 'true' run: pnpm --dir tests/performance realtime-load - name: editor-local (interaction p95 < 50 ms, block move < 16 ms) if: env.DEPLOY_STAGING == 'true' run: | WEB_BASE_URL="${{ vars.STAGING_WEB_URL }}" \ API_BASE_URL="${{ vars.STAGING_API_URL }}" \ pnpm --dir tests/performance editor-local - name: Upload perf reports if: always() uses: actions/upload-artifact@v4 with: name: perf-reports path: tests/performance/reports if-no-files-found: warn - name: Placeholder verdict if: env.DEPLOY_STAGING != 'true' run: | echo "SKIPPED (loud): staging is not wired — performance cannot run against" echo "CI services (the suites target the deployed stack). The DEV/PR pipeline" echo "runs the same suites report-only; this stage is hard-once-staging-exists." exit 1 # --------------------------------------------------------------------------- # Stage 9 — backup-health (hard): the TASK-0135 backup/DR gate (runbook §8.7). # Reads the REAL backup artifacts (no synthetic markers): # - newest object LastModified under pg/basebackups_005/ < 26 h # - newest object LastModified under pg/wal_005/ < 10 min # - newest SUCCESS snapshot start_time in repo factory-s3 < 26 h # Semantics (scripts/backup-health.ts): reachable-but-stale or auth errors # ALWAYS fail; an unreachable/empty endpoint is a loud report-only SKIP # (exit 0) in dev, and a hard FAIL when DEPLOY_STAGING=true / # BACKUP_HEALTH_STRICT=1 — so in staging the gate bites where the backup # stack actually exists. A release whose rollback backup is stale must not # ship. # --------------------------------------------------------------------------- stage-9-backup-health: name: "[9/12] backup health (PITR/OS gate)" runs-on: ubuntu-24.04 timeout-minutes: 10 needs: [gate, stage-8-performance] if: needs.gate.outputs.from == 9 environment: name: staging env: CI: true # Endpoints of the BACKUP stack (the S3 the CronJobs write to + the # OpenSearch the snapshot CronJob targets). Defaults match local dev; # set them in the staging environment to the real values. S3_ENDPOINT: ${{ vars.BACKUP_S3_ENDPOINT }} BACKUP_S3_BUCKET: ${{ vars.BACKUP_S3_BUCKET }} OS_URL: ${{ vars.BACKUP_OS_URL }} # Hard-gate in staging (unreachable = FAIL); report-only in dev. DEPLOY_STAGING: ${{ vars.DEPLOY_STAGING }} steps: - name: Checkout uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: ${{ env.PNPM_VERSION }} - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: Install dependencies run: pnpm install --frozen-lockfile - name: Assert backup freshness (26 h / 10 min / 26 h) env: # Credentials for the backup identity (least-priv, see # infrastructure/kubernetes/postgres/walg-iam-policy.yaml). S3_ACCESS_KEY: ${{ secrets.BACKUP_S3_ACCESS_KEY }} S3_SECRET_KEY: ${{ secrets.BACKUP_S3_SECRET_KEY }} S3_REGION: ${{ vars.BACKUP_S3_REGION }} BACKUP_S3_PG_PREFIX: pg OS_SNAPSHOT_REPO: factory-s3 run: pnpm backup:health # --------------------------------------------------------------------------- # Stage 10 — final-e2e (hard): tests/e2e/final-flow.spec.ts — the 13-step # sequential final flow, against the staging URL. All green = the release # is authorized (the tag step, 12, may now run). # --------------------------------------------------------------------------- stage-10-final-e2e: name: "[10/12] final E2E (13-step flow)" runs-on: ubuntu-24.04 timeout-minutes: 45 needs: [gate, stage-9-backup-health] if: needs.gate.outputs.from == 10 environment: name: staging env: E2E_BASE_URL: ${{ vars.STAGING_WEB_URL }} E2E_API_URL: ${{ vars.STAGING_API_URL }} E2E_INTERNAL_SERVICE_TOKEN: ${{ secrets.STAGING_INTERNAL_SERVICE_TOKEN }} INTERNAL_TEST_SEAM: "1" AI_PROVIDER: mock AI_MOCK_DELTA_DELAY_MS: "60" steps: - name: Checkout uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: ${{ env.PNPM_VERSION }} - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - name: Install (frozen lockfile) run: pnpm install --frozen-lockfile - name: Install Playwright browsers run: pnpm --dir tests/e2e exec playwright install --with-deps chromium - name: Run the 13-step final flow (dedicated config) run: pnpm --dir tests/e2e exec playwright test --config playwright.final-flow.config.ts - name: Upload E2E report if: always() uses: actions/upload-artifact@v4 with: name: final-e2e-report path: tests/e2e/playwright-report if-no-files-found: warn # --------------------------------------------------------------------------- # Stage 11 — docs-completeness (hard): scripts/docs-completeness.mjs # (READMEs per package, OpenAPI + docs/api coverage, ROADMAP Phase 15 # boxes). Release mode: --require-phase-15-ticked — the boxes are ticked # last (by the coordinator, after this release is green), so this stage is # re-run (from_stage: 11) once they land. # --------------------------------------------------------------------------- stage-11-docs-completeness: name: "[11/12] docs completeness" runs-on: ubuntu-24.04 timeout-minutes: 10 needs: [gate, stage-10-final-e2e] if: needs.gate.outputs.from == 11 steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 - name: Run docs-completeness (release mode — Phase 15 boxes ticked) run: node scripts/docs-completeness.mjs --require-phase-15-ticked # --------------------------------------------------------------------------- # Stage 12 — push+tag (hard; THE LAST ACT). # 1. Push the four GHCR images: tag = , plus `latest` for # vX.0.0 releases (is_patch), plus the commit sha. Idempotent — # re-pushing the same tags/digests is a no-op. # 2. Create the ANNOTATED tag v (idempotent: never moves an # existing tag). # 3. Publish the GitHub release (notes: CHANGELOG section + digests + # run links) as a DRAFT (the human publishes — v1.0.0 is deliberate). # --------------------------------------------------------------------------- stage-12-push-tag: name: "[12/12] push + tag + release" runs-on: ubuntu-24.04 timeout-minutes: 45 needs: [gate, stage-11-docs-completeness] if: needs.gate.outputs.from == 12 steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 # full history → the annotated tag can live anywhere - name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Push the four images (version + [latest] + sha) env: VERSION: ${{ needs.gate.outputs.version }} IS_PATCH: ${{ needs.gate.outputs.is_patch }} run: | set -euo pipefail : "${VERSION:?}" for app in api web worker realtime; do IMAGE="${REGISTRY}/${IMAGE_OWNER}/${app}" TAGS="${IMAGE}:${VERSION}" if [ "$IS_PATCH" = "true" ]; then TAGS="${TAGS},${IMAGE}:latest" fi echo "=== ${IMAGE}:${VERSION} ${IS_PATCH:+"(+ latest)"} ===" docker buildx build \ --file "apps/${app}/Dockerfile" \ --tag "$TAGS" \ --push \ --cache-from type=gha,scope="${app}" \ --cache-to type=gha,scope="${app}" \ --label "org.opencontainers.image.source=${GITHUB_REPOSITORY_URL}" \ --label "org.opencontainers.image.revision=${GITHUB_SHA}" \ --label "org.opencontainers.image.version=${VERSION}" \ . done echo "all four images pushed" - name: Registry check (tag + latest exist) env: VERSION: ${{ needs.gate.outputs.version }} IS_PATCH: ${{ needs.gate.outputs.is_patch }} run: | set -euo pipefail for app in api web worker realtime; do IMAGE="${REGISTRY}/${IMAGE_OWNER}/${app}" # `docker manifest inspect` proves the tag is pullable from GHCR. docker manifest inspect "${IMAGE}:${VERSION}" >/dev/null [ "$IS_PATCH" = "true" ] && docker manifest inspect "${IMAGE}:latest" >/dev/null echo "${IMAGE}:${VERSION} present" done echo "registry check: OK" - name: Digests to job summary env: VERSION: ${{ needs.gate.outputs.version }} IS_PATCH: ${{ needs.gate.outputs.is_patch }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail echo "### Published images — v${VERSION}" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" for app in api web worker realtime; do IMAGE="${REGISTRY}/${IMAGE_OWNER}/${app}" DIGEST="$(docker buildx imagetools inspect "${IMAGE}:${VERSION}" 2>/dev/null \ | awk -F': ' '/^ digest:/{print $2}' | head -1)" echo "- \`${IMAGE}:${VERSION}\` — \`${DIGEST:-see build log}\`" >> "$GITHUB_STEP_SUMMARY" [ "$IS_PATCH" = "true" ] && echo "- \`${IMAGE}:latest\` → v${VERSION}" >> "$GITHUB_STEP_SUMMARY" done echo "" >> "$GITHUB_STEP_SUMMARY" echo "commit: \`${GITHUB_SHA}\`" >> "$GITHUB_STEP_SUMMARY" - name: Create annotated tag (idempotent — never moves an existing tag) env: VERSION: ${{ needs.gate.outputs.version }} run: | set -euo pipefail TAG="v${VERSION}" if git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then echo "tag ${TAG} already exists — leaving it exactly where it is (idempotent re-run)" exit 0 fi # The tag is created BY this workflow on the tested commit # (this dispatch's head — the code all 11 preceding stages ran). # The release notes carry the run link, so provenance is explicit. git tag -a "${TAG}" -m "Release v${VERSION}" "${GITHUB_SHA}" git push origin "refs/tags/${TAG}" echo "tagged ${GITHUB_SHA} as ${TAG}" - name: Publish GitHub release (draft) env: VERSION: ${{ needs.gate.outputs.version }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} run: | set -euo pipefail TAG="v${VERSION}" RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" # Notes: the CHANGELOG section for this version (if present) + the # run link + the runbook links. NOTES="" if [ -f CHANGELOG.md ]; then SECTION="$(awk -v v="## [${VERSION}]" 'index($0,v)==1{f=1;next} f&&/^## \[/{exit} f' CHANGELOG.md || true)" NOTES="${SECTION:-_CHANGELOG.md has no [${VERSION}] section — add it._}" fi NOTES="${NOTES} --- - Release run: ${RUN_URL} - Runbooks: [deployment](docs/development/deployment.md) · [admin guide](docs/development/admin-guide.md) · [support matrix](docs/support-matrix.md) " if gh release view "${TAG}" >/dev/null 2>&1; then echo "release ${TAG} exists — leaving it untouched (idempotent re-run)" else gh release create "${TAG}" --title "v${VERSION}" --notes "${NOTES}" --draft echo "draft release ${TAG} created" fi