Repository navigation
feat: add price feed worker and circuit breaker #457
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| # Cancel in-progress runs for the same branch/PR | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| secrets-scan: | |
| name: Secrets Scanning (Gitleaks) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| with: | |
| fetch-depth: 0 | |
| - name: Gitleaks Scan | |
| uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9 | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| vulnerability-scan: | |
| name: Dependency Vulnerability Audit | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 20 | |
| cache: npm | |
| - run: npm audit --audit-level=high | |
| license-scan: | |
| name: Dependency License Compliance | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 20 | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci | |
| # Permissive-only allow-list, reconciled with the project's own | |
| # MIT/Apache-2.0 license (issue #107). Fails the build on any | |
| # dependency (direct or transitive) carrying a copyleft or | |
| # unrecognized/unlicensed license. | |
| - name: Check dependency licenses | |
| run: | | |
| npx license-checker --production \ | |
| --onlyAllow 'MIT;Apache-2.0;BSD-2-Clause;BSD-3-Clause;ISC;0BSD;CC0-1.0;Unlicense' \ | |
| --excludePrivatePackages | |
| commitlint: | |
| name: Commit Message Lint | |
| runs-on: ubuntu-latest | |
| # Only meaningful on PRs — on direct pushes to main we skip this check | |
| # since squash merges are handled by the branch protection rule. | |
| if: github.event_name == 'pull_request' | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| with: | |
| # Fetch enough history to validate all commits in the PR. | |
| fetch-depth: 0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 20 | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci | |
| # Lint every commit in the PR from the merge base to HEAD. | |
| - name: Lint commit messages | |
| run: | | |
| npx commitlint \ | |
| --from ${{ github.event.pull_request.base.sha }} \ | |
| --to ${{ github.event.pull_request.head.sha }} \ | |
| --verbose | |
| backend: | |
| # The en dash in the name is not cosmetic: branch protection stores the | |
| # exact check name as a string, so replacing it with a hyphen renames a | |
| # required check and blocks every PR until an admin edits the rule. | |
| name: Backend (Nest) – Node ${{ matrix.node-version }} | |
| runs-on: ubuntu-latest | |
| strategy: | |
| matrix: | |
| node-version: [20, 22] | |
| # Spin up a PostgreSQL service container so Prisma can connect during the | |
| # migrate step (migrate deploy requires a live DB). | |
| services: | |
| postgres: | |
| image: postgres:16-alpine | |
| env: | |
| POSTGRES_USER: vortex | |
| POSTGRES_PASSWORD: vortex | |
| POSTGRES_DB: vortex | |
| ports: | |
| - 5432:5432 | |
| # Wait until postgres is healthy before the steps run. | |
| options: >- | |
| --health-cmd="pg_isready -U vortex" | |
| --health-interval=10s | |
| --health-timeout=5s | |
| --health-retries=5 | |
| env: | |
| DATABASE_URL: postgresql://vortex:vortex@localhost:5432/vortex?schema=public | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| # ── Node / npm cache (#130) ────────────────────────────────────────── | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: ${{ matrix.node-version }} | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci | |
| # ── Env drift check ──────────────────────────────────────────────────── | |
| # Fast, early-failing check that env.validation.ts, .env*.example, and | |
| # configuration.ts's process.env reads all agree on the same variable set. | |
| - name: Check env var drift | |
| run: npx tsx scripts/check-env-drift.ts | |
| # ── Prisma ────────────────────────────────────────────────────────────── | |
| - name: Validate Prisma schema | |
| run: npm run db:validate | |
| # `npm ci` deletes node_modules, so the generated client has to be cached | |
| # *after* the install, not before. The key includes package-lock.json as | |
| # well as the schema: the client is also regenerated when the Prisma | |
| # version moves, and a client generated by a different version fails at | |
| # runtime rather than at generate time. | |
| - name: Restore cached Prisma client | |
| uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 | |
| with: | |
| path: node_modules/.prisma | |
| key: prisma-${{ runner.os }}-node${{ matrix.node-version }}-${{ hashFiles('prisma/schema.prisma', 'package-lock.json') }} | |
| - name: Generate Prisma client | |
| run: npm run db:generate | |
| # Apply all pending migrations to the CI database so the schema stays in | |
| # sync and any broken migration SQL is caught before merge. | |
| - name: Run database migrations | |
| run: npm run db:migrate:prod | |
| # ── Build & static checks ────────────────────────────────────────────── | |
| - name: Lint | |
| run: npm run lint | |
| - name: Type-check | |
| run: npm run typecheck | |
| # dist/ is keyed on everything tsc reads, so a hit means the restored | |
| # output is what this commit's build would produce anyway. There is | |
| # deliberately no restore-keys prefix fallback: a partially-restored | |
| # dist/ from a different commit is exactly the stale-artifact bug this | |
| # cache must not be able to introduce. | |
| - name: Restore cached build output | |
| uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 | |
| with: | |
| path: dist | |
| key: dist-${{ runner.os }}-node${{ matrix.node-version }}-${{ hashFiles('src/**/*.ts', 'package.json', 'tsconfig.json', 'nest-cli.json') }} | |
| - name: Build | |
| run: npm run build | |
| # Unit and E2E tests deliberately do NOT run here. They are sharded into | |
| # the `unit-tests`, `e2e-tests` and `coverage` jobs below (issue #486) so | |
| # the suite fans out across runners instead of serialising behind lint | |
| # and build on both Node versions. This job's name is a required status | |
| # check, so it stays exactly as it was and keeps meaning "the backend | |
| # compiles and passes static analysis on Node X". | |
| dast: | |
| name: DAST API scan (OWASP ZAP) | |
| runs-on: ubuntu-latest | |
| needs: backend | |
| timeout-minutes: 15 | |
| env: | |
| DATABASE_URL: postgresql://vortex:vortex@localhost:5432/vortex?schema=public | |
| NODE_ENV: test | |
| PORT: 4000 | |
| CORS_ORIGIN: "*" | |
| services: | |
| postgres: | |
| image: postgres:16-alpine | |
| env: | |
| POSTGRES_USER: vortex | |
| POSTGRES_PASSWORD: vortex | |
| POSTGRES_DB: vortex | |
| ports: | |
| - 5432:5432 | |
| options: >- | |
| --health-cmd="pg_isready -U vortex" | |
| --health-interval=10s | |
| --health-timeout=5s | |
| --health-retries=5 | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 20 | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Generate Prisma client | |
| run: npm run db:generate | |
| - name: Run database migrations | |
| run: npm run db:migrate:prod | |
| - name: Build backend | |
| run: npm run build | |
| - name: Start backend for ZAP scan | |
| run: >- | |
| npm run start > /tmp/vortex-backend.log 2>&1 & | |
| - name: Wait for backend to become healthy | |
| run: | | |
| for i in $(seq 1 60); do | |
| if curl -fsS http://localhost:${PORT}/health >/dev/null; then | |
| echo "Backend healthy"; exit 0; | |
| fi | |
| sleep 2; | |
| done | |
| echo "Backend never became healthy" >&2 | |
| cat /tmp/vortex-backend.log || true | |
| exit 1 | |
| - name: Run OWASP ZAP API scan | |
| run: | | |
| docker run --rm \ | |
| --network host \ | |
| -v "$PWD/.github/zap:/zap/wrk:ro" \ | |
| -v "$PWD:/zap/output:rw" \ | |
| owasp/zap2docker-stable \ | |
| zap-api-scan.py \ | |
| -t http://localhost:${PORT}/docs-json \ | |
| -f openapi \ | |
| -m 10 \ | |
| -J /zap/output/zap-report.json \ | |
| -r /zap/output/zap-report.html \ | |
| -x /zap/output/zap-report.xml \ | |
| \ | |
| || true | |
| - name: Fail on medium+ findings | |
| run: | | |
| python - <<'PY' | |
| import json, os, sys | |
| path = "zap-report.json" | |
| if not os.path.exists(path): | |
| print("No ZAP report generated; failing the job.") | |
| sys.exit(1) | |
| data = json.load(open(path, encoding="utf-8")) | |
| findings = [] | |
| for site in data.get("site", []): | |
| for alert in site.get("alerts", []): | |
| risk = int(alert.get("riskcode", "0")) | |
| if risk >= 2: | |
| findings.append({ | |
| "name": alert.get("name"), | |
| "risk": alert.get("riskdesc", "Unknown"), | |
| "url": alert.get("url", "unknown"), | |
| }) | |
| rules = json.load(open("zap-report.json", encoding="utf-8")) if False else None | |
| if findings: | |
| print("Medium+ ZAP findings detected:") | |
| for item in findings: | |
| print(f"- {item['name']} ({item['risk']}) @ {item['url']}") | |
| sys.exit(1) | |
| print("No medium+ ZAP findings detected.") | |
| PY | |
| - name: Convert ZAP results to SARIF | |
| if: always() | |
| run: | | |
| python - <<'PY' | |
| import json | |
| import os | |
| src = "zap-report.json" | |
| out = "zap-report.sarif" | |
| if not os.path.exists(src): | |
| print("No ZAP results to convert; writing an empty SARIF file.") | |
| empty = { | |
| "$schema": "https://json.schemastore.org/sarif-2.1.0.json", | |
| "version": "2.1.0", | |
| "runs": [{ | |
| "tool": {"driver": {"name": "OWASP ZAP", "informationUri": "https://www.zaproxy.org/", "rules": []}}, | |
| "results": [] | |
| }] | |
| } | |
| json.dump(empty, open(out, "w", encoding="utf-8")) | |
| raise SystemExit(0) | |
| data = json.load(open(src, encoding="utf-8")) | |
| results = [] | |
| rules = [] | |
| seen = set() | |
| for site in data.get("site", []): | |
| for alert in site.get("alerts", []): | |
| rule_id = alert.get("pluginid", alert.get("alert", "unknown")) | |
| if rule_id not in seen: | |
| seen.add(rule_id) | |
| rules.append({ | |
| "id": str(rule_id), | |
| "shortDescription": {"text": alert.get("name", "OWASP ZAP alert")}, | |
| "fullDescription": {"text": alert.get("desc", "Alert reported by OWASP ZAP")}, | |
| "defaultConfiguration": {"level": "warning"}, | |
| "helpUri": "https://www.zaproxy.org/docs/alerts/" | |
| }) | |
| level = "warning" | |
| risk = alert.get("riskdesc", "Informational") | |
| if "High" in risk: | |
| level = "error" | |
| elif "Medium" in risk: | |
| level = "warning" | |
| results.append({ | |
| "ruleId": str(rule_id), | |
| "level": level, | |
| "message": {"text": alert.get("name", "OWASP ZAP alert")}, | |
| "locations": [{"physicalLocation": {"artifactLocation": {"uri": alert.get("url", "http://localhost")}}}], | |
| "properties": {"issue_confidence": alert.get("confidence", "Medium")} | |
| }) | |
| sarif = { | |
| "$schema": "https://json.schemastore.org/sarif-2.1.0.json", | |
| "version": "2.1.0", | |
| "runs": [{ | |
| "tool": {"driver": {"name": "OWASP ZAP", "informationUri": "https://www.zaproxy.org/", "rules": rules}}, | |
| "results": results | |
| }] | |
| } | |
| json.dump(sarif, open(out, "w", encoding="utf-8")) | |
| PY | |
| - name: Upload SARIF to GitHub code scanning | |
| if: always() | |
| uses: github/codeql-action/upload-sarif@v3 | |
| with: | |
| sarif_file: zap-report.sarif | |
| # ── Sharded test fan-out (issue #486) ─────────────────────────────────── | |
| # Three jobs replace the old `npm test` + `npm run test:e2e` pair: | |
| # unit-tests -> N Jest shards, each writing its own coverage + flake report | |
| # e2e-tests -> M Jest shards against the migrated Postgres service | |
| # coverage -> merges the shard reports and enforces the 70% gate | |
| # | |
| # fail-fast is off on both matrices on purpose: with it on, the first failing | |
| # shard cancels its siblings, their coverage artifacts are never uploaded, | |
| # and the merge job reports "a shard never wrote its report" instead of the | |
| # real failure. Every shard has to finish so the coverage merge is complete. | |
| unit-tests: | |
| name: Unit tests (shard ${{ matrix.shard }}/${{ matrix.shard-total }}) | |
| runs-on: ubuntu-latest | |
| needs: backend | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| shard: [1, 2, 3, 4] | |
| shard-total: [4] | |
| services: | |
| postgres: | |
| image: postgres:16-alpine | |
| env: | |
| POSTGRES_USER: vortex | |
| POSTGRES_PASSWORD: vortex | |
| POSTGRES_DB: vortex | |
| ports: | |
| - 5432:5432 | |
| options: >- | |
| --health-cmd="pg_isready -U vortex" | |
| --health-interval=10s | |
| --health-timeout=5s | |
| --health-retries=5 | |
| env: | |
| DATABASE_URL: postgresql://vortex:vortex@localhost:5432/vortex?schema=public | |
| # Opts the Postgres repository contract suite in | |
| # (src/intents/prisma-intents.repository.spec.ts — issue #404). | |
| TEST_DATABASE_URL: postgresql://vortex:vortex@localhost:5432/vortex?schema=public | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| # Tests run on the lowest supported Node only. Running the same 35 | |
| # spec files on 20 and 22 doubles CI minutes to re-test runtime | |
| # behaviour the build job already proves compiles on both; the | |
| # version-specific risk is a native module, which `npm run build` | |
| # loads on both anyway. | |
| node-version: 20 | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci | |
| # Same Prisma client cache as the backend job: generate-and-migrate is | |
| # the slowest fixed cost in a test shard, and the key is content-addressed | |
| # so a schema or lockfile change invalidates it on its own. | |
| - name: Restore cached Prisma client | |
| uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 | |
| with: | |
| path: node_modules/.prisma | |
| key: prisma-${{ runner.os }}-node20-${{ hashFiles('prisma/schema.prisma', 'package-lock.json') }} | |
| - name: Generate Prisma client | |
| run: npm run db:generate | |
| - name: Run database migrations | |
| run: npm run db:migrate:prod | |
| # run-tests.mjs applies test/quarantine.json, writes the shard's | |
| # Istanbul report to its own directory (so shards cannot clobber each | |
| # other), and retries only the failing files to separate a flake from a | |
| # real failure. | |
| - name: Run unit test shard | |
| run: >- | |
| node scripts/ci/run-tests.mjs | |
| --suite=unit | |
| --shard=${{ matrix.shard }}/${{ matrix.shard-total }} | |
| --out-dir=ci-artifacts/unit-s${{ matrix.shard }} | |
| --coverage-dir=coverage-shards/unit-s${{ matrix.shard }} | |
| - name: Upload shard coverage | |
| if: always() | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: coverage-unit-s${{ matrix.shard }} | |
| path: coverage-shards/unit-s${{ matrix.shard }} | |
| if-no-files-found: warn | |
| - name: Upload flake report and Jest results | |
| if: always() | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: unit-s${{ matrix.shard }}-logs | |
| path: ci-artifacts/unit-s${{ matrix.shard }} | |
| if-no-files-found: warn | |
| e2e-tests: | |
| name: E2E tests (shard ${{ matrix.shard }}/${{ matrix.shard-total }}) | |
| runs-on: ubuntu-latest | |
| needs: backend | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| shard: [1, 2] | |
| shard-total: [2] | |
| services: | |
| postgres: | |
| image: postgres:16-alpine | |
| env: | |
| POSTGRES_USER: vortex | |
| POSTGRES_PASSWORD: vortex | |
| POSTGRES_DB: vortex | |
| ports: | |
| - 5432:5432 | |
| options: >- | |
| --health-cmd="pg_isready -U vortex" | |
| --health-interval=10s | |
| --health-timeout=5s | |
| --health-retries=5 | |
| env: | |
| DATABASE_URL: postgresql://vortex:vortex@localhost:5432/vortex?schema=public | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 20 | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci | |
| # Same Prisma client cache as the backend job: generate-and-migrate is | |
| # the slowest fixed cost in a test shard, and the key is content-addressed | |
| # so a schema or lockfile change invalidates it on its own. | |
| - name: Restore cached Prisma client | |
| uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 | |
| with: | |
| path: node_modules/.prisma | |
| key: prisma-${{ runner.os }}-node20-${{ hashFiles('prisma/schema.prisma', 'package-lock.json') }} | |
| - name: Generate Prisma client | |
| run: npm run db:generate | |
| - name: Run database migrations | |
| run: npm run db:migrate:prod | |
| # Anvil for the EVM deposit-verification integration test (issue #403, | |
| # test/evm/deposit-verifier.anvil.test.ts — skipped when anvil is absent). | |
| - name: Install Foundry (anvil) | |
| uses: foundry-rs/foundry-toolchain@v1 | |
| # No --coverage-dir here. test/jest-e2e.json has no collectCoverageFrom, | |
| # so its coverage map has different file keys than the unit config's; | |
| # merging the two would trip the shard-consistency check in | |
| # coverage-merge.mjs. The coverage gate is measured on the unit shards, | |
| # which is also where nearly all of the code is exercised. | |
| - name: Run E2E test shard | |
| run: >- | |
| node scripts/ci/run-tests.mjs | |
| --suite=e2e | |
| --shard=${{ matrix.shard }}/${{ matrix.shard-total }} | |
| --out-dir=ci-artifacts/e2e-s${{ matrix.shard }} | |
| - name: Upload flake report and Jest results | |
| if: always() | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: e2e-s${{ matrix.shard }}-logs | |
| path: ci-artifacts/e2e-s${{ matrix.shard }} | |
| if-no-files-found: warn | |
| # ── E2E against the Postgres-backed intents store (issue #404) ───────────── | |
| # The e2e-tests shards use the default in-memory store. This runs the same | |
| # suite, including the concurrency and POST /intents latency load tests, | |
| # with INTENTS_STORE=postgres. --runInBand: suites share one database, so | |
| # parallel workers would race on table-wide counts (e.g. the stats deltas). | |
| e2e-postgres-store: | |
| name: E2E tests (INTENTS_STORE=postgres) | |
| runs-on: ubuntu-latest | |
| needs: backend | |
| services: | |
| postgres: | |
| image: postgres:16-alpine | |
| env: | |
| POSTGRES_USER: vortex | |
| POSTGRES_PASSWORD: vortex | |
| POSTGRES_DB: vortex | |
| ports: | |
| - 5432:5432 | |
| options: >- | |
| --health-cmd="pg_isready -U vortex" | |
| --health-interval=10s | |
| --health-timeout=5s | |
| --health-retries=5 | |
| env: | |
| DATABASE_URL: postgresql://vortex:vortex@localhost:5432/vortex?schema=public | |
| INTENTS_STORE: postgres | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 20 | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Generate Prisma client | |
| run: npm run db:generate | |
| - name: Run database migrations | |
| run: npm run db:migrate:prod | |
| - name: Install Foundry (anvil) | |
| uses: foundry-rs/foundry-toolchain@v1 | |
| - name: E2E tests | |
| run: npm run test:e2e -- --runInBand | |
| coverage: | |
| name: Coverage merge and gate | |
| runs-on: ubuntu-latest | |
| needs: [unit-tests, e2e-tests] | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 20 | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci | |
| # Without merge-multiple, each artifact lands in its own subdirectory of | |
| # the target path, so the four shards arrive as | |
| # coverage-shards/coverage-unit-s1..4/coverage-final.json -- exactly the | |
| # one-directory-deep layout coverage-merge.mjs scans. | |
| - name: Download shard coverage | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| with: | |
| pattern: coverage-unit-s* | |
| path: coverage-shards | |
| - name: Download flake reports | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 | |
| with: | |
| pattern: "*-logs" | |
| path: ci-artifacts | |
| # The 70% gate is enforced here, on the union, and never per shard: a | |
| # shard that runs a ninth of the suite cannot meet a global threshold. | |
| # The threshold itself is read from jest.config.js, so this stays a single | |
| # definition rather than a second copy in the workflow. | |
| - name: Merge shard coverage and enforce threshold | |
| run: >- | |
| node scripts/ci/coverage-merge.mjs | |
| --shards=coverage-shards | |
| --out=coverage | |
| --expect-shards=4 | |
| --title="Coverage (merged from 4 unit test shards)" | |
| - name: Upload merged coverage | |
| if: always() | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: coverage-merged | |
| path: coverage | |
| if-no-files-found: warn | |
| # Pure Node, no npm ci: the quarantine file is the one piece of test | |
| # infrastructure that can be validated before dependencies are installed, so | |
| # it is also usable as a pre-commit guard (scripts/ci/check-quarantine.mjs). | |
| test-hygiene: | |
| name: Quarantine file hygiene | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 20 | |
| # Fails on a missing owner/issue, a path that no longer exists, a | |
| # duplicate entry, or an entry older than staleAfterDays. | |
| - name: Validate test/quarantine.json | |
| run: node scripts/ci/check-quarantine.mjs | |
| # ── TLA+ model checking (issue #471) ───────────────────────────────────── | |
| # Bounded TLC run on changes to the spec or the canonical state machine. | |
| formal: | |
| name: TLA+ model check (TLC, bounded) | |
| runs-on: ubuntu-latest | |
| if: >- | |
| github.event_name == 'pull_request' && | |
| (contains(join(github.event.pull_request.labels.*.name, ','), 'formal') || | |
| true) | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| - name: Run TLC (bounded model) | |
| uses: docker://tlaplus/tlaplus:latest | |
| with: | |
| args: tlc -config formal/IntentLifecycle.cfg formal/IntentLifecycle.tla | |
| - name: Upload TLC output | |
| if: always() | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: tlc-output | |
| path: formal/*.out | |
| # ── Semgrep custom rules (issue #478) ──────────────────────────────────── | |
| semgrep: | |
| name: Semgrep custom rules | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| - uses: returntocorp/semgrep-action@713efdd345f3035192eaa63f56867b88e63e4e5d # v1 | |
| with: | |
| config: .semgrep/rules | |
| error: true | |
| - name: Semgrep rule tests | |
| run: | | |
| pip install semgrep | |
| semgrep --test --config .semgrep/rules .semgrep/tests | |
| # ── Prometheus rules (issue #480) ──────────────────────────────────────── | |
| promtool: | |
| name: promtool check + test | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| - name: Install promtool | |
| run: | | |
| curl -sL https://github.com/prometheus/prometheus/releases/download/v2.53.0/prometheus-2.53.0.linux-amd64.tar.gz | tar xz | |
| sudo mv prometheus-2.53.0.linux-amd64/promtool /usr/local/bin/ | |
| - name: Check rules | |
| run: promtool check rules ops/prometheus/rules/vortex-slo.yml ops/prometheus/rules/vortex-canary.yml | |
| - name: Unit-test rules | |
| run: promtool test rules ops/prometheus/rules/vortex-slo_test.yml ops/prometheus/rules/vortex-canary_test.yml | |
| - name: Check rule file is also a valid Prometheus config fragment | |
| run: promtool check config ops/prometheus/prometheus.yml | |
| # ── Grafana dashboards (issue #481) ─────────────────────────────────────── | |
| # Two things are checked, neither of which needs a Grafana instance: | |
| # 1. build.mjs --check proves the committed dashboard JSON is exactly what | |
| # the generator produces, so a hand-edited panel cannot drift. | |
| # 2. validate.mjs proves the panels are usable: every recording rule and | |
| # metric a panel names exists, every panel has a legend, description, | |
| # unit and threshold set, no panel reintroduces a multi-day | |
| # histogram_quantile, and every alert links to a dashboard that exists. | |
| # | |
| # Both are plain Node with no dependencies, which is the whole point of | |
| # generating the JSON ourselves instead of adding grafonnet to the toolchain. | |
| grafana: | |
| name: Grafana dashboards | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 20 | |
| - name: Generated dashboards are in sync with build.mjs | |
| run: node ops/grafana/build.mjs --check | |
| - name: Validate dashboards, panels and alert links | |
| run: node ops/grafana/validate.mjs | |
| - name: Validate the observability Compose profile | |
| run: docker compose --profile observability config --quiet | |
| - name: Upload dashboards for review | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 | |
| with: | |
| name: grafana-dashboards | |
| path: ops/grafana/dashboards | |
| # ── Synthetic canary against the docker-compose stack (issue #496) ──────── | |
| canary: | |
| name: Canary – local lifecycle run (docker-compose) | |
| runs-on: ubuntu-latest | |
| needs: backend | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 20 | |
| cache: npm | |
| - run: npm ci | |
| - name: Generate throwaway canary keys | |
| run: | | |
| node -e ' | |
| const { Keypair } = require("@stellar/stellar-sdk"); | |
| const u = Keypair.random(), s = Keypair.random(); | |
| console.log(`CANARY_USER_SECRET=${u.secret()}`); | |
| console.log(`CANARY_SOLVER_SECRET=${s.secret()}`); | |
| console.log(`CANARY_ADDRESSES=${u.publicKey()},${s.publicKey()}`); | |
| ' >> "$GITHUB_ENV" | |
| - name: Start stack | |
| run: docker compose up -d --build --wait postgres redis && docker compose up -d app | |
| - name: Wait for API | |
| run: timeout 300 sh -c 'until curl -sf http://localhost:4000/health/live; do sleep 5; done' | |
| - name: Run canary once | |
| env: | |
| CANARY_SETTLE_ONCHAIN: "false" | |
| run: npm run canary -- --once | |
| - name: Stack logs | |
| if: failure() | |
| run: docker compose logs app | |
| # ── Migration rollback verification ──────────────────────────────────────── | |
| # For every prisma/migrations/*/down.sql, applies migration.sql then | |
| # down.sql against a fresh Postgres and asserts the schema returns to its | |
| # pre-migration (empty) state. See prisma/migrations/README.md. | |
| migration-rollback: | |
| name: Migration rollback verification | |
| runs-on: ubuntu-latest | |
| services: | |
| postgres: | |
| image: postgres:16-alpine | |
| env: | |
| POSTGRES_USER: vortex | |
| POSTGRES_PASSWORD: vortex | |
| POSTGRES_DB: vortex_rollback_test | |
| ports: | |
| - 5432:5432 | |
| options: >- | |
| --health-cmd pg_isready | |
| --health-interval 10s | |
| --health-timeout 5s | |
| --health-retries 5 | |
| env: | |
| PGHOST: localhost | |
| PGPORT: 5432 | |
| PGUSER: vortex | |
| PGPASSWORD: vortex | |
| PGDATABASE: vortex_rollback_test | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| - name: Apply each migration's up then down script and verify schema is restored | |
| run: | | |
| set -euo pipefail | |
| for dir in prisma/migrations/*/; do | |
| name=$(basename "$dir") | |
| up="$dir/migration.sql" | |
| down="$dir/down.sql" | |
| [ -f "$down" ] || continue | |
| echo "::group::$name" | |
| before=$(psql -Atc "select tablename from pg_tables where schemaname='public' order by 1") | |
| psql -v ON_ERROR_STOP=1 -f "$up" | |
| psql -v ON_ERROR_STOP=1 -f "$down" | |
| after=$(psql -Atc "select tablename from pg_tables where schemaname='public' order by 1") | |
| if [ "$before" != "$after" ]; then | |
| echo "Schema after down.sql does not match pre-migration state for $name" | |
| echo "before: $before" | |
| echo "after: $after" | |
| exit 1 | |
| fi | |
| echo "::endgroup::" | |
| done | |
| # ── Migration lint ───────────────────────────────────────────────────────── | |
| # Blocks unsafe DDL in migrations this change adds or modifies: non-concurrent | |
| # index builds/drops, column type rewrites, NOT NULL without a default, | |
| # lock-heavy constraints, LOCK TABLE. Also requires a down.sql in every such | |
| # migration. Untouched migrations aren't linted, so older ones can't fail | |
| # retroactively. The rules and the `-- squawk-ignore` override are in | |
| # prisma/migrations/README.md. | |
| migration-lint: | |
| name: Migration lint (squawk) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| # Full history so the diff base below is always present. | |
| fetch-depth: 0 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: 20 | |
| cache: npm | |
| # --ignore-scripts skips Prisma's engine download and the husky hook; | |
| # neither is needed here, and it keeps the job well under 2 minutes. | |
| - name: Install dependencies | |
| run: npm ci --ignore-scripts | |
| - name: Test the migration checker (fixtures) | |
| run: npm run test:scripts | |
| - name: Lint changed migrations | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| BEFORE_SHA: ${{ github.event.before }} | |
| run: | | |
| # Pull requests: GitHub checks out a merge commit whose first parent is | |
| # the base branch tip, so HEAD^1 is exactly "everything this PR adds". | |
| # Pushes: diff against the commit that was on the branch before the push. | |
| if [ "$EVENT_NAME" = "pull_request" ] || [ -z "$BEFORE_SHA" ] || [ -z "${BEFORE_SHA//0/}" ]; then | |
| base="HEAD^1" | |
| else | |
| base="$BEFORE_SHA" | |
| fi | |
| npm run check:migrations -- --base "$base" | |
| # ── N-1 app against N schema (issue #497) ───────────────────────────────── | |
| # The issue's compatibility criterion: "previous app version's e2e smoke | |
| # suite runs against the migrated schema in CI". Concretely, this job: | |
| # | |
| # 1. applies the migrations of THIS revision (schema N) to a fresh Postgres; | |
| # 2. checks out the previous revision of the app (N-1) into a second | |
| # directory and installs its own dependencies there; | |
| # 3. runs three things from N-1 against schema N: | |
| # a. `prisma migrate deploy` — N-1's migration set must reconcile | |
| # against the N database. This is the exact "misordered migration" | |
| # failure the issue describes: if a migration was rewritten, | |
| # renamed or re-ordered between N-1 and N, this fails here instead | |
| # of in production; | |
| # b. a raw query through N-1's *generated Prisma client* — the e2e | |
| # harness (test/utils/create-test-app.ts) deliberately MOCKS | |
| # PrismaService, so the smoke specs below boot the N-1 app but do | |
| # not touch the database. This probe is what makes the run | |
| # schema-sensitive; | |
| # c. N-1's e2e smoke subset (health + OpenAPI contract), when those | |
| # specs exist at that revision. | |
| # | |
| # Which revision counts as "previous"? Chosen per event so that N-1 is the | |
| # app that is actually running/deployed, not an arbitrary ancestor: | |
| # * push: `github.event.before` — literally the tip that was on | |
| # the branch before this push, i.e. what staging runs. On | |
| # main that is the deployed revision by definition. | |
| # (`HEAD^1` would be wrong for multi-commit pushes: it | |
| # points *inside* the pushed range, at something never | |
| # deployed.) | |
| # * pull_request: the merge commit's first parent, i.e. the base-branch | |
| # tip this PR is merged onto — what the branch runs. Only | |
| # if that is not an ancestor of origin/main (checkout fell | |
| # back to the head commit because the merge ref was | |
| # unavailable) do we fall back to the merge-base with | |
| # origin/main; an older base only makes the test stricter, | |
| # never weaker. | |
| # * first push / unreachable object: skip gracefully with an explicit | |
| # ::notice:: — there is no N-1 to test, and failing the build for that | |
| # would block the very first commit of a branch. | |
| migration-compat: | |
| name: Migration compatibility (N-1 app vs N schema) | |
| runs-on: ubuntu-latest | |
| # Hard budget: the issue asks for "under ~10 minutes". The two installs | |
| # (current + N-1 worktree) dominate; everything else is seconds. | |
| timeout-minutes: 10 | |
| services: | |
| postgres: | |
| image: postgres:16-alpine | |
| env: | |
| POSTGRES_USER: vortex | |
| POSTGRES_PASSWORD: vortex | |
| POSTGRES_DB: vortex | |
| ports: | |
| - 5432:5432 | |
| options: >- | |
| --health-cmd="pg_isready -U vortex" | |
| --health-interval=10s | |
| --health-timeout=5s | |
| --health-retries=5 | |
| env: | |
| DATABASE_URL: postgresql://vortex:vortex@localhost:5432/vortex?schema=public | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| with: | |
| # Full history: resolving `before`, the merge-base and the worktree | |
| # checkout all need commits that a shallow clone would omit. | |
| fetch-depth: 0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 20 | |
| cache: npm | |
| - name: Install dependencies (current revision) | |
| run: npm ci | |
| - name: Generate Prisma client | |
| run: npm run db:generate | |
| # ── Step 1: bring the database to schema N ──────────────────────────── | |
| - name: Apply this revision's migrations (schema N) | |
| run: npm run db:migrate:prod | |
| # ── Step 2: pick the previous revision, or skip with a notice ───────── | |
| - name: Resolve the previous revision (N-1) | |
| id: prev | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| BEFORE_SHA: ${{ github.event.before }} | |
| BASE_REF: ${{ github.base_ref }} | |
| run: | | |
| set -euo pipefail | |
| prev="" | |
| case "$EVENT_NAME" in | |
| push) | |
| # `before` is all-zeros on the first push of a ref and can be | |
| # missing after a force-push; both are handled by the guards. | |
| if [ -n "${BEFORE_SHA:-}" ] && [ "${BEFORE_SHA//0/}" != "" ] \ | |
| && git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then | |
| prev="$BEFORE_SHA" | |
| fi | |
| ;; | |
| pull_request) | |
| # Merge ref: HEAD has two parents, first one is the base tip. | |
| if git rev-parse --verify --quiet HEAD^2 >/dev/null \ | |
| && git cat-file -e "HEAD^1^{commit}" 2>/dev/null \ | |
| && git merge-base --is-ancestor "HEAD^1" "origin/${BASE_REF}"; then | |
| prev=$(git rev-parse "HEAD^1") | |
| else | |
| # Fallback: the shared ancestor with the integration branch. | |
| prev=$(git merge-base HEAD "origin/${BASE_REF}" 2>/dev/null || true) | |
| fi | |
| ;; | |
| esac | |
| if [ -z "$prev" ] || ! git cat-file -e "${prev}^{commit}" 2>/dev/null; then | |
| echo "available=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::No usable previous revision for this event — skipping the N-1 compatibility check (first push of the ref, force-push, or unrelated history). Nothing to compare against, so there is nothing to fail." | |
| exit 0 | |
| fi | |
| if [ "$prev" = "$(git rev-parse HEAD)" ]; then | |
| echo "available=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Previous revision equals HEAD — skipping the N-1 compatibility check (degenerate history)." | |
| exit 0 | |
| fi | |
| echo "sha=$prev" >> "$GITHUB_OUTPUT" | |
| echo "available=true" >> "$GITHUB_OUTPUT" | |
| echo "N-1 revision: $prev ($(git log -1 --format=%s "$prev" | cut -c1-72))" | |
| # ── Step 3: materialise N-1 in a second directory ───────────────────── | |
| # A `git worktree` shares the object database with the checkout above | |
| # (no second clone), while giving N-1 its own package.json, lockfile and | |
| # node_modules — the current tree stays untouched for the schema-N side. | |
| # It lives under $RUNNER_TEMP so it cannot be mistaken for workspace | |
| # content by later steps or by the artifact uploaders. | |
| - name: Check out N-1 into a separate worktree | |
| if: steps.prev.outputs.available == 'true' | |
| id: worktree | |
| run: | | |
| set -euo pipefail | |
| N1_DIR="$RUNNER_TEMP/n1-app" | |
| git worktree add --detach "$N1_DIR" "${{ steps.prev.outputs.sha }}" | |
| echo "N1_DIR=$N1_DIR" >> "$GITHUB_ENV" | |
| echo "Checked out N-1 at $N1_DIR" | |
| - name: Install N-1 dependencies | |
| if: steps.prev.outputs.available == 'true' | |
| working-directory: ${{ env.N1_DIR }} | |
| run: npm ci --no-audit --no-fund | |
| # @prisma/client's postinstall normally generates the client, but that is | |
| # an implicit behaviour of whatever Prisma version N-1 pinned; make it | |
| # explicit (and skip it cleanly on revisions whose package.json has no | |
| # such script yet). | |
| - name: Generate N-1 Prisma client | |
| if: steps.prev.outputs.available == 'true' | |
| working-directory: ${{ env.N1_DIR }} | |
| run: | | |
| if node -e 'process.exit(require("./package.json").scripts?.["db:generate"] ? 0 : 1)'; then | |
| npm run db:generate | |
| else | |
| echo "No db:generate script at this revision; relying on @prisma/client postinstall." | |
| fi | |
| # ── Step 4: the actual compatibility probes ─────────────────────────── | |
| # (a) N-1's migration set must reconcile with schema N — the ordering / | |
| # rewriting guard. | |
| - name: N-1 `prisma migrate deploy` against schema N | |
| if: steps.prev.outputs.available == 'true' | |
| working-directory: ${{ env.N1_DIR }} | |
| run: npx prisma migrate deploy | |
| # (b) The schema-sensitive probe: query through N-1's generated client. | |
| # Written as a file (not `node -e`) so quoting cannot eat the SQL, and | |
| # placed inside the worktree so `require("@prisma/client")` resolves | |
| # N-1's client, not the current revision's. | |
| - name: Query schema N through the N-1 Prisma client | |
| if: steps.prev.outputs.available == 'true' | |
| working-directory: ${{ env.N1_DIR }} | |
| run: | | |
| set -euo pipefail | |
| cat > compat-smoke.js <<'JS' | |
| const { PrismaClient } = require("@prisma/client"); | |
| (async () => { | |
| const prisma = new PrismaClient(); | |
| const tables = await prisma.$queryRawUnsafe( | |
| "SELECT count(*)::int AS n FROM information_schema.tables WHERE table_schema = 'public'", | |
| ); | |
| const n = Number(tables[0].n); | |
| if (n < 1) throw new Error("schema N shows no public tables"); | |
| // The table every released revision since init depends on. | |
| await prisma.$queryRawUnsafe('SELECT count(*)::int FROM "intents"'); | |
| console.log(`N-1 client queried schema N: ${n} public tables, intents readable`); | |
| await prisma.$disconnect(); | |
| })().catch((err) => { | |
| console.error("N-1 client cannot use schema N:", err); | |
| process.exit(1); | |
| }); | |
| JS | |
| node compat-smoke.js | |
| # (c) N-1's e2e smoke subset. Existence is checked per file because the | |
| # two specs may not have existed at every historical revision — missing | |
| # coverage at N-1 is a reason to notice, not to fail the build. | |
| - name: Run N-1 e2e smoke suite against schema N | |
| if: steps.prev.outputs.available == 'true' | |
| working-directory: ${{ env.N1_DIR }} | |
| run: | | |
| set -euo pipefail | |
| smoke=() | |
| for f in test/health.e2e-spec.ts test/openapi-contract.e2e-spec.ts; do | |
| if [ -f "$f" ]; then | |
| smoke+=("$f") | |
| else | |
| echo "::notice::$f does not exist at the N-1 revision — skipping that spec." | |
| fi | |
| done | |
| if [ "${#smoke[@]}" -eq 0 ]; then | |
| echo "::notice::No e2e smoke specs exist at the N-1 revision — nothing to run." | |
| exit 0 | |
| fi | |
| npx jest --config test/jest-e2e.json --runTestsByPath "${smoke[@]}" --ci | |
| - name: Provenance for the executed comparison | |
| if: always() && steps.prev.outputs.available == 'true' | |
| run: | | |
| echo "::notice::N-1 compatibility checked: N=${GITHUB_SHA} schema vs N-1=${{ steps.prev.outputs.sha }} app." | |
| # ── Docker build verification (#131) ────────────────────────────────────── | |
| # Ensures every push that changes application code doesn't silently break | |
| # the Dockerfile. Image publishing to a registry can be wired up separately | |
| # once registry credentials are in place. | |
| docker: | |
| name: Docker – build verification | |
| runs-on: ubuntu-latest | |
| needs: backend | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| - name: Build Docker image | |
| uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 | |
| with: | |
| context: . | |
| push: false | |
| tags: vortex-backend:ci | |
| # ── OpenAPI drift check (issue #318) ────────────────────────────────────── | |
| # Verifies that src/generated/openapi.json (and the api-types.ts it drives) | |
| # still matches what the current controllers would produce. A PR that | |
| # changes a controller DTO or route without re-running `npm run generate:client` | |
| # will fail here before stale types ship to SDK consumers. | |
| # | |
| # The job runs only on pull_request events so we don't block main pushes that | |
| # are the *result* of running the generator. The sdk-publish job below | |
| # re-generates on tags anyway, so production is always up-to-date. | |
| openapi-drift: | |
| name: OpenAPI drift check | |
| runs-on: ubuntu-latest | |
| needs: backend | |
| if: github.event_name == 'pull_request' | |
| services: | |
| postgres: | |
| image: postgres:16-alpine | |
| env: | |
| POSTGRES_USER: vortex | |
| POSTGRES_PASSWORD: vortex | |
| POSTGRES_DB: vortex | |
| ports: | |
| - 5432:5432 | |
| options: >- | |
| --health-cmd="pg_isready -U vortex" | |
| --health-interval=10s | |
| --health-timeout=5s | |
| --health-retries=5 | |
| env: | |
| DATABASE_URL: postgresql://vortex:vortex@localhost:5432/vortex?schema=public | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| # ── Solver SDK (issue #446): build + publish dry-run on every PR ─────────── | |
| solver-sdk: | |
| name: Solver SDK – build + publish dry-run | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 20 | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Restore cached Prisma client | |
| uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 | |
| with: | |
| path: node_modules/.prisma | |
| key: prisma-${{ runner.os }}-node20-${{ hashFiles('prisma/schema.prisma', 'package-lock.json') }} | |
| - name: Generate Prisma client | |
| run: npm run db:generate | |
| - name: Run database migrations | |
| run: npm run db:migrate:prod | |
| # Build first — generate:client requires dist/ to boot the throw-away app. | |
| - name: Build | |
| run: npm run build | |
| # Re-generate the client from the current controllers into a temp directory, | |
| # then diff against the checked-in src/generated/. Any difference means | |
| # a controller change was not followed by `npm run generate:client`. | |
| - name: Regenerate OpenAPI client | |
| run: npm run generate:client | |
| - name: Check for OpenAPI drift | |
| run: | | |
| if ! git diff --exit-code src/generated/; then | |
| echo "" | |
| echo "::error::src/generated/ is out of date. Run \`npm run generate:client\` locally," | |
| echo "::error::commit the updated files, and push again." | |
| echo "" | |
| git diff src/generated/ | |
| exit 1 | |
| fi | |
| echo "✅ src/generated/ is in sync with the current controllers." | |
| - run: npm ci | |
| - run: npm run check:version -w @vortex/solver-sdk | |
| - run: npm run build -w @vortex/solver-sdk | |
| - run: npm publish -w @vortex/solver-sdk --dry-run | |
| sdk-publish: | |
| name: Publish generated SDK | |
| runs-on: ubuntu-latest | |
| needs: [backend, docker] | |
| if: startsWith(github.ref, 'refs/tags/v') | |
| permissions: | |
| contents: read | |
| steps: | |
| - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 | |
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | |
| with: | |
| node-version: 20 | |
| registry-url: https://registry.npmjs.org | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Regenerate client SDK | |
| run: npm run generate:client | |
| - name: Verify packaged SDK installs cleanly | |
| run: | | |
| tmpdir=$(mktemp -d) | |
| cd "$tmpdir" | |
| npm init -y | |
| npm install --silent "$GITHUB_WORKSPACE/src/generated" | |
| npm install --silent typescript @types/node | |
| cat <<'TS' > index.ts | |
| import type { paths, components } from '@vortex-protocol/backend-sdk'; | |
| type IntentList = paths['/api/v1/intents']['get']; | |
| type Intent = components['schemas']['Intent']; | |
| const fallback: Intent | undefined = undefined; | |
| void fallback; | |
| void IntentList; | |
| TS | |
| npx tsc --noEmit --moduleResolution node --module commonjs --target es2020 index.ts | |
| - name: Publish SDK to npm | |
| env: | |
| NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| run: | | |
| version="${GITHUB_REF_NAME#v}" | |
| npm --prefix src/generated version "$version" --no-git-tag-version | |
| npm --prefix src/generated publish --access public |