|
| 1 | +# Real-backend (LIVE) E2E against PRODUCTION (api.instanode.dev) using an |
| 2 | +# ephemeral, cohort-scoped account minted on the fly. This is the prod sibling |
| 3 | +# of e2e-live.yml (which targets STAGING) — DO NOT delete that one. |
| 4 | +# |
| 5 | +# Plan: docs/sessions/2026-06-04 TEST-ACCOUNTS-AND-NR-SYNTHETICS-PLAN.md. |
| 6 | +# |
| 7 | +# WHY this is safe to run against prod (and e2e-live.yml is not): |
| 8 | +# - The api guards a mint endpoint (PR #260) that creates an account with |
| 9 | +# is_test_cohort=true. The live worker skip-guards neuter |
| 10 | +# billing/churn/email/quota for that team, so a LIVE run can never charge a |
| 11 | +# card, burn a real quota budget, send a "we miss you" email, or churn a |
| 12 | +# real customer. |
| 13 | +# - The account + every resource it creates is reaped: this job DELETEs the |
| 14 | +# minted account (DELETE /internal/e2e/account/{team_id}) AND runs the |
| 15 | +# per-run ledger reaper (npm run reap:live) in an `if: always()` teardown. |
| 16 | +# The reaper exits non-zero on any leak, failing the job loudly (rule 24). |
| 17 | +# - cohort.ts assertSafeApiTarget() ALLOWS a prod E2E_API_URL only when a mint |
| 18 | +# token / minted session is present (a sanctioned run); an un-tokened prod |
| 19 | +# target is still refused, so a stray invocation can never hammer prod. |
| 20 | +# |
| 21 | +# HOW it mints/runs/reaps: |
| 22 | +# 1. MINT — POST https://api.instanode.dev/internal/e2e/account with header |
| 23 | +# X-E2E-Token: $E2E_ACCOUNT_TOKEN and body {"tier":"pro"} → |
| 24 | +# {team_id, user_id, email, tier, session_jwt, expires_at}. The |
| 25 | +# session_jwt + team_id are masked and exported to later steps. |
| 26 | +# 2. RUN — E2E_LIVE=1 E2E_API_URL=https://api.instanode.dev |
| 27 | +# E2E_SESSION_JWT=<minted> npx playwright test |
| 28 | +# --config=playwright.live.config.ts. The authed legs use the |
| 29 | +# minted account (cohort.ts mintedSession()); anon legs run as-is. |
| 30 | +# 3. REAP — (always) DELETE the minted account, then npm run reap:live to |
| 31 | +# sweep any spec-created resources from the on-disk ledger. |
| 32 | +# |
| 33 | +# Triggers: |
| 34 | +# - workflow_dispatch (operator on demand). |
| 35 | +# - schedule every 30 min (continuous prod integration signal). |
| 36 | +# - repository_dispatch type `e2e-prod-from-deploy` (post-deploy hook the api |
| 37 | +# repo can fire after a prod rollout). |
| 38 | +# |
| 39 | +# Guard: if secrets.E2E_ACCOUNT_TOKEN is empty (not yet configured) the job |
| 40 | +# no-ops cleanly with a ::notice:: — it NEVER reds when unconfigured. The |
| 41 | +# workflow ships before the secret exists and goes green only once the operator |
| 42 | +# sets E2E_ACCOUNT_TOKEN and the api mint endpoint is deployed. |
| 43 | + |
| 44 | +name: E2E LIVE (prod, minted account) |
| 45 | + |
| 46 | +on: |
| 47 | + workflow_dispatch: {} |
| 48 | + schedule: |
| 49 | + # Every 30 minutes — continuous prod integration signal. |
| 50 | + - cron: '*/30 * * * *' |
| 51 | + repository_dispatch: |
| 52 | + types: [e2e-prod-from-deploy] |
| 53 | + |
| 54 | +concurrency: |
| 55 | + # One prod LIVE run at a time: they mint a real cohort account + create real |
| 56 | + # resources; overlapping runs could interleave ledger writes / dedup state. |
| 57 | + group: e2e-prod-${{ github.workflow }} |
| 58 | + cancel-in-progress: false |
| 59 | + |
| 60 | +permissions: |
| 61 | + contents: read |
| 62 | + |
| 63 | +jobs: |
| 64 | + e2e-prod: |
| 65 | + name: LIVE against prod via minted account + reap |
| 66 | + runs-on: ubuntu-latest |
| 67 | + timeout-minutes: 15 |
| 68 | + env: |
| 69 | + # Fixed prod target — this workflow is prod-only by design. |
| 70 | + E2E_API_URL: https://api.instanode.dev |
| 71 | + E2E_LIVE_RUN_ID: ${{ github.run_id }} |
| 72 | + # The mint-endpoint guard token. Empty until the operator configures it → |
| 73 | + # the gate step below no-ops the job cleanly. |
| 74 | + E2E_ACCOUNT_TOKEN: ${{ secrets.E2E_ACCOUNT_TOKEN }} |
| 75 | + steps: |
| 76 | + - name: Gate on configured mint token |
| 77 | + # No token configured → no-op cleanly (never a false red). Sets RUN=0 |
| 78 | + # so every later step is skipped. |
| 79 | + run: | |
| 80 | + set -euo pipefail |
| 81 | + if [ -z "${E2E_ACCOUNT_TOKEN:-}" ]; then |
| 82 | + echo "::notice::secrets.E2E_ACCOUNT_TOKEN not configured — skipping prod LIVE E2E (no-op)." |
| 83 | + echo "RUN=0" >> "$GITHUB_ENV" |
| 84 | + else |
| 85 | + echo "RUN=1" >> "$GITHUB_ENV" |
| 86 | + fi |
| 87 | +
|
| 88 | + - uses: actions/checkout@v6 |
| 89 | + if: env.RUN == '1' |
| 90 | + |
| 91 | + - uses: actions/setup-node@v6 |
| 92 | + if: env.RUN == '1' |
| 93 | + with: |
| 94 | + node-version: '22' |
| 95 | + cache: 'npm' |
| 96 | + |
| 97 | + - name: Install deps |
| 98 | + if: env.RUN == '1' |
| 99 | + run: npm ci |
| 100 | + |
| 101 | + - name: Install Chromium |
| 102 | + if: env.RUN == '1' |
| 103 | + run: npx playwright install --with-deps chromium |
| 104 | + |
| 105 | + - name: Mint ephemeral cohort account |
| 106 | + id: mint |
| 107 | + if: env.RUN == '1' |
| 108 | + # POST the guarded mint endpoint → capture session_jwt + team_id, mask |
| 109 | + # them, and export to later steps. Fails the job (non-2xx) so a broken |
| 110 | + # mint endpoint surfaces immediately rather than running un-authed. |
| 111 | + run: | |
| 112 | + set -euo pipefail |
| 113 | + resp="$(curl -sS -w '\n%{http_code}' \ |
| 114 | + -X POST "${E2E_API_URL}/internal/e2e/account" \ |
| 115 | + -H "X-E2E-Token: ${E2E_ACCOUNT_TOKEN}" \ |
| 116 | + -H 'Content-Type: application/json' \ |
| 117 | + -d '{"tier":"pro"}')" |
| 118 | + code="$(printf '%s' "$resp" | tail -n1)" |
| 119 | + body="$(printf '%s' "$resp" | sed '$d')" |
| 120 | + if [ "$code" != "200" ]; then |
| 121 | + echo "::error::mint endpoint returned HTTP $code (expected 200). Body: $body" |
| 122 | + exit 1 |
| 123 | + fi |
| 124 | + jwt="$(printf '%s' "$body" | jq -r '.session_jwt // empty')" |
| 125 | + team="$(printf '%s' "$body" | jq -r '.team_id // empty')" |
| 126 | + email="$(printf '%s' "$body" | jq -r '.email // empty')" |
| 127 | + tier="$(printf '%s' "$body" | jq -r '.tier // empty')" |
| 128 | + if [ -z "$jwt" ] || [ -z "$team" ]; then |
| 129 | + echo "::error::mint response missing session_jwt or team_id. Body: $body" |
| 130 | + exit 1 |
| 131 | + fi |
| 132 | + # Mask the secrets so they never appear in logs. |
| 133 | + echo "::add-mask::$jwt" |
| 134 | + echo "::add-mask::$team" |
| 135 | + # session_jwt + team_id are secret-ish → env only (not step outputs). |
| 136 | + # team_id is also a non-secret output for the reap step's `if`. |
| 137 | + { |
| 138 | + echo "MINTED_SESSION_JWT=$jwt" |
| 139 | + echo "MINTED_TEAM_ID=$team" |
| 140 | + echo "MINTED_EMAIL=$email" |
| 141 | + echo "MINTED_TIER=$tier" |
| 142 | + } >> "$GITHUB_ENV" |
| 143 | + echo "minted=1" >> "$GITHUB_OUTPUT" |
| 144 | + echo "Minted cohort account (tier=$tier) — session + team_id masked." |
| 145 | +
|
| 146 | + - name: Run LIVE E2E against prod (minted account) |
| 147 | + if: env.RUN == '1' && steps.mint.outputs.minted == '1' |
| 148 | + env: |
| 149 | + E2E_LIVE: '1' |
| 150 | + # The minted account drives the authed legs (cohort.ts mintedSession); |
| 151 | + # anon legs run as-is. assertSafeApiTarget() permits the prod target |
| 152 | + # because E2E_SESSION_JWT is present (a sanctioned run). |
| 153 | + E2E_SESSION_JWT: ${{ env.MINTED_SESSION_JWT }} |
| 154 | + E2E_TEAM_ID: ${{ env.MINTED_TEAM_ID }} |
| 155 | + E2E_ACCOUNT_EMAIL: ${{ env.MINTED_EMAIL }} |
| 156 | + E2E_ACCOUNT_TIER: ${{ env.MINTED_TIER }} |
| 157 | + run: npm run test:e2e:live |
| 158 | + |
| 159 | + - name: Reap minted account (teardown) |
| 160 | + # ALWAYS runs (even on test failure/cancel) so the minted account + its |
| 161 | + # resources are deleted out-of-band. Idempotent: 404 == already gone. |
| 162 | + if: always() && env.RUN == '1' && env.MINTED_TEAM_ID != '' |
| 163 | + run: | |
| 164 | + set -euo pipefail |
| 165 | + code="$(curl -sS -o /dev/null -w '%{http_code}' \ |
| 166 | + -X DELETE "${E2E_API_URL}/internal/e2e/account/${MINTED_TEAM_ID}" \ |
| 167 | + -H "X-E2E-Token: ${E2E_ACCOUNT_TOKEN}")" |
| 168 | + case "$code" in |
| 169 | + 200|202|204|404|410) |
| 170 | + echo "Reaped minted account (HTTP $code)." ;; |
| 171 | + *) |
| 172 | + echo "::error::DELETE minted account returned HTTP $code — possible leak." |
| 173 | + exit 1 ;; |
| 174 | + esac |
| 175 | +
|
| 176 | + - name: Reap cohort resources from ledger (teardown) |
| 177 | + # The per-run ledger reaper sweeps any resource a spec created. Exits |
| 178 | + # non-zero on any leak, failing the job loudly (rule 24). |
| 179 | + if: always() && env.RUN == '1' |
| 180 | + run: npm run reap:live |
| 181 | + |
| 182 | + - name: Upload LIVE trace + ledger on failure |
| 183 | + if: failure() && env.RUN == '1' |
| 184 | + uses: actions/upload-artifact@v4 |
| 185 | + with: |
| 186 | + name: e2e-prod-trace-${{ github.run_id }} |
| 187 | + path: | |
| 188 | + test-results/ |
| 189 | + playwright-report-live/ |
| 190 | + e2e/.cleanup-ledger.json |
| 191 | + if-no-files-found: ignore |
| 192 | + retention-days: 14 |
0 commit comments