1.0: tenant isolation hardening, the satellite split, and a real benchmark suite #131
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: [master, main] | |
| pull_request: | |
| branches: [master, main] | |
| jobs: | |
| lint-and-typecheck: | |
| name: Lint & Typecheck | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 | |
| with: | |
| node-version: '24' | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci --include=optional | |
| # .npmrc keeps install scripts off for supply-chain safety. esbuild (used | |
| # by tsx and vitepress) needs its postinstall to place the platform | |
| # binary, so rebuild only that package with scripts enabled. Far narrower | |
| # than re-enabling scripts for the whole tree on a token-bearing runner. | |
| - name: Rebuild esbuild (scoped install script) | |
| run: npm_config_ignore_scripts=false npm rebuild esbuild | |
| # Build core first, then the extracted satellites, BEFORE typecheck. The | |
| # core's integration + fixture specs import the satellites by package name | |
| # (`@adonisjs-lasagna/billing`, etc.), which TypeScript resolves to each | |
| # package's emitted `build/*.d.ts` — so those must exist before `tsc | |
| # --noEmit` runs. Order matters: core -> sso -> billing -> admin (admin | |
| # imports `@adonisjs-lasagna/sso`; all import the core). backup only needs | |
| # core. Building each package also typechecks its own source. | |
| - name: Build (core) | |
| run: npm run build | |
| - name: Build satellite packages (sso, billing, admin, backup, websockets) | |
| run: npm run build:sso && npm run build:billing && npm run build:admin && npm run build:backup && npm run build:websockets | |
| # The dev-only satellite-test-kit is imported by core's bin/test.integration.ts | |
| # (and each satellite's), which core's tsconfig typechecks. Build it before the | |
| # typecheck step so its build/*.d.ts resolves. Depends on core (built above). | |
| - name: Build satellite-test-kit (dev-only integration harness) | |
| run: npm run build:test-kit | |
| # One gate for every workspace with a typecheck script (--workspaces | |
| # --if-present), so adding a package or example can't reopen the gap | |
| # the demo app fell into when nothing ran its typecheck. Must run AFTER | |
| # the builds above: satellites, demo and benchmarks all resolve | |
| # @adonisjs-lasagna/* through each package's build/*.d.ts. | |
| - name: Typecheck (all workspaces) | |
| run: npm run typecheck | |
| # Lint is a hard gate. The baseline of pre-existing findings was cleared | |
| # (eslint rules + prettier); CRLF-only prettier diffs don't surface here | |
| # because the Linux checkout is LF (.gitattributes `eol=lf`). | |
| - name: Lint | |
| run: npm run lint | |
| # Versions must agree with the stability labels in docs/docs/stability.md | |
| # (experimental -> 0.x, release candidate/stable -> >=1.0.0). Labels are | |
| # parsed from the doc, so relabeling without re-versioning fails here. | |
| - name: Stability labels vs. versions | |
| run: node scripts/check-stability-versions.mjs | |
| # Every satellite labeled `release candidate` must clear the graduation gate | |
| # (own coverage gate, ABI-versioned manifest, configure hook, CHANGELOG, doc | |
| # page, version >= 1.0.0), so a satellite can't carry the RC label without | |
| # the artifacts that make it honest. | |
| - name: Satellite graduation gate | |
| run: node scripts/check-satellite-graduation.mjs | |
| - name: Test (unit) + coverage | |
| run: npm run test:coverage | |
| # Satellite packages with decorated models or app.booted-sensitive source | |
| # run their own unit suites from the package cwd (their tsconfig + a | |
| # package-local japa runner), so they are not part of the core unit run. | |
| - name: Test (satellite package units) | |
| run: | | |
| npm run test:coverage --workspace @adonisjs-lasagna/billing | |
| npm run test:coverage --workspace @adonisjs-lasagna/backup | |
| npm run test:coverage --workspace @adonisjs-lasagna/sso | |
| npm run test:coverage --workspace @adonisjs-lasagna/admin | |
| npm run test:coverage --workspace @adonisjs-lasagna/websockets | |
| # Satellite ABI compatibility (B5): the reference third-party satellite | |
| # is built + tested against the freshly-built core above. Its typecheck | |
| # (in the all-workspaces step) compiles against core's public `.d.ts`, and | |
| # its unit suite exercises the SDK/registries at runtime — so a breaking | |
| # change to the Satellite ABI fails here, not silently in a consumer. | |
| - name: Build + test the reference satellite (ABI compat) | |
| run: | | |
| npm run build:template | |
| npm run test --workspace @adonisjs-lasagna/satellite-template | |
| # Raw V8 so the coverage-report job can re-report it with source-map | |
| # remapping back to src/*.ts. | |
| - name: Upload raw unit coverage (V8) | |
| if: always() | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| with: | |
| name: c8-raw-unit | |
| path: coverage/.v8/unit | |
| if-no-files-found: warn | |
| # Full report (unused exports / orphaned files / deps) stays informational. | |
| - name: Knip (unused-code report) | |
| run: npm run knip | |
| # Blocking: fails on an unused dependency so dead deps like gsap / motion / | |
| # tailwindcss can't creep back in. Scoped to the dependency issue type via | |
| # `--include dependencies`; unused exports and files stay informational above. | |
| - name: Knip (unused-dependency gate) | |
| run: npm run knip:deps | |
| # Production-only audit (moderate+) so we don't trip on devDependency-only | |
| # CVEs that never ship to consumers. Runs scripts/audit-gate.mjs, which wraps | |
| # `npm audit --omit=dev` and fails on any moderate-or-higher advisory except | |
| # the GHSA ids explicitly reviewed + allowlisted in that script (each with a | |
| # justification and a removal condition). | |
| - name: npm audit (production deps) | |
| run: npm run audit:prod | |
| # Package-contract gate (B5): publint validates each publishable package's | |
| # exports/files map, and arethetypeswrong proves every declared subpath | |
| # resolves to types + JS under node10 / node16 / bundler. (This caught the | |
| # satellites' missing `typesVersions` for their /provider + /commands | |
| # subpaths.) The ESM-only "cjs-resolves-to-esm" rule is expected — these | |
| # packages are `"type": "module"` — so it is ignored. Runs after the builds | |
| # above, since both tools inspect build/ through the exports map. | |
| - name: Package contracts (publint + types resolution) | |
| run: | | |
| set -e | |
| for pkg in core sso billing admin backup websockets; do | |
| echo "== @adonisjs-lasagna/$pkg ==" | |
| ( cd "packages/$pkg" \ | |
| && npx -y publint@0.3.21 \ | |
| && npx -y @arethetypeswrong/cli@0.18.3 --pack --ignore-rules cjs-resolves-to-esm ) | |
| done | |
| test-integration: | |
| name: Integration Tests | |
| runs-on: ubuntu-latest | |
| services: | |
| postgres: | |
| image: postgres:16-alpine | |
| env: | |
| POSTGRES_USER: postgres | |
| POSTGRES_PASSWORD: postgres | |
| POSTGRES_DB: multitenancy_test | |
| ports: | |
| - 5432:5432 | |
| options: >- | |
| --health-cmd pg_isready | |
| --health-interval 5s | |
| --health-timeout 5s | |
| --health-retries 10 | |
| redis: | |
| image: redis:7.0.5-alpine | |
| ports: | |
| - 6379:6379 | |
| options: >- | |
| --health-cmd "redis-cli ping" | |
| --health-interval 5s | |
| --health-timeout 5s | |
| --health-retries 10 | |
| # Wire-compliant OIDC server for sso_oidc_real.spec.ts. | |
| # (MinIO can't run here — Actions services don't allow CMD overrides.) | |
| mock-oidc: | |
| image: ghcr.io/navikt/mock-oauth2-server:2.1.10 | |
| ports: | |
| - 8080:8080 | |
| # Official stripe-mock for stripe_mock_smoke.spec.ts — validates the | |
| # StripeDriver's SDK call-sites against the current Stripe API schema | |
| # without credentials. Pinned like the other service images. | |
| stripe-mock: | |
| image: stripe/stripe-mock:v0.185.0 | |
| ports: | |
| - 12111:12111 | |
| env: | |
| TZ: UTC | |
| NODE_ENV: test | |
| HOST: 127.0.0.1 | |
| PORT: 3333 | |
| APP_KEY: a-32-character-long-secret-key!! | |
| LOG_LEVEL: error | |
| TENANT_HEADER_KEY: x-tenant-id | |
| DB_HOST: 127.0.0.1 | |
| DB_PORT: 5432 | |
| DB_USER: postgres | |
| DB_PASSWORD: postgres | |
| DB_DATABASE: multitenancy_test | |
| REDIS_HOST: 127.0.0.1 | |
| REDIS_PORT: 6379 | |
| QUEUE_REDIS_HOST: 127.0.0.1 | |
| QUEUE_REDIS_PORT: 6379 | |
| QUEUE_REDIS_DB: 1 | |
| CACHE_REDIS_HOST: 127.0.0.1 | |
| CACHE_REDIS_PORT: 6379 | |
| CACHE_REDIS_DB: 2 | |
| # stripe-mock endpoint for stripe_mock_smoke.spec.ts (Layer 2 call-site | |
| # contract). Always available in CI via the service above; unset locally | |
| # → the spec self-skips. | |
| STRIPE_MOCK_HOST: 127.0.0.1 | |
| STRIPE_MOCK_PORT: 12111 | |
| # Optional billing real-API smoke keys. Each *_real_smoke.spec.ts skips | |
| # visibly when its key is unset. Add via Settings → Secrets → Actions. | |
| # Stripe (sk_test_…) for stripe_real_smoke.spec.ts: | |
| STRIPE_TEST_API_KEY: ${{ secrets.STRIPE_TEST_API_KEY }} | |
| # Paddle sandbox for paddle_real_smoke.spec.ts. API key alone runs the | |
| # ensureCustomer + webhook round-trip; PRICE_ID adds the live checkout | |
| # test; WEBHOOK_SECRET is optional (the round-trip self-signs). | |
| PADDLE_TEST_API_KEY: ${{ secrets.PADDLE_TEST_API_KEY }} | |
| PADDLE_TEST_PRICE_ID: ${{ secrets.PADDLE_TEST_PRICE_ID }} | |
| PADDLE_TEST_WEBHOOK_SECRET: ${{ secrets.PADDLE_TEST_WEBHOOK_SECRET }} | |
| # Lemon Squeezy test mode for lemon_squeezy_real_smoke.spec.ts. API key + | |
| # STORE_ID run ensureCustomer + webhook round-trip (verifyConfig needs the | |
| # store id); VARIANT_ID adds the live checkout test. | |
| LEMONSQUEEZY_TEST_API_KEY: ${{ secrets.LEMONSQUEEZY_TEST_API_KEY }} | |
| LEMONSQUEEZY_TEST_STORE_ID: ${{ secrets.LEMONSQUEEZY_TEST_STORE_ID }} | |
| LEMONSQUEEZY_TEST_VARIANT_ID: ${{ secrets.LEMONSQUEEZY_TEST_VARIANT_ID }} | |
| # MinIO (S3) for backup_s3.spec.ts. Unset → spec skips. | |
| BACKUP_S3_ENDPOINT: http://127.0.0.1:9000 | |
| BACKUP_S3_BUCKET: lasagna-test | |
| BACKUP_S3_REGION: us-east-1 | |
| AWS_ACCESS_KEY_ID: minioadmin | |
| AWS_SECRET_ACCESS_KEY: minioadmin | |
| # mock-oauth2-server for sso_oidc_real.spec.ts. `default` = mock's | |
| # placeholder issuer path for unspecified issuers. | |
| MOCK_OIDC_BASE_URL: http://127.0.0.1:8080/default | |
| # Least-privilege role for the rowscope RLS enforcement proof. The default | |
| # DB user is the image bootstrap SUPERUSER, which BYPASSes RLS — so without | |
| # this the proof self-skips and ships false-green. Created below; the | |
| # fixture's `rls_probe` connection picks these up. | |
| RLS_DB_USER: rls_ci | |
| RLS_DB_PASSWORD: rls_ci | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 | |
| with: | |
| node-version: '24' | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci --include=optional | |
| # .npmrc keeps install scripts off for supply-chain safety. esbuild (used | |
| # by tsx and vitepress) needs its postinstall to place the platform | |
| # binary, so rebuild only that package with scripts enabled. Far narrower | |
| # than re-enabling scripts for the whole tree on a token-bearing runner. | |
| - name: Rebuild esbuild (scoped install script) | |
| run: npm_config_ignore_scripts=false npm rebuild esbuild | |
| # better-sqlite3 is an optional peer dependency, so npm does not install | |
| # it with the tree above. The sqlite-memory storage spec needs it; build | |
| # it here so that spec runs for real. Best-effort: the spec self-skips if | |
| # this ever fails (e.g. no prebuilt binary for the runner's Node ABI). | |
| # Scoped scripts: this installs only better-sqlite3 (+ its prebuild | |
| # helper), which needs its install script to place the native binary. | |
| # Far narrower than re-enabling scripts for the whole `npm ci` tree. | |
| - name: Install better-sqlite3 (optional peer for the sqlite-memory spec) | |
| env: | |
| npm_config_ignore_scripts: 'false' | |
| run: npm install better-sqlite3@^11 --no-save --include=optional || true | |
| # Run MinIO directly (Actions services can't pass `server /data`). | |
| - name: Start MinIO (S3-compatible) | |
| run: | | |
| docker run -d --rm --name lasagna-minio \ | |
| -p 9000:9000 \ | |
| -e MINIO_ROOT_USER=minioadmin \ | |
| -e MINIO_ROOT_PASSWORD=minioadmin \ | |
| minio/minio:latest server /data | |
| for i in 1 2 3 4 5 6 7 8 9 10; do | |
| if curl -fsS http://127.0.0.1:9000/minio/health/ready > /dev/null; then | |
| echo "minio ready"; exit 0 | |
| fi | |
| sleep 1 | |
| done | |
| echo "minio failed to become ready"; docker logs lasagna-minio; exit 1 | |
| # Fail fast if the mock-oidc service container didn't come up. | |
| - name: Probe mock-oauth2-server | |
| run: | | |
| for i in 1 2 3 4 5 6 7 8 9 10; do | |
| if curl -fsS "$MOCK_OIDC_BASE_URL/.well-known/openid-configuration" > /dev/null; then | |
| echo "mock-oidc ready"; exit 0 | |
| fi | |
| sleep 1 | |
| done | |
| echo "mock-oidc failed to become ready"; exit 1 | |
| # psql for the role-creation step below. Don't rely on the runner image's | |
| # preinstalled client (the e2e job installs it explicitly for the same | |
| # reason — image contents rotate). | |
| - name: Install postgresql-client | |
| timeout-minutes: 5 | |
| run: | | |
| # The runner's default apt mirror (azure.archive.ubuntu.com) is | |
| # periodically unreachable, and apt then hangs retrying it for the | |
| # whole job. Bound the retries and per-connection timeouts so this | |
| # step fails fast, or falls through to the other mirrors in the | |
| # runner's mirrorlist, instead of stalling for over an hour. | |
| sudo apt-get -o Acquire::Retries=3 \ | |
| -o Acquire::http::Timeout=20 -o Acquire::https::Timeout=20 update | |
| sudo apt-get install -y --no-install-recommends postgresql-client | |
| # Create a NOSUPERUSER NOBYPASSRLS role so rowscope_rls.spec.ts runs its | |
| # enforcement proof for real instead of self-skipping under the bootstrap | |
| # superuser. The fixture's `rls_probe` connection authenticates as this | |
| # role (via RLS_DB_USER/RLS_DB_PASSWORD); the spec's setup GRANTs table | |
| # access to PUBLIC so the role can read/write within the policy. | |
| - name: Create least-privilege RLS role | |
| env: | |
| PGPASSWORD: postgres | |
| run: | | |
| psql -h 127.0.0.1 -U postgres -d multitenancy_test -v ON_ERROR_STOP=1 <<'SQL' | |
| DO $$ BEGIN | |
| IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'rls_ci') THEN | |
| CREATE ROLE rls_ci LOGIN PASSWORD 'rls_ci' NOSUPERUSER NOBYPASSRLS; | |
| END IF; | |
| END $$; | |
| GRANT CONNECT ON DATABASE multitenancy_test TO rls_ci; | |
| GRANT USAGE ON SCHEMA public TO rls_ci; | |
| SQL | |
| - name: Test (integration) + coverage | |
| run: npm run test:integration:coverage | |
| # Satellite integration tiers boot through the shared satellite-test-kit, | |
| # proving the harness end to end on a real satellite. The step above ran | |
| # build:all, so the kit + every satellite build are already in place; this | |
| # reuses core's canonical fixture. sso is the first consumer; more satellites | |
| # join here as their tiers land (Phase 2). Coverage is report-only here | |
| # (--check-coverage=false); merging these V8 dumps into the aggregate gate is | |
| # the remaining Phase 6 wiring. | |
| - name: Test (satellite integration) + coverage — sso | |
| run: npm run test:integration:coverage --workspace @adonisjs-lasagna/sso | |
| # backup's integration tier: CloneService lifecycle (real PG) + the S3 | |
| # roundtrip against MinIO (backup_s3 self-skips without BACKUP_S3_ENDPOINT, | |
| # which is set above). Reuses core's canonical fixture via the kit. | |
| - name: Test (satellite integration) + coverage — backup | |
| run: npm run test:integration:coverage --workspace @adonisjs-lasagna/backup | |
| # admin's integration tier: the REST + OpenAPI satellite endpoints | |
| # (audit-logs, webhooks, feature-flags, branding, sso, metrics, quotas) | |
| # against real PG/Redis, mounted by core's canonical fixture via the kit. | |
| - name: Test (satellite integration) + coverage — admin | |
| run: npm run test:integration:coverage --workspace @adonisjs-lasagna/admin | |
| # billing's integration tier (the largest): the webhook pipeline, | |
| # subscription/usage/dunning flows, the billing ace commands, the | |
| # mock-driver contract, and the stripe-mock / real-API smokes. Reuses | |
| # core's canonical fixture (billing provider + Stripe webhook route) via | |
| # the kit. The *_real_smoke specs self-skip without their secrets; | |
| # stripe_mock_smoke uses the stripe-mock service container above. | |
| - name: Test (satellite integration) + coverage — billing | |
| run: npm run test:integration:coverage --workspace @adonisjs-lasagna/billing | |
| # Raw V8, not lcov: the integration suite runs against build/*.js, so its | |
| # per-run lcov undercounts src. The coverage-report job re-reports this | |
| # raw data with the build source maps to attribute it back to src/*.ts. | |
| - name: Upload raw integration coverage (V8) | |
| if: always() | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| with: | |
| name: c8-raw-integration | |
| path: coverage/.v8/integration | |
| if-no-files-found: warn | |
| # Satellite integration V8 dumps, merged into the aggregate gate alongside | |
| # core's so a satellite's src coverage moves with its specs when they leave | |
| # core's integration run (Phase 2). One artifact per satellite tier. | |
| - name: Upload raw satellite integration coverage (V8) | |
| if: always() | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| with: | |
| name: c8-raw-sso-integration | |
| path: coverage/.v8/sso-integration | |
| if-no-files-found: warn | |
| - name: Upload raw satellite integration coverage (V8) — backup | |
| if: always() | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| with: | |
| name: c8-raw-backup-integration | |
| path: coverage/.v8/backup-integration | |
| if-no-files-found: warn | |
| - name: Upload raw satellite integration coverage (V8) — admin | |
| if: always() | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| with: | |
| name: c8-raw-admin-integration | |
| path: coverage/.v8/admin-integration | |
| if-no-files-found: warn | |
| - name: Upload raw satellite integration coverage (V8) — billing | |
| if: always() | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| with: | |
| name: c8-raw-billing-integration | |
| path: coverage/.v8/billing-integration | |
| if-no-files-found: warn | |
| test-e2e-demo: | |
| name: E2E (demo app) | |
| runs-on: ubuntu-latest | |
| services: | |
| postgres: | |
| image: postgres:16-alpine | |
| env: | |
| POSTGRES_USER: app | |
| POSTGRES_PASSWORD: app | |
| POSTGRES_DB: lasagna_demo | |
| ports: | |
| - 55432:5432 | |
| options: >- | |
| --health-cmd "pg_isready -U app -d lasagna_demo" | |
| --health-interval 5s | |
| --health-timeout 5s | |
| --health-retries 10 | |
| redis: | |
| image: redis:7-alpine | |
| ports: | |
| - 56379:6379 | |
| options: >- | |
| --health-cmd "redis-cli ping" | |
| --health-interval 5s | |
| --health-timeout 5s | |
| --health-retries 10 | |
| mailcatcher: | |
| image: schickling/mailcatcher | |
| ports: | |
| - 1025:1025 | |
| - 1080:1080 | |
| env: | |
| TZ: UTC | |
| NODE_ENV: development | |
| HOST: 127.0.0.1 | |
| PORT: 3333 | |
| APP_KEY: a-32-character-long-secret-key!! | |
| LOG_LEVEL: error | |
| TENANT_HEADER_KEY: x-tenant-id | |
| APP_DOMAIN: localhost | |
| DB_HOST: 127.0.0.1 | |
| DB_PORT: 55432 | |
| DB_USER: app | |
| DB_PASSWORD: app | |
| DB_DATABASE: lasagna_demo | |
| REDIS_HOST: 127.0.0.1 | |
| REDIS_PORT: 56379 | |
| QUEUE_REDIS_HOST: 127.0.0.1 | |
| QUEUE_REDIS_PORT: 56379 | |
| QUEUE_REDIS_DB: 1 | |
| CACHE_REDIS_HOST: 127.0.0.1 | |
| CACHE_REDIS_PORT: 56379 | |
| CACHE_REDIS_DB: 2 | |
| BACKUP_STORAGE_PATH: ./storage/backups | |
| # The webhook-delivery e2e posts to an in-process listener on 127.0.0.1; | |
| # exempt loopback from the SSRF guard for the demo run. Off in production, | |
| # and even when on it only allows loopback (private/metadata stay blocked). | |
| WEBHOOKS_ALLOW_LOOPBACK_TARGETS: 'true' | |
| DEMO_ADMIN_TOKEN: demo-admin-token-change-me | |
| MAILCATCHER_HOST: 127.0.0.1 | |
| MAILCATCHER_PORT: 1025 | |
| MAIL_FROM_ADDRESS: demo@example.test | |
| MAIL_FROM_NAME: Demo Multitenancy | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 | |
| with: | |
| node-version: '24' | |
| cache: npm | |
| # `pg_dump` / `pg_restore` / `psql` on PATH so backups_real.spec.ts + | |
| # the import / clone tests run instead of skipping. The suite skips | |
| # gracefully when these are absent, but we want them green in CI. | |
| - name: Install postgresql-client | |
| timeout-minutes: 5 | |
| run: | | |
| # The runner's default apt mirror (azure.archive.ubuntu.com) is | |
| # periodically unreachable, and apt then hangs retrying it for the | |
| # whole job. Bound the retries and per-connection timeouts so this | |
| # step fails fast, or falls through to the other mirrors in the | |
| # runner's mirrorlist, instead of stalling for over an hour. | |
| sudo apt-get -o Acquire::Retries=3 \ | |
| -o Acquire::http::Timeout=20 -o Acquire::https::Timeout=20 update | |
| sudo apt-get install -y --no-install-recommends postgresql-client | |
| - name: Install dependencies | |
| run: npm ci --include=optional | |
| # .npmrc keeps install scripts off for supply-chain safety. esbuild (used | |
| # by tsx and vitepress) needs its postinstall to place the platform | |
| # binary, so rebuild only that package with scripts enabled. Far narrower | |
| # than re-enabling scripts for the whole tree on a token-bearing runner. | |
| - name: Rebuild esbuild (scoped install script) | |
| run: npm_config_ignore_scripts=false npm rebuild esbuild | |
| # Demo's `@adonisjs-lasagna/saas-tenancy` and `@adonisjs-lasagna/admin` | |
| # are workspace symlinks that resolve through each package's `build/`, so | |
| # a fresh build of both is mandatory before the e2e suite imports them. | |
| - name: Build packages | |
| run: npm run build:all | |
| - name: Backoffice setup (creates backoffice schema + tenants table) | |
| working-directory: examples/api | |
| run: npx tsx ace.ts backoffice:setup | |
| # Runs the whole e2e suite, which auto-includes the hardening specs under | |
| # tests/e2e/hardening/** (isolation under concurrency, quota atomicity, | |
| # circuit-breaker fail-fast, audit immutability, SSO replay, concurrent | |
| # provisioning, admin/OpenAPI conformance, doctor --json) via the | |
| # `tests/e2e/**/*.spec.ts` suite glob — no separate job needed. | |
| - name: Run e2e suite (includes hardening/**) | |
| working-directory: examples/api | |
| run: npx tsx ace.ts test e2e | |
| test-e2e-websockets: | |
| name: E2E (websockets demo) | |
| runs-on: ubuntu-latest | |
| services: | |
| postgres: | |
| image: postgres:16-alpine | |
| env: | |
| POSTGRES_USER: app | |
| POSTGRES_PASSWORD: app | |
| POSTGRES_DB: lasagna_demo | |
| ports: | |
| - 55432:5432 | |
| options: >- | |
| --health-cmd "pg_isready -U app -d lasagna_demo" | |
| --health-interval 5s | |
| --health-timeout 5s | |
| --health-retries 10 | |
| redis: | |
| image: redis:7-alpine | |
| ports: | |
| - 56379:6379 | |
| options: >- | |
| --health-cmd "redis-cli ping" | |
| --health-interval 5s | |
| --health-timeout 5s | |
| --health-retries 10 | |
| mailcatcher: | |
| image: schickling/mailcatcher | |
| ports: | |
| - 1025:1025 | |
| - 1080:1080 | |
| env: | |
| TZ: UTC | |
| NODE_ENV: development | |
| HOST: 127.0.0.1 | |
| PORT: 3333 | |
| APP_KEY: a-32-character-long-secret-key!! | |
| LOG_LEVEL: error | |
| TENANT_HEADER_KEY: x-tenant-id | |
| APP_DOMAIN: localhost | |
| DB_HOST: 127.0.0.1 | |
| DB_PORT: 55432 | |
| DB_USER: app | |
| DB_PASSWORD: app | |
| DB_DATABASE: lasagna_demo | |
| REDIS_HOST: 127.0.0.1 | |
| REDIS_PORT: 56379 | |
| QUEUE_REDIS_HOST: 127.0.0.1 | |
| QUEUE_REDIS_PORT: 56379 | |
| QUEUE_REDIS_DB: 1 | |
| CACHE_REDIS_HOST: 127.0.0.1 | |
| CACHE_REDIS_PORT: 56379 | |
| CACHE_REDIS_DB: 2 | |
| BACKUP_STORAGE_PATH: ./storage/backups | |
| DEMO_ADMIN_TOKEN: demo-admin-token-change-me | |
| MAILCATCHER_HOST: 127.0.0.1 | |
| MAILCATCHER_PORT: 1025 | |
| MAIL_FROM_ADDRESS: demo@example.test | |
| MAIL_FROM_NAME: Demo Multitenancy | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 | |
| with: | |
| node-version: '24' | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci --include=optional | |
| - name: Rebuild esbuild (scoped install script) | |
| run: npm_config_ignore_scripts=false npm rebuild esbuild | |
| # socket.io is an optional peer of @adonisjs-lasagna/websockets and | |
| # socket.io-client drives the test. @socket.io/redis-adapter is the optional | |
| # multi-node fan-out peer the severance integration spec wires. Installed | |
| # here (not committed to the lockfile) so the WS e2e + the multi-node | |
| # integration tier run for real here while the general e2e job self-skips | |
| # them. Pure JS, no install scripts needed. | |
| - name: Install socket.io (server + client + redis adapter) for the WS tests | |
| run: npm install socket.io@^4 socket.io-client@^4 @socket.io/redis-adapter@^8 --no-save --include=optional | |
| - name: Build packages | |
| run: npm run build:all | |
| - name: Backoffice setup (creates backoffice schema + tenants table) | |
| working-directory: examples/api | |
| run: npx tsx ace.ts backoffice:setup | |
| - name: Run websockets e2e spec | |
| working-directory: examples/api | |
| run: npx tsx ace.ts test e2e --files tests/e2e/websockets.spec.ts | |
| # Multi-node severance (Phase 4 chaos): two real socket.io servers wired | |
| # like the cookbook's multi-node recipe (Redis adapter + pub/sub severance | |
| # bridge), proving a suspend on one node severs the tenant fleet-wide. Boots | |
| # no Ignitor — a plain Japa runner over the websockets package's own | |
| # tests/integration/**. Uses this job's Redis (REDIS_HOST/PORT) and the | |
| # socket peers installed above; self-skips if they are absent. | |
| - name: Test (websockets multi-node integration) | |
| run: npm run test:integration:run --workspace @adonisjs-lasagna/websockets | |
| deploy-validate: | |
| name: Deploy assets (static validation) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| # Catches interpolation typos and schema drift in the compose files | |
| # without booting anything. The prod file needs an env file because it | |
| # requires APP_KEY/DB_*/REDIS_PASSWORD at interpolation time. | |
| - name: Validate docker compose files | |
| run: | | |
| cp deploy/docker-compose.prod.example.env .env.compose-check | |
| docker compose -f deploy/docker-compose.prod.yml --env-file .env.compose-check config -q | |
| docker compose -f deploy/docker-compose.e2e.yml config -q | |
| - name: Helm lint | |
| run: helm lint deploy/charts/lasagna-app | |
| # kubeconform validates the rendered manifests against the upstream | |
| # Kubernetes JSON schemas — `helm template` alone accepts YAML the API | |
| # server would reject. Pinned so the gate stays deterministic. | |
| - name: Install kubeconform | |
| run: | | |
| curl -sSL https://github.com/yannh/kubeconform/releases/download/v0.6.7/kubeconform-linux-amd64.tar.gz \ | |
| | tar xz kubeconform | |
| sudo mv kubeconform /usr/local/bin/kubeconform | |
| kubeconform -v | |
| - name: Helm template + kubeconform (default values) | |
| run: helm template smoke deploy/charts/lasagna-app | kubeconform -strict -summary | |
| - name: Helm template + kubeconform (production values) | |
| run: | | |
| helm template smoke deploy/charts/lasagna-app \ | |
| -f deploy/charts/lasagna-app/values.production.yaml \ | |
| | kubeconform -strict -summary | |
| deploy-e2e: | |
| name: Deploy E2E (compose stack) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 25 | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 | |
| # Build once with the GHA layer cache (the npm-install layer survives | |
| # source-only changes), then hand the tag to the smoke script so | |
| # compose doesn't rebuild. | |
| - name: Build demo image | |
| uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 | |
| with: | |
| context: . | |
| file: examples/api/Dockerfile | |
| load: true | |
| tags: lasagna-demo:ci | |
| cache-from: type=gha | |
| cache-to: type=gha,mode=max | |
| # Boots deploy/docker-compose.e2e.yml (pg primary + streaming replica, | |
| # password-protected redis, 2 app replicas + queue worker, nginx) and | |
| # proves the deployment claims end to end: probes through the proxy, | |
| # queue-driven tenant provisioning, isolation via nginx, replica reads, | |
| # primary-outage -> 503 -> recovery, and fail-fast on missing APP_KEY. | |
| # The script dumps compose logs itself on failure before tearing down. | |
| - name: Run deploy smoke | |
| env: | |
| LASAGNA_DEMO_IMAGE: lasagna-demo:ci | |
| run: bash deploy/scripts/deploy-smoke.sh | |
| coverage-report: | |
| name: Aggregate coverage (unit + integration) | |
| runs-on: ubuntu-latest | |
| # Runs once both coverage producers succeed. It re-reports the raw V8 | |
| # coverage from both suites through a single `c8 report`, which remaps each | |
| # script back to src/*.ts via source maps — so the integration suite (run | |
| # against build/) finally counts toward src instead of reading as ~0. | |
| needs: [lint-and-typecheck, test-integration] | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 | |
| with: | |
| node-version: '24' | |
| cache: npm | |
| - name: Install dependencies | |
| run: npm ci --include=optional | |
| # .npmrc keeps install scripts off for supply-chain safety. esbuild (used | |
| # by tsx and vitepress) needs its postinstall to place the platform | |
| # binary, so rebuild only that package with scripts enabled. Far narrower | |
| # than re-enabling scripts for the whole tree on a token-bearing runner. | |
| - name: Rebuild esbuild (scoped install script) | |
| run: npm_config_ignore_scripts=false npm rebuild esbuild | |
| # build/ + its .map files must exist at the same path the integration run | |
| # executed from, so c8 can resolve each build/*.js back to src/*.ts. Both | |
| # core and admin are rebuilt so the admin package's source maps resolve too. | |
| - name: Build (for source-map remapping) | |
| run: npm run build:all | |
| # Both artifacts extract into the same temp dir; V8 dump filenames are | |
| # unique, so they merge rather than collide. | |
| - name: Download raw unit coverage (V8) | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 | |
| with: | |
| name: c8-raw-unit | |
| path: coverage/.v8/all | |
| - name: Download raw integration coverage (V8) | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 | |
| with: | |
| name: c8-raw-integration | |
| path: coverage/.v8/all | |
| # Satellite integration V8, merged into the same temp dir so each | |
| # satellite's src counts toward the aggregate gate now that its specs run in | |
| # the satellite's own tier instead of core's integration suite. | |
| - name: Download raw satellite integration coverage (V8) | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 | |
| with: | |
| name: c8-raw-sso-integration | |
| path: coverage/.v8/all | |
| - name: Download raw satellite integration coverage (V8) — backup | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 | |
| with: | |
| name: c8-raw-backup-integration | |
| path: coverage/.v8/all | |
| - name: Download raw satellite integration coverage (V8) — admin | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 | |
| with: | |
| name: c8-raw-admin-integration | |
| path: coverage/.v8/all | |
| - name: Download raw satellite integration coverage (V8) — billing | |
| uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 | |
| with: | |
| name: c8-raw-billing-integration | |
| path: coverage/.v8/all | |
| - name: Aggregate coverage report (unit + integration, remapped to src) | |
| run: npm run coverage:report | |
| # Blocking gate on the merged unit+integration coverage. These floors come | |
| # from a real CI run, not the unit floors: the merged report covers a | |
| # different file set than the unit run (executed files vs the unit run's | |
| # `all: true` set), so its percentages differ — branches in particular read | |
| # LOWER here than in the unit gate. Keep a small margin under the measured | |
| # numbers and re-check after a run before ratcheting further. | |
| - name: Coverage gate (aggregate) | |
| env: | |
| COV_MIN_LINES: '80' # measured ~81.5% | |
| COV_MIN_FUNCTIONS: '78' # measured ~79.7% | |
| COV_MIN_BRANCHES: '77' # measured ~77.5% | |
| run: node scripts/coverage-gate.mjs coverage/lcov.info | |
| - name: Upload combined coverage | |
| if: always() | |
| uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 | |
| with: | |
| name: coverage-combined | |
| path: coverage/lcov.info | |
| if-no-files-found: warn |