1.0: tenant isolation hardening, the satellite split, and a real benchmark suite #112
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) | |
| run: npm run build:sso && npm run build:billing && npm run build:admin && npm run build:backup | |
| # 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 | |
| - 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 --workspace @adonisjs-lasagna/billing | |
| npm run test --workspace @adonisjs-lasagna/backup | |
| npm run test --workspace @adonisjs-lasagna/sso | |
| npm run test --workspace @adonisjs-lasagna/admin | |
| # 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 so we don't trip on devDependency-only CVEs that | |
| # never ship to consumers. `high` level matches the publish-gate bar. | |
| - name: npm audit (production deps) | |
| run: npm run audit:prod | |
| 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 | |
| run: | | |
| sudo apt-get 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 | |
| # 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 | |
| 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 | |
| run: | | |
| sudo apt-get 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 | |
| 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 | |
| - 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 |