Skip to content

feat: add price feed worker and circuit breaker #457

feat: add price feed worker and circuit breaker

feat: add price feed worker and circuit breaker #457

Workflow file for this run

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