diff --git a/.codecov.yml b/.codecov.yml index a566c54e6..e4c77b563 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,7 +1,8 @@ -# Coverage arrives in exactly two uploads: `unit` (jsdom lane) and `browser` -# (Chromium-WebGL2 lane). Both jobs in `_ci-checks.yml` are gated on the same -# `changes.outputs.engine == 'true'`, so they either both run or neither does — -# there is no case where one upload arrives alone and the second never comes. +# Coverage arrives in exactly two uploads: `unit` (jsdom lane) and `webgl` +# (Chromium-WebGL2 lane), both from a push to a long-lived branch. Both lanes +# are enabled by the same `engine` area, so they either both upload or neither +# does — there is no case where one upload arrives alone and the second never +# comes. # # Without this, Codecov judges the first upload it receives and posts a verdict # on jsdom-only coverage: every line the browser lane covers counts as a miss, @@ -57,7 +58,7 @@ bundle_analysis: status: informational warning_threshold: '5%' -# Upload flags — one per CI lane that uploads (see _ci-checks.yml). +# Upload flags — one per test lane that uploads coverage (see scripts/ci/lanes.ts). flags: unit: paths: @@ -69,7 +70,7 @@ flags: # webgpu bootstrap, browser-only branches). Merged with `unit` into the # overall number; carryforward keeps it for commits whose CI run skips the # browser lane. - browser: + webgl: paths: - src/ - packages/ @@ -77,7 +78,7 @@ flags: # Per-package coverage breakdown in the PR comment and dashboard. # Components are pure reporting slices over the merged uploads (`unit` + -# `browser`) — no extra uploads needed. +# `webgl`) — no extra uploads needed. component_management: default_rules: statuses: [] # informational only — no per-component commit statuses diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 000000000..55b997a63 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,69 @@ +name: Set up the workspace +description: pnpm, Node, dependencies, and optionally a Playwright browser, the Naga validator and the built dist. + +inputs: + node-version: + description: Node version for setup-node + default: '24.x' + browser: + description: Playwright browser to install (chromium or firefox); empty installs none + default: '' + apt: + description: Space-separated apt packages to install; empty installs none + default: '' + naga: + description: Install the Naga WGSL validator (pinned by NAGA_VERSION in the workflow env) + default: 'false' + dist: + description: Download the dist artifact the build job uploaded + default: 'false' + +runs: + using: composite + steps: + # The pnpm version comes from `packageManager` in package.json. + - uses: pnpm/action-setup@v6 + with: + run_install: false + + - uses: actions/setup-node@v6 + with: + node-version: ${{ inputs.node-version }} + check-latest: true + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - shell: bash + run: pnpm bootstrap + + - if: inputs.apt != '' + shell: bash + run: sudo apt-get update && sudo apt-get install -y ${{ inputs.apt }} + + - if: inputs.browser != '' + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-playwright- + + - if: inputs.browser != '' + shell: bash + run: pnpm exec playwright install --with-deps ${{ inputs.browser }} + + - if: inputs.naga == 'true' + id: naga-cache + uses: actions/cache@v5 + with: + path: ~/.cargo/bin/naga + key: naga-cli-${{ env.NAGA_VERSION }}-${{ runner.os }} + + - if: inputs.naga == 'true' && steps.naga-cache.outputs.cache-hit != 'true' + shell: bash + run: cargo install naga-cli --version "$NAGA_VERSION" --locked + + - if: inputs.dist == 'true' + uses: actions/download-artifact@v4 + with: + name: dist diff --git a/.github/workflows/_ci-checks.yml b/.github/workflows/_ci-checks.yml deleted file mode 100644 index 993c19e91..000000000 --- a/.github/workflows/_ci-checks.yml +++ /dev/null @@ -1,1427 +0,0 @@ -name: CI Checks - -# Reusable workflow with parallel quality gates (typecheck, lint, unit tests, -# browser tests), plus package verification and a final site build gate. -# -# Called by ci.yml and release.yml so both use exactly the same gate. - -on: - workflow_call: - inputs: - node-version: - description: 'Node.js version passed to setup-node' - type: string - default: '24.x' - ref: - description: 'Git ref to check out (empty = triggering commit)' - type: string - default: '' - pnpm-version: - description: 'pnpm version spec (e.g. 11.4.0, 11, latest)' - type: string - default: '11.4.0' - -permissions: - contents: read - # Codecov action posts a coverage comment on PRs. - pull-requests: write - -env: - CI: true - # Pinned so the WGSL gate is reproducible and its cache key is exact. Bumping - # it is a deliberate change: a newer Naga accepts more of the language, which - # moves what this gate rejects. - NAGA_VERSION: 26.0.0 - -jobs: - changes: - name: Detect changes - runs-on: ubuntu-latest - timeout-minutes: 5 - outputs: - engine: ${{ steps.decide.outputs.engine }} - site: ${{ steps.decide.outputs.site }} - audioFx: ${{ steps.decide.outputs.audioFx }} - tilemapWorker: ${{ steps.decide.outputs.tilemapWorker }} - exampleCatalog: ${{ steps.decide.outputs.exampleCatalog }} - benchStructural: ${{ steps.decide.outputs.benchStructural }} - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.sha }} - - # On pull requests, list every changed file (dorny/paths-filter resolves - # the base / merge-base robustly) and hand the list to - # scripts/ci/select-lanes.ts, which decides the effective lanes. Pushes to - # main, tag releases (via release.yml) and manual dispatches skip this step; - # select-lanes then runs every lane, so a release is never partially - # checked. - # - # The path-to-lane mapping lives in scripts/ci/select-lanes.ts — NOT in - # this YAML — so it is a single source of truth that - # test/ci/select-lanes.test.ts can assert deterministically. - - name: List changed files - id: changed - if: ${{ github.event_name == 'pull_request' }} - uses: dorny/paths-filter@v3 - with: - list-files: json - filters: | - all: - - '**' - - # select-lanes.ts is TypeScript that node type-strips on its own, so this - # job needs a node that does that unflagged (>= 22.18) — pinned here to the - # same version every other job uses rather than inherited from whatever the - # runner image happens to ship. Node only, no cache and no install: the - # detector stays dependency-free and always-on. - - name: Set up Node - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - - - name: Decide effective lanes - id: decide - env: - EVENT_NAME: ${{ github.event_name }} - CHANGED_FILES: ${{ steps.changed.outputs.all_files }} - run: node scripts/ci/select-lanes.ts >> "$GITHUB_OUTPUT" - - typecheck: - name: Typecheck - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.sha }} - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: ${{ inputs.pnpm-version }} - run_install: false - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - check-latest: true - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install dependencies - run: pnpm bootstrap - - # The gate list lives in scripts/ci/gates.ts, which the local verify:quick - # pre-push hook runs in full. Spelling the commands out here again is what - # let this job drift below the hook: it claimed parity in a comment while - # omitting typecheck:site and typecheck:test. Add a gate there, not here. - # - # This group covers src/** plus the sets root `pnpm typecheck` does not - # reach: guides, examples (strict config), the standalone type-tests - # project, the extension packages (own tsconfig each), and the test - # sources. typecheck:site is deliberately NOT here — it needs the built - # dist, so it runs as the `site` group in the site-build job. - - name: Typecheck gates - run: pnpm gates typecheck - - # Validates the published-manifest shape (exports map, files allowlist, - # peer/dev-dependency split, no `workspace:` leaks, ...) of every official - # runtime package via `@codexo/exojs-config/package-policy`. Pure Node, no - # third-party deps and no build required, so it stays ungated (like - # typecheck/lint) instead of waiting on the `build` job's dist artifact. - package-policy: - name: Package Policy - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.sha }} - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: ${{ inputs.pnpm-version }} - run_install: false - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - check-latest: true - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install dependencies - run: pnpm bootstrap - - - name: Verify package policy - run: pnpm verify:package-policy - - lint: - name: Lint - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.sha }} - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: ${{ inputs.pnpm-version }} - run_install: false - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - check-latest: true - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install dependencies - run: pnpm bootstrap - - # lint:all + format:check, from the shared list in scripts/ci/gates.ts. - # - # Prettier is enforced by the pre-push hook (verify:release), but hooks can - # be bypassed (--no-verify) and merged PRs were never format-gated, so - # formatting drifted across the v0.13 PRs and blocked the v0.13.0 tag push. - # This is the CI backstop. Auto-fixable: a failure means `pnpm format`. - - name: Lint gates - run: pnpm gates lint - - # Generated-artifact sync gates: the committed API docs and the committed - # example .js files must match a fresh generation from source. Both regenerate - # from src/ and examples/ without needing the built dist, so this stays ungated - # (like typecheck/lint/package-policy) instead of waiting on the `build` job — - # `docs:api:check` previously sat in `package-verify` behind that wait. - sync-checks: - name: Sync Checks - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.sha }} - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: ${{ inputs.pnpm-version }} - run_install: false - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - check-latest: true - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install dependencies - run: pnpm bootstrap - - # Both are dry-run checks that leave the working tree unchanged. - - name: Sync gates - run: pnpm gates sync - - unit-tests: - name: Unit Tests (Coverage) - needs: [changes] - if: ${{ needs.changes.outputs.engine == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.sha }} - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: ${{ inputs.pnpm-version }} - run_install: false - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - check-latest: true - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install dependencies - run: pnpm bootstrap - - # Naga is the WGSL front end of wgpu, and therefore of Firefox's WebGPU. - # `test/rendering/wgsl-naga-validation.test.ts` is the only gate in CI - # that sees WGSL through anything but Tint: the Firefox WebGPU lane gets - # no adapter here and runs non-blocking. Restored from cache on almost - # every run — the key pins the exact version, so a hit is a hit. - - name: Cache the Naga validator - id: naga-cache - uses: actions/cache@v5 - with: - path: ~/.cargo/bin/naga - key: naga-cli-${{ env.NAGA_VERSION }}-${{ runner.os }} - - - name: Install the Naga validator - if: steps.naga-cache.outputs.cache-hit != 'true' - run: cargo install naga-cli --version "$NAGA_VERSION" --locked - - # NOTE: no `--` before the extra args — pnpm (unlike npm) forwards a - # literal `--` to the script, and vitest silently discards everything - # after it (the junit reporter args here were dropped that way for - # months; the analytics upload below only warned, never failed). - - name: Test (with coverage) - # EXOJS_REQUIRE_NAGA turns a missing binary from a skip into a failure: - # locally the validator is optional, in CI its absence is a broken lane. - env: - EXOJS_REQUIRE_NAGA: '1' - run: pnpm test:coverage --reporter=default --reporter=junit --outputFile.junit=./test-results/unit.junit.xml - - # Deliberately OUTSIDE the coverage run. Istanbul rewrites every statement - # and V8 then stops scalar-replacing what it otherwise would, so the - # allocation gate measures the instrumentation instead of the engine — - # `mesh/1000` reads 71 KB/frame instrumented against 0.65 KB/frame plain. - # No junit reporter here: the `skip-budget` job below reads the file the - # coverage run wrote, and a second writer would replace it. - - name: Allocation gate (no coverage instrumentation) - run: pnpm test:alloc - - # A skipped test is neither a pass nor a failure, so it vanishes from the - # summary. The budget check itself runs once, in the `skip-budget` job, - # against every lane's JUnit report combined — not here per-lane — because - # a `ctx.skip()` inside a browser/WebGPU spec never shows up in THIS job's - # report at all. This step just hands that job the unit lane's half. - - name: Upload JUnit report for the skip-budget gate - if: ${{ !cancelled() }} - uses: actions/upload-artifact@v4 - with: - name: junit-unit - path: ./test-results/unit.junit.xml - retention-days: 1 - if-no-files-found: warn - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v6.0.2 - with: - files: ./coverage/lcov.info - flags: unit - # Loud on purpose when a token is present (the upload failed silently - # for months and nobody noticed), but non-fatal when it is absent: - # Dependabot / fork PRs run without repository secrets, so a strict - # upload would fail Required CI on a green test run (only the codecov - # step fails: "Token required because branch is protected"). - fail_ci_if_error: ${{ secrets.CODECOV_TOKEN != '' }} - token: ${{ secrets.CODECOV_TOKEN }} - slug: Exoridus/ExoJS - - # Test Analytics (flaky detection, failure rates, slowest tests). Runs - # even when the test step failed — capturing failures is the point. - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@v1 - with: - files: ./test-results/unit.junit.xml - flags: unit - fail_ci_if_error: false - token: ${{ secrets.CODECOV_TOKEN }} - - browser-tests: - name: Browser Tests - needs: [changes] - if: ${{ needs.changes.outputs.engine == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.sha }} - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: ${{ inputs.pnpm-version }} - run_install: false - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - check-latest: true - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install dependencies - run: pnpm bootstrap - - - name: Cache Playwright browsers - uses: actions/cache@v5 - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-playwright- - - - name: Install Playwright browsers - run: pnpm exec playwright install --with-deps chromium - - # Istanbul instrumentation is enabled so the real GPU-backend lines this - # lane executes count toward the Codecov number (the jsdom `unit` flag - # can never reach them). The global vitest coverage thresholds are the - # jsdom ratchet — zero them here, this lane only *collects* coverage. - # No `--` before the args (see the unit-tests step note). - - name: Browser tests (Chromium WebGL2, new headless) - run: >- - pnpm test:browser:webgl - --reporter=default --reporter=junit --outputFile.junit=./test-results/browser-webgl.junit.xml - --coverage --coverage.reporter=lcov --coverage.reporter=text-summary - --coverage.thresholds.statements=0 --coverage.thresholds.branches=0 - --coverage.thresholds.functions=0 --coverage.thresholds.lines=0 - - # The inline-source lane rides along here rather than owning a job: it - # needs the same headless Chromium and finishes in seconds, and its - # subject (`@codexo/exojs-build`) is in the engine path set that already - # gates this job. No coverage - it asserts that emitted strings run in a - # real AudioWorklet and a real Worker, not that engine lines execute. - - name: Browser tests (inline worklet/worker sources, headless Chromium) - run: pnpm test:browser:build - - # The asset cache's persistent store rides along for the same reasons: - # the same headless Chromium, seconds to run, and a subject in the engine - # path set that already gates this job. No coverage - it asserts that a - # real IndexedDB commits, clones and ranges the way the store assumes, - # not that engine lines execute. - - name: Browser tests (IndexedDB cache store, headless Chromium) - run: pnpm test:browser:assets - - - name: Upload coverage to Codecov (browser flag) - if: ${{ !cancelled() }} - uses: codecov/codecov-action@v6.0.2 - with: - files: ./coverage/lcov.info - flags: browser - # Non-fatal without a token (Dependabot / fork PRs) — see the unit - # coverage upload above for the rationale. - fail_ci_if_error: ${{ secrets.CODECOV_TOKEN != '' }} - token: ${{ secrets.CODECOV_TOKEN }} - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@v1 - with: - files: ./test-results/browser-webgl.junit.xml - flags: browser-webgl - fail_ci_if_error: false - token: ${{ secrets.CODECOV_TOKEN }} - - # See the unit-tests job's matching step: fed into the `skip-budget` job. - - name: Upload JUnit report for the skip-budget gate - if: ${{ !cancelled() }} - uses: actions/upload-artifact@v4 - with: - name: junit-browser-webgl - path: ./test-results/browser-webgl.junit.xml - retention-days: 1 - if-no-files-found: warn - - # Blocking lane. Chromium WebGPU is run against Mesa lavapipe (a real Vulkan - # software rasterizer) instead of Chromium's bundled SwiftShader WebGPU - # fallback — the three.js-proven recipe for getting actual GPU-path coverage - # out of a free `ubuntu-latest` runner (no Docker / self-hosted runner - # needed). `mesa-vulkan-drivers` provides the lavapipe ICD, `VK_DRIVER_FILES` - # points Chromium's ANGLE Vulkan backend at it, and the launch args in - # `vitest.config.ts` (`--enable-features=Vulkan`, `--disable-vulkan-surface`) - # are required for Chromium to actually pick up a Vulkan adapter instead of - # silently falling back to SwiftShader. lavapipe additionally needs a real - # display surface to report a usable adapter, so this job sets - # `EXOJS_WEBGPU_CI_HEADED=1` (opts the `browser-webgpu` vitest project into - # `headless: false`) and runs under `xvfb-run` to supply that display — CI - # only; the project stays headless by default so a plain local - # `pnpm test:browser:webgpu` never pops a visible browser window. Previously - # this lane self-skipped almost everything via `getBackendDeviceOrSkip()` - # under SwiftShader and was `continue-on-error`, so it reported green - # without proving anything; it is now a real, blocking WebGPU lane. - browser-tests-webgpu-chromium: - name: Browser Tests (Chromium WebGPU) - needs: [changes] - if: ${{ needs.changes.outputs.engine == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 20 - env: - VK_DRIVER_FILES: /usr/share/vulkan/icd.d/lvp_icd.x86_64.json - # Opts the `browser-webgpu` vitest project into `headless: false` (see - # vitest.config.ts). Unset locally, so `pnpm test:browser:webgpu` stays - # headless by default and never pops a visible browser window on a dev box. - EXOJS_WEBGPU_CI_HEADED: '1' - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.sha }} - - - name: Install Mesa lavapipe + xvfb - run: sudo apt-get update && sudo apt-get install -y mesa-vulkan-drivers xvfb - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: ${{ inputs.pnpm-version }} - run_install: false - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - check-latest: true - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install dependencies - run: pnpm bootstrap - - - name: Cache Playwright browsers - uses: actions/cache@v5 - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-playwright- - - - name: Install Playwright browsers - run: pnpm exec playwright install --with-deps chromium - - # No `--` before the args (see the unit-tests step note). - - name: Browser tests (Chromium WebGPU, Mesa lavapipe via xvfb) - run: xvfb-run -a pnpm test:browser:webgpu --reporter=default --reporter=junit --outputFile.junit=./test-results/browser-webgpu.junit.xml - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@v1 - with: - files: ./test-results/browser-webgpu.junit.xml - flags: browser-webgpu - fail_ci_if_error: false - token: ${{ secrets.CODECOV_TOKEN }} - - # See the unit-tests job's matching step: fed into the `skip-budget` job. - # This is the lane where the runtime `ctx.skip('WebGPU device lost - # mid-test — …')` calls actually fire — Mesa lavapipe's software adapter - # loses the device under load in a way a real GPU adapter does not — so - # this artifact is the one that makes them visible to the budget at all. - - name: Upload JUnit report for the skip-budget gate - if: ${{ !cancelled() }} - uses: actions/upload-artifact@v4 - with: - name: junit-browser-webgpu - path: ./test-results/browser-webgpu.junit.xml - retention-days: 1 - if-no-files-found: warn - - # The WebGL2 half of this lane is a required gate: running headed under xvfb - # gives Firefox a real WebGL2 context, and it has been at 262/262 since. It is - # the only lane covering a second engine, so a regression there should block. - # - # The WebGPU half stays non-blocking at the step level. Firefox exposes a - # WebGPU adapter only in a genuinely headed session — `requestAdapter()` - # returns null under xvfb regardless of `dom.webgpu.enabled` / - # `gfx.webgpu.force-enabled` — so those rows come from local runs via - # `pnpm test:browser:webgpu:firefox`. - browser-tests-firefox: - name: Browser Tests (Firefox) - needs: [changes] - if: ${{ needs.changes.outputs.engine == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.sha }} - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: ${{ inputs.pnpm-version }} - run_install: false - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - check-latest: true - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install dependencies - run: pnpm bootstrap - - - name: Cache Playwright browsers - uses: actions/cache@v5 - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-playwright- - - - name: Install Playwright browsers - run: pnpm exec playwright install --with-deps firefox - - - name: Install xvfb - run: sudo apt-get update && sudo apt-get install -y xvfb - - # Firefox disables WebGL outright in headless mode — no preference changes - # that, it is a browser-level limitation (playwright#1032, #21783, still - # current). A window is the only configuration where a context exists, so - # this runs headed against the virtual display xvfb supplies, the same - # recipe as the Chromium WebGPU lane above. LIBGL_ALWAYS_SOFTWARE points - # Mesa at llvmpipe so there is something to rasterise on. - - name: Browser tests (Firefox WebGL2, headed via xvfb) - env: - EXOJS_FIREFOX_CI_HEADED: '1' - LIBGL_ALWAYS_SOFTWARE: '1' - GALLIUM_DRIVER: llvmpipe - # No `--` before the args (see the unit-tests step note). - run: xvfb-run -a pnpm test:browser:webgl:firefox --reporter=default --reporter=junit --outputFile.junit=./test-results/browser-webgl-firefox.junit.xml - - # See the unit-tests job's matching step: fed into the `skip-budget` job. - # Only the blocking WebGL2 half — the WebGPU half below is non-blocking - # and does not run in CI by default (see the job comment above), so - # folding its skips into the global budget would make a required gate - # depend on a lane CI does not reliably execute. - - name: Upload JUnit report for the skip-budget gate - if: ${{ !cancelled() }} - uses: actions/upload-artifact@v4 - with: - name: junit-browser-webgl-firefox - path: ./test-results/browser-webgl-firefox.junit.xml - retention-days: 1 - if-no-files-found: warn - - # Non-blocking on purpose: no WebGPU adapter without a real display (see - # the job comment). Kept in the lane so the log records what a headless - # runner actually reports, rather than the absence being invisible. - - name: Browser tests (Firefox WebGPU, headed) - continue-on-error: true - run: pnpm test:browser:webgpu:firefox - - # Renders the audio-fx worklet effects (PitchShift/Vocoder/Granular) through a - # real OfflineAudioContext + AudioWorklet in headless Chromium — the acoustic - # contract layer the jsdom mock cannot cover. Path-gated to `audioFx` so it - # only runs when the audio-fx package (or a shared root) changes; required - # (blocking) on those PRs because it exercises our own DSP. - browser-tests-audio: - name: Browser Tests (Audio) - needs: [changes] - if: ${{ needs.changes.outputs.audioFx == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.sha }} - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: ${{ inputs.pnpm-version }} - run_install: false - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - check-latest: true - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install dependencies - run: pnpm bootstrap - - - name: Cache Playwright browsers - uses: actions/cache@v5 - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-playwright- - - - name: Install Playwright browsers - run: pnpm exec playwright install --with-deps chromium - - - name: Browser tests (audio, headless Chromium) - run: pnpm test:browser:audio --reporter=default --reporter=junit --outputFile.junit=./test-results/browser-audio.junit.xml - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@v1 - with: - files: ./test-results/browser-audio.junit.xml - flags: browser-audio - fail_ci_if_error: false - token: ${{ secrets.CODECOV_TOKEN }} - - # See the unit-tests job's matching step: fed into the `skip-budget` job. - # Path-gated like the job itself — absent on a run where this lane did - # not execute at all, which the aggregator tolerates (see that job). - - name: Upload JUnit report for the skip-budget gate - if: ${{ !cancelled() }} - uses: actions/upload-artifact@v4 - with: - name: junit-browser-audio - path: ./test-results/browser-audio.junit.xml - retention-days: 1 - if-no-files-found: warn - - # Runs WorkerSampledChunkSource's real-Worker round trip in headless Chromium - # — jsdom implements neither Worker nor URL.createObjectURL. Path-gated to - # `tilemapWorker` so it only runs when exojs-tilemap (or a shared root) - # changes; required (blocking) on those PRs because it exercises our own - # worker-transport logic. - browser-tests-tilemap-worker: - name: Browser Tests (Tilemap Worker) - needs: [changes] - if: ${{ needs.changes.outputs.tilemapWorker == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.sha }} - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: ${{ inputs.pnpm-version }} - run_install: false - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - check-latest: true - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install dependencies - run: pnpm bootstrap - - - name: Cache Playwright browsers - uses: actions/cache@v5 - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-playwright- - - - name: Install Playwright browsers - run: pnpm exec playwright install --with-deps chromium - - - name: Browser tests (tilemap worker, headless Chromium) - run: pnpm test:browser:tilemap --reporter=default --reporter=junit --outputFile.junit=./test-results/browser-tilemap-worker.junit.xml - - - name: Upload test results to Codecov - if: ${{ !cancelled() }} - uses: codecov/test-results-action@v1 - with: - files: ./test-results/browser-tilemap-worker.junit.xml - flags: browser-tilemap-worker - fail_ci_if_error: false - token: ${{ secrets.CODECOV_TOKEN }} - - # See the unit-tests job's matching step: fed into the `skip-budget` job. - # Path-gated like the job itself — absent on a run where this lane did - # not execute at all, which the aggregator tolerates (see that job). - - name: Upload JUnit report for the skip-budget gate - if: ${{ !cancelled() }} - uses: actions/upload-artifact@v4 - with: - name: junit-browser-tilemap-worker - path: ./test-results/browser-tilemap-worker.junit.xml - retention-days: 1 - if-no-files-found: warn - - # The benchmark harness's structural gate: exact draw/bind/upload counters - # compared against a committed baseline. - # - # It runs in CI - unlike the rest of `@codexo/exojs-bench`, which deliberately - # has no lane - because it needs neither a GPU nor a competitor library. The - # counters are integers decided CPU-side, so the run asks for the software - # rasterizer explicitly and only measures the ExoJS arms; nothing here installs - # `bench:setup`, so the competitor packages never enter the CI trust boundary. - bench-structural-gate: - name: Bench Structural Gate - needs: [changes] - if: ${{ needs.changes.outputs.benchStructural == 'true' }} - runs-on: ubuntu-latest - # Generous, because the run is fill-bound on a software rasterizer rather than - # CPU-bound: measured at roughly 55 minutes at the node count the gate first - # shipped with, which is why it now measures a smaller one. The cap is a - # runaway guard, not a budget. - timeout-minutes: 30 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.sha }} - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: ${{ inputs.pnpm-version }} - run_install: false - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - check-latest: true - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install dependencies - run: pnpm bootstrap - - - name: Cache Playwright browsers - uses: actions/cache@v5 - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-playwright- - - - name: Install Playwright browsers - run: pnpm exec playwright install --with-deps chromium - - - name: Structural counter gate (software rasterizer) - run: pnpm gate:bench:structural - - # Aggregates the JUnit report every test lane above just uploaded and runs - # the skip-budget gate against ALL of them combined — one global budget per - # suite file, not one per lane (see scripts/check-skipped-tests.ts). This is - # deliberately its own job, downstream of every lane, rather than a step - # inside `unit-tests`: a runtime `ctx.skip('WebGPU device lost mid-test — …')` - # only ever appears in `browser-tests-webgpu-chromium`'s report, so checking - # from inside the unit lane alone could never see it — exactly the blind spot - # this gate exists to close. - # - # `needs` lists every lane that MAY upload a `junit-*` artifact so this job - # waits for all of them; `!cancelled()` (rather than the default implicit - # `success()`) still runs it when a path-gated lane (audio/tilemap-worker) - # was skipped, since the download step below tolerates a pattern matching - # zero artifacts. It does NOT run after an outright cancellation. - skip-budget: - name: Skip Budget - needs: [changes, unit-tests, browser-tests, browser-tests-webgpu-chromium, browser-tests-firefox, browser-tests-audio, browser-tests-tilemap-worker] - if: ${{ !cancelled() && needs.changes.outputs.engine == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.sha }} - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: ${{ inputs.pnpm-version }} - run_install: false - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - check-latest: true - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install dependencies - run: pnpm bootstrap - - # Every lane's `junit-*` artifact, merged into one directory. A - # path-gated lane that didn't run this time simply contributes nothing: - # a `pattern` matching fewer artifacts than expected only warns, so a - # docs-only PR's missing audio/tilemap-worker artifact doesn't fail here. - # Matching NOTHING at all is still fatal, but one step later and with a - # better message — the checker refuses to read zero reports as zero skips. - - name: Download JUnit reports from every lane - uses: actions/download-artifact@v4 - with: - pattern: junit-* - path: test-results - merge-multiple: true - - - name: Check skipped tests against the budget (all lanes combined) - run: pnpm test:skips:check - - # Builds the core package and all extension packages exactly once, then - # publishes the resulting dist trees as a shared artifact. `package-verify` - # and `site-build` both consume this artifact instead of each rebuilding the - # library themselves — this collapses what used to be a serial - # `lint -> package-verify (builds) -> site-build (rebuilds)` critical path - # into a single build that the two downstream jobs can consume in parallel. - build: - name: Build - needs: [changes] - # Gated on engine OR site (not just engine): a site-only change still - # needs the library built, because `site:build`'s vendor:sync reads from - # the built dist/ tree. `needs: [changes]` only (not typecheck/lint) so - # this build runs in parallel with those gates rather than after them — a - # wasted build on a rare lint/typecheck failure is an acceptable trade for - # the parallelism, since required-ci fails on typecheck/lint regardless. - if: ${{ needs.changes.outputs.engine == 'true' || needs.changes.outputs.site == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.sha }} - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: ${{ inputs.pnpm-version }} - run_install: false - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - check-latest: true - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install dependencies - run: pnpm bootstrap - - - name: Build core - env: - # Enables the Codecov bundle-analysis upload in scripts/build.ts; - # absent (e.g. fork PRs, local builds) the build stays offline. - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - # Also build + track the all-in-one bundle (core + every extension) - # so Codecov's Bundles tab carries the "everything together" number. - EXOJS_FULL_BUNDLE: '1' - run: pnpm build - - # The extension packages each have their own Rolldown build; pnpm runs - # them in workspace-dependency order (tilemap before tiled). - - name: Build extension packages - env: - # Per-package bundle-analysis uploads (see exojs-config/rolldown). - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - run: pnpm --filter "@codexo/exojs-particles" --filter "@codexo/exojs-tilemap" --filter "@codexo/exojs-tiled" --filter "@codexo/exojs-physics" --filter "@codexo/exojs-tilemap-physics" --filter "@codexo/exojs-audio-fx" --filter "@codexo/exojs-aseprite" --filter "@codexo/exojs-ldtk" --filter "@codexo/exojs-react" build - - # The dist-dependent half of production-stripping.test.ts asserts that the - # artefacts we actually ship carry no unresolved __DEV__/__VERSION__/ - # __REVISION__ and that buildInfo matches the manifest. The unit lane never - # builds, so those checks self-skipped on every run; this is the only lane - # where `dist/` exists. EXOJS_REQUIRE_PRODUCTION_BUILD turns a missing - # build into a failure so the step cannot go quiet again. - - name: Verify production stripping against the built dist - env: - EXOJS_REQUIRE_PRODUCTION_BUILD: '1' - run: pnpm test:production-stripping - - - name: Upload dist artifact - uses: actions/upload-artifact@v4 - with: - name: dist - path: | - dist/ - packages/*/dist/ - retention-days: 1 - if-no-files-found: error - - package-verify: - name: Package Build + Verify - needs: [changes, build] - if: ${{ needs.changes.outputs.engine == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.sha }} - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: ${{ inputs.pnpm-version }} - run_install: false - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - check-latest: true - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install dependencies - run: pnpm bootstrap - - # Restores dist/ + packages/*/dist/ (built once by the `build` job) - # instead of rebuilding here. - - name: Download dist artifact - uses: actions/download-artifact@v4 - with: - name: dist - - - name: Check bundle sizes - run: pnpm size - - # Reports, never judges: the step above is the gate. `always()` so a - # breached budget still says by how much instead of only that it failed. - - name: Report bundle budgets - if: always() - run: pnpm size:summary - - - name: Verify core package exports - run: pnpm verify:exports - - # A published .d.ts may only import what a consumer can resolve. Catches - # the shader/worklet specifiers here rather than in the release job's - # external-consumer smoke, which is the only other place that reads the - # emitted declarations the way a consumer does. - - name: Verify declaration imports - run: pnpm verify:declaration-imports - - # Lockstep versions + peer-range coherence + deterministic publish-order - # matrix across all four published packages (the same gates release.yml - # runs before a publish). - - name: Verify lockstep versions and peer ranges - run: pnpm verify:lockstep - - - name: Verify release publish matrix - run: pnpm verify:release-matrix - - - name: Core package dry run - run: pnpm pack --dry-run - - # Pack each extension package (dry run) so a broken `files`/`exports` set in - # a package manifest is caught here, not at release time. - # `@codexo/exojs-build` is published too, but on its own tooling version - # line rather than the engine lockstep, so it is packed here and left out - # of the lockstep/publish-order matrix above. - - name: Extension package dry runs - run: pnpm --filter "@codexo/exojs-build" --filter "@codexo/exojs-particles" --filter "@codexo/exojs-tilemap" --filter "@codexo/exojs-tiled" --filter "@codexo/exojs-physics" --filter "@codexo/exojs-tilemap-physics" --filter "@codexo/exojs-audio-fx" --filter "@codexo/exojs-aseprite" --filter "@codexo/exojs-ldtk" --filter "@codexo/exojs-react" pack --dry-run - - # publint validates the published package.json itself (exports map, files, - # types resolution, module/main coherence) — the layer attw does NOT cover - # (attw checks .d.ts resolution; publint checks the manifest contract). - # Pinned + via dlx (no permanent dependency), matching the attw pattern. - # Runs against the built dist of each package (core + extensions built above). - - name: Validate published package manifests (publint) - run: pnpm verify:publint - - release-dry-run: - name: Release dry run - # PR-only shift-left of the build-once release `prepare` (pack -> attw -> - # external consumers). Every tooling bug that broke the v0.13.0 release — - # tilemap missing from the pack set, attw output-format drift — would have - # failed HERE on a cheap PR instead of mid-flight on an irreversible tag. - # `--skip-zip` avoids the site build + 220 MB archive. It does NOT run the - # real publish (provenance / dist-tag need real OIDC), so the - # repository-field/provenance prerequisite is covered by verify:release-matrix - # instead. The tag/release context skips this — release.yml runs the real - # prepare there. Path-filtered to release-relevant changes so most PRs skip it. - if: ${{ github.event_name == 'pull_request' }} - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Filter release-relevant changes - id: filter - uses: dorny/paths-filter@v3 - with: - filters: | - release: - - 'scripts/release/**' - - 'package.json' - - 'pnpm-lock.yaml' - - 'packages/*/package.json' - - '.github/workflows/release.yml' - - - name: Setup pnpm - if: steps.filter.outputs.release == 'true' - uses: pnpm/action-setup@v6 - with: - version: ${{ inputs.pnpm-version }} - run_install: false - - - name: Setup Node - if: steps.filter.outputs.release == 'true' - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - check-latest: true - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install dependencies - if: steps.filter.outputs.release == 'true' - run: pnpm bootstrap - - - name: Release prepare (build-once, no publish, no full-zip) - if: steps.filter.outputs.release == 'true' - run: pnpm release:prepare --build --skip-zip - - site-build: - name: Site build - # Runs whenever site/examples/packages changed. Depends only on `build` - # (not `package-verify`) so it runs in parallel with package-verify - # instead of serially after it; it still won't waste a site build if the - # shared build failed. - needs: [changes, build] - if: ${{ !cancelled() && needs.changes.outputs.site == 'true' && needs.build.result == 'success' }} - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.sha }} - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: ${{ inputs.pnpm-version }} - run_install: false - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - check-latest: true - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - # Root install is required because the site consumes the local - # workspace package and vendor:sync reads from the built dist/ tree. - - name: Install root dependencies - run: pnpm bootstrap - - # Restores dist/ + packages/*/dist/ (built once by the `build` job) - # instead of rebuilding here. - - name: Download dist artifact - uses: actions/download-artifact@v4 - with: - name: dist - - # `astro check` + `tsc --noEmit` over the site sources. This is the one - # verify:quick gate that cannot run in the ungated typecheck job: the site - # consumes @codexo/exojs as a workspace package whose `types` resolve to - # dist/esm/index.d.ts, which only exists after the `build` job. Runs before - # the build so a type error reports as a typecheck failure, not a build one. - - name: Site typecheck gate - run: pnpm gates site - - # Site build = vendor:sync + examples:sync + astro build. - - name: Build site - env: - EXOJS_PACKAGE_PATH: .. - # Enables the Codecov bundle-analysis upload in astro.config.ts. - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - run: pnpm site:build - - # The bytes that just passed `gates site` + `site:build` ARE the bytes - # GitHub Pages serves: deploy-pages.yml downloads this artifact from this - # run and deploys it verbatim, instead of checking the sources out and - # producing a second, ungated build of its own. The commit in the name - # binds the artifact to a single commit on both sides of the handover, so - # a mismatched pair fails to resolve rather than deploying silently. - - name: Upload site artifact - uses: actions/upload-artifact@v4 - with: - name: site-dist-${{ github.sha }} - path: site/dist/ - retention-days: 1 - if-no-files-found: error - - # Boots every entry in the example catalog in headless Chromium. Nothing else - # in CI executes an example: `typecheck:examples` compiles the sources, - # `examples:sync:check` compares the generated `.js` twins against them, and - # the site build only has to render the pages that link them - an example - # whose scene throws on start passes all three and still ships a black canvas. - # - # Consumes the `site-build` job's artifact rather than building the site a - # second time: `smoke-examples.ts` serves `site/dist` over a throwaway static - # server, so the bytes it smokes are the bytes that job validated. - example-smoke: - name: Example Smoke - needs: [changes, site-build] - if: ${{ !cancelled() && needs.changes.outputs.exampleCatalog == 'true' && needs['site-build'].result == 'success' }} - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ inputs.ref || github.sha }} - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: ${{ inputs.pnpm-version }} - run_install: false - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: ${{ inputs.node-version }} - check-latest: true - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install dependencies - run: pnpm bootstrap - - - name: Cache Playwright browsers - uses: actions/cache@v5 - with: - path: ~/.cache/ms-playwright - key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-playwright- - - - name: Install Playwright browsers - run: pnpm exec playwright install --with-deps chromium - - - name: Download site artifact - uses: actions/download-artifact@v4 - with: - name: site-dist-${{ github.sha }} - path: site/dist - - # A pull request smokes one example per catalog category; a push to a - # long-lived branch and a merge-queue entry smoke the whole catalog. - # The subset is a sixth of the wall time and still catches what this lane - # exists for - a renderer path or engine change that stops a whole - # category from drawing while every other lane stays green. A defect - # confined to one example is caught before it can reach `next`, which is - # the point the full run is placed at. - # `--renderer webgl2` withholds the WebGPU adapter so the playground picks - # WebGL2. The runner has no GPU, and a WebGPU canvas backed by the - # software rasteriser reaches the harness's capture as the clear colour - # alone - a signature indistinguishable from an example that drew - # nothing, which made every entry in the catalog read as blank. WebGPU - # itself is covered by the browser-webgpu test projects. - - name: Smoke the example catalog - run: pnpm test:examples:smoke --renderer webgl2 ${{ github.event_name == 'pull_request' && '--sample' || '' }} - - # The harness writes a per-example table, and for a blank verdict the - # capture that verdict was read from; keep both when the lane is red so - # the failing entry is readable without re-running anything. - - name: Upload the smoke report - if: ${{ !cancelled() }} - uses: actions/upload-artifact@v4 - with: - name: example-smoke-report - path: | - .workspace/reports/example-smoke.md - .workspace/reports/example-smoke-artifacts - retention-days: 7 - if-no-files-found: ignore - - required-ci: - name: Required CI - if: ${{ always() }} - needs: - [ - changes, - typecheck, - lint, - sync-checks, - package-policy, - unit-tests, - browser-tests, - browser-tests-webgpu-chromium, - browser-tests-firefox, - browser-tests-audio, - browser-tests-tilemap-worker, - skip-budget, - build, - package-verify, - site-build, - example-smoke, - bench-structural-gate, - ] - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Evaluate required job results - env: - ENGINE: ${{ needs.changes.outputs.engine }} - SITE: ${{ needs.changes.outputs.site }} - AUDIOFX: ${{ needs.changes.outputs.audioFx }} - TILEMAPWORKER: ${{ needs.changes.outputs.tilemapWorker }} - EXAMPLECATALOG: ${{ needs.changes.outputs.exampleCatalog }} - BENCHSTRUCTURAL: ${{ needs.changes.outputs.benchStructural }} - CHANGES_RESULT: ${{ needs.changes.result }} - TYPECHECK_RESULT: ${{ needs.typecheck.result }} - LINT_RESULT: ${{ needs.lint.result }} - SYNC_CHECKS_RESULT: ${{ needs['sync-checks'].result }} - PACKAGE_POLICY_RESULT: ${{ needs['package-policy'].result }} - UNIT_TESTS_RESULT: ${{ needs['unit-tests'].result }} - BROWSER_TESTS_RESULT: ${{ needs['browser-tests'].result }} - BROWSER_WEBGPU_RESULT: ${{ needs['browser-tests-webgpu-chromium'].result }} - BROWSER_FIREFOX_RESULT: ${{ needs['browser-tests-firefox'].result }} - BROWSER_AUDIO_RESULT: ${{ needs['browser-tests-audio'].result }} - BROWSER_TILEMAP_WORKER_RESULT: ${{ needs['browser-tests-tilemap-worker'].result }} - SKIP_BUDGET_RESULT: ${{ needs['skip-budget'].result }} - BUILD_RESULT: ${{ needs.build.result }} - PACKAGE_VERIFY_RESULT: ${{ needs['package-verify'].result }} - SITE_BUILD_RESULT: ${{ needs['site-build'].result }} - EXAMPLE_SMOKE_RESULT: ${{ needs['example-smoke'].result }} - BENCH_STRUCTURAL_RESULT: ${{ needs['bench-structural-gate'].result }} - run: | - echo "areas: engine=$ENGINE site=$SITE audioFx=$AUDIOFX tilemapWorker=$TILEMAPWORKER exampleCatalog=$EXAMPLECATALOG benchStructural=$BENCHSTRUCTURAL" - echo "changes: $CHANGES_RESULT" - echo "typecheck: $TYPECHECK_RESULT" - echo "lint: $LINT_RESULT" - echo "sync-checks: $SYNC_CHECKS_RESULT" - echo "package-policy: $PACKAGE_POLICY_RESULT" - echo "unit-tests: $UNIT_TESTS_RESULT" - echo "browser-tests: $BROWSER_TESTS_RESULT" - echo "browser-tests-webgpu-chromium: $BROWSER_WEBGPU_RESULT" - echo "browser-tests-firefox: $BROWSER_FIREFOX_RESULT" - echo "browser-tests-audio: $BROWSER_AUDIO_RESULT" - echo "browser-tests-tilemap-worker: $BROWSER_TILEMAP_WORKER_RESULT" - echo "skip-budget: $SKIP_BUDGET_RESULT" - echo "build: $BUILD_RESULT" - echo "package-verify: $PACKAGE_VERIFY_RESULT" - echo "site-build: $SITE_BUILD_RESULT" - echo "example-smoke: $EXAMPLE_SMOKE_RESULT" - echo "bench-structural-gate: $BENCH_STRUCTURAL_RESULT" - - failed=0 - - # The change detector must run — a skipped detector would silently - # false-green every downstream lane. - if [ "$CHANGES_RESULT" != "success" ]; then - echo "::error::Detect changes did not succeed (result: $CHANGES_RESULT)." - failed=1 - fi - - # A real "failure" or "cancelled" in any required lane fails the gate. - # "skipped" is tolerated HERE because path filtering can legitimately - # skip a whole area (a docs-only PR skips the engine lanes; an - # engine-only PR skips the site build). Wrongly-skipped IMPACTED lanes - # are caught by the contract checks below. - for entry in \ - "typecheck=$TYPECHECK_RESULT" \ - "lint=$LINT_RESULT" \ - "sync-checks=$SYNC_CHECKS_RESULT" \ - "package-policy=$PACKAGE_POLICY_RESULT" \ - "unit-tests=$UNIT_TESTS_RESULT" \ - "browser-tests=$BROWSER_TESTS_RESULT" \ - "browser-tests-webgpu-chromium=$BROWSER_WEBGPU_RESULT" \ - "browser-tests-firefox=$BROWSER_FIREFOX_RESULT" \ - "browser-tests-audio=$BROWSER_AUDIO_RESULT" \ - "browser-tests-tilemap-worker=$BROWSER_TILEMAP_WORKER_RESULT" \ - "skip-budget=$SKIP_BUDGET_RESULT" \ - "build=$BUILD_RESULT" \ - "package-verify=$PACKAGE_VERIFY_RESULT" \ - "site-build=$SITE_BUILD_RESULT" \ - "example-smoke=$EXAMPLE_SMOKE_RESULT" \ - "bench-structural-gate=$BENCH_STRUCTURAL_RESULT"; do - name="${entry%%=*}" - result="${entry#*=}" - if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then - echo "::error::Required job '$name' $result." - failed=1 - fi - done - - # Contract enforcement (the core of this fix): when the detector - # classifies a change as engine-impacting, the engine lanes MUST have - # run. A "skipped" here means the path filter wrongly excluded an - # impacted change — exactly the defect that let package-only PRs go - # green while their unit / package / browser lanes were skipped. - # `browser-tests-webgpu-chromium` shares the same `engine` gate as - # `browser-tests` / `unit-tests` / `package-verify` (see its `if:` - # above), so it belongs in this same contract check rather than a - # bespoke one like `browser-tests-audio`'s (audio-fx has its own, - # narrower path-filter flag). - if [ "$ENGINE" = "true" ]; then - for entry in \ - "unit-tests=$UNIT_TESTS_RESULT" \ - "browser-tests=$BROWSER_TESTS_RESULT" \ - "browser-tests-webgpu-chromium=$BROWSER_WEBGPU_RESULT" \ - "browser-tests-firefox=$BROWSER_FIREFOX_RESULT" \ - "skip-budget=$SKIP_BUDGET_RESULT" \ - "build=$BUILD_RESULT" \ - "package-verify=$PACKAGE_VERIFY_RESULT"; do - name="${entry%%=*}" - result="${entry#*=}" - if [ "$result" = "skipped" ]; then - echo "::error::Engine change detected but required lane '$name' was skipped (path-filter regression)." - failed=1 - fi - done - fi - - # A rendering / bench change must run the structural counter gate. - if [ "$BENCHSTRUCTURAL" = "true" ] && [ "$BENCH_STRUCTURAL_RESULT" = "skipped" ]; then - echo "::error::Rendering or bench change detected but 'bench-structural-gate' was skipped (path-filter regression)." - failed=1 - fi - - # An audio-fx change must run the browser-audio lane. - if [ "$AUDIOFX" = "true" ] && [ "$BROWSER_AUDIO_RESULT" = "skipped" ]; then - echo "::error::Audio-fx change detected but 'browser-tests-audio' was skipped (path-filter regression)." - failed=1 - fi - - # A tilemap-worker change must run the browser-tilemap-worker lane. - if [ "$TILEMAPWORKER" = "true" ] && [ "$BROWSER_TILEMAP_WORKER_RESULT" = "skipped" ]; then - echo "::error::Tilemap-worker change detected but 'browser-tests-tilemap-worker' was skipped (path-filter regression)." - failed=1 - fi - - # Likewise a site-impacting change must build the site — unless the - # package build failed first, in which case site-build is intentionally - # skipped and the package-verify failure is already counted above. - if [ "$SITE" = "true" ] && [ "$PACKAGE_VERIFY_RESULT" != "failure" ]; then - if [ "$SITE_BUILD_RESULT" = "skipped" ]; then - echo "::error::Site change detected but 'site-build' was skipped (path-filter regression)." - failed=1 - fi - fi - - # An example-catalog change must smoke the catalog - unless the site - # build it consumes failed first, in which case example-smoke is - # intentionally skipped and the site-build failure is already counted. - if [ "$EXAMPLECATALOG" = "true" ] && [ "$SITE_BUILD_RESULT" != "failure" ] && [ "$BUILD_RESULT" != "failure" ]; then - if [ "$EXAMPLE_SMOKE_RESULT" = "skipped" ]; then - echo "::error::Example-catalog change detected but 'example-smoke' was skipped (path-filter regression)." - failed=1 - fi - fi - - if [ "$failed" -ne 0 ]; then - echo "::error::At least one required CI check failed, was cancelled, or was wrongly skipped." - exit 1 - fi - echo "All required CI checks satisfied." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca1e95810..3289c5095 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,28 +1,288 @@ name: CI +# One workflow, five stages. Which lanes run is decided once, in `plan`, from +# the lane table in scripts/ci/lanes.ts - the same table the pre-push hook +# runs locally. A lane is added there, never here. +# +# plan ─┬─► gates (matrix) +# ├─► test (matrix) ─► skip-budget +# └─► build ─┬─► verify (matrix) +# └─► site ─► smoke +# all ─► verdict (the required check) +# +# A pull request runs the lanes its changed files require; a push, a tag or a +# dispatch runs every lane. Coverage is collected on pushes to the long-lived +# branches only, so a pull request's unit lane runs uninstrumented. + on: push: branches: [main, next] pull_request: branches: [main, next] - # Run the same gate on merge-queue entries so the queue can require it. On a - # merge_group event select-lanes.ts (non-PR path) runs every lane, so the - # queued merge is checked against the full matrix. - merge_group: workflow_dispatch: permissions: contents: read + # Codecov posts its coverage comment on pull requests. pull-requests: write concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} + group: ci-${{ github.ref }} cancel-in-progress: true +env: + CI: true + # Pinned so the WGSL gate is reproducible: a newer Naga accepts more of the + # language, which moves what the gate rejects. + NAGA_VERSION: 26.0.0 + jobs: - checks: - uses: ./.github/workflows/_ci-checks.yml - # Reusable workflows do NOT see caller secrets by default; without this the - # Codecov upload runs tokenless and is rejected ("Token required because - # branch is protected"). - secrets: inherit + plan: + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + gates: ${{ steps.plan.outputs.gates }} + test: ${{ steps.plan.outputs.test }} + verify: ${{ steps.plan.outputs.verify }} + hasTest: ${{ steps.plan.outputs.hasTest }} + hasVerify: ${{ steps.plan.outputs.hasVerify }} + build: ${{ steps.plan.outputs.build }} + site: ${{ steps.plan.outputs.site }} + smoke: ${{ steps.plan.outputs.smoke }} + smokeSample: ${{ steps.plan.outputs.smokeSample }} + skipBudget: ${{ steps.plan.outputs.skipBudget }} + coverage: ${{ steps.plan.outputs.coverage }} + steps: + - uses: actions/checkout@v6 + + - if: github.event_name == 'pull_request' + id: changed + uses: dorny/paths-filter@v3 + with: + list-files: json + filters: | + all: + - '**' + + # No install: the planner is dependency-free TypeScript that node strips. + - uses: actions/setup-node@v6 + with: + node-version: '24.x' + + - id: plan + env: + EVENT_NAME: ${{ github.event_name }} + CHANGED_FILES: ${{ steps.changed.outputs.all_files }} + REF_NAME: ${{ github.ref_name }} + run: node scripts/ci/lanes.ts >> "$GITHUB_OUTPUT" + + gates: + name: gates (${{ matrix.id }}) + needs: plan + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.plan.outputs.gates) }} + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup + - run: ${{ matrix.run }} + + test: + name: test (${{ matrix.id }}) + needs: plan + if: needs.plan.outputs.hasTest == 'true' + runs-on: ubuntu-latest + timeout-minutes: ${{ matrix.timeoutMinutes }} + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.plan.outputs.test) }} + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup + with: + browser: ${{ matrix.browser }} + apt: ${{ matrix.apt }} + naga: ${{ matrix.naga }} + + - run: ${{ matrix.run }} + + - name: Keep the JUnit report for the skip budget + if: ${{ matrix.junit && !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: junit-${{ matrix.id }} + path: test-results/${{ matrix.id }}.junit.xml + retention-days: 1 + if-no-files-found: warn + + - if: ${{ matrix.junit && !cancelled() }} + uses: codecov/test-results-action@v1 + with: + files: ./test-results/${{ matrix.id }}.junit.xml + flags: ${{ matrix.id }} + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} + + - if: ${{ matrix.coverage }} + uses: codecov/codecov-action@v6.0.2 + with: + files: ./coverage/lcov.info + flags: ${{ matrix.id }} + fail_ci_if_error: ${{ secrets.CODECOV_TOKEN != '' }} + token: ${{ secrets.CODECOV_TOKEN }} + slug: Exoridus/ExoJS + + # One budget over every lane's report: a test that moves from one lane to + # another must not change the count. + skip-budget: + needs: [plan, test] + if: ${{ !cancelled() && needs.plan.outputs.skipBudget == 'true' && needs.test.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup + - uses: actions/download-artifact@v4 + with: + pattern: junit-* + path: test-results + merge-multiple: true + - run: pnpm test:skips:check + + # Built once; everything downstream consumes the artifact. + build: + needs: plan + if: needs.plan.outputs.build == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup + + - name: Build core + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + EXOJS_FULL_BUNDLE: '1' + run: pnpm build + + - name: Build extension packages + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + run: >- + pnpm --filter "@codexo/exojs-particles" --filter "@codexo/exojs-tilemap" --filter "@codexo/exojs-tiled" + --filter "@codexo/exojs-physics" --filter "@codexo/exojs-tilemap-physics" --filter "@codexo/exojs-audio-fx" + --filter "@codexo/exojs-aseprite" --filter "@codexo/exojs-ldtk" --filter "@codexo/exojs-react" build + + - name: Verify production stripping against the built dist + env: + EXOJS_REQUIRE_PRODUCTION_BUILD: '1' + run: pnpm test:production-stripping + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: | + dist/ + packages/*/dist/ + retention-days: 1 + if-no-files-found: error + + verify: + name: verify (${{ matrix.id }}) + needs: [plan, build] + if: ${{ !cancelled() && needs.plan.outputs.hasVerify == 'true' && needs.build.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: ${{ matrix.timeoutMinutes }} + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.plan.outputs.verify) }} + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup + with: + dist: ${{ matrix.dist }} + - run: ${{ matrix.run }} + + site: + needs: [plan, build] + if: ${{ !cancelled() && needs.plan.outputs.site == 'true' && needs.build.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup + with: + dist: 'true' + + # Typechecks the site against the published entry points, so it needs + # the dist the build job produced. + - run: pnpm gates site + + - env: + EXOJS_PACKAGE_PATH: .. + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + run: pnpm site:build + + # Deploy Pages ships exactly this artifact; it builds nothing itself. + - uses: actions/upload-artifact@v4 + with: + name: site-dist-${{ github.sha }} + path: site/dist/ + retention-days: 1 + if-no-files-found: error + + # Every catalog example through the real playground route, against the site + # job's artifact. A pull request smokes one example per category. + # `--renderer webgl2` withholds the WebGPU adapter: the runner has no GPU, + # and a software-rasterised WebGPU canvas reaches the capture as the clear + # colour alone, which reads as blank for every entry. WebGPU itself is + # covered by the webgpu test lane. + smoke: + needs: [plan, site] + if: ${{ !cancelled() && needs.plan.outputs.smoke == 'true' && needs.site.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup + with: + browser: chromium + - uses: actions/download-artifact@v4 + with: + name: site-dist-${{ github.sha }} + path: site/dist + + - run: pnpm test:examples:smoke --renderer webgl2 ${{ needs.plan.outputs.smokeSample == 'true' && '--sample' || '' }} + + - name: Keep the smoke report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: example-smoke-report + path: | + .workspace/reports/example-smoke.md + .workspace/reports/example-smoke-artifacts + retention-days: 7 + if-no-files-found: ignore + + # The one required status check. Fails on any job the plan asked for that + # did not succeed - a skipped one included. + verdict: + if: always() + needs: [plan, gates, test, skip-budget, build, verify, site, smoke] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '24.x' + - env: + NEEDS: ${{ toJSON(needs) }} + PLAN: ${{ toJSON(needs.plan.outputs) }} + run: node scripts/ci/verdict.ts diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index bd6ad2f0f..874a0dce3 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -1,10 +1,10 @@ name: Deploy Pages # Deploys the site EXACTLY as CI built and validated it — this workflow builds -# no source code. `_ci-checks.yml`'s `site-build` job runs `gates site` and -# `site:build` and uploads `site/dist/**` as `site-dist-`; here that same -# artifact is downloaded from the run that triggered this one and handed to -# Pages unchanged. +# no source code. CI's `site` job (and a release's `prepare` job) runs the site +# gates and `site:build` and uploads `site/dist/**` as `site-dist-`; here +# that same artifact is downloaded from the run that triggered this one and +# handed to Pages unchanged. # # The defect this prevents: the workflow used to check the sources out and # rebuild the library and the site itself, so what went live was a second build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f1d68c85..1732a53d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,12 @@ name: Release +# Tag -> trust -> prepare -> publish. +# +# The tagged commit is not re-verified here: `trust` requires it to sit on +# `main` with a green `verdict` from the CI workflow, which ran every lane on +# the push that brought it there. Tagging a commit CI never validated is a +# hard failure, not a slower path. + on: push: tags: @@ -7,96 +14,90 @@ on: workflow_dispatch: inputs: tag: - description: 'Tag to publish (must already exist, e.g. v0.6.3)' + description: 'Tag to publish (must already exist, e.g. v0.16.2)' required: true permissions: contents: write - # Required so npm-cli can mint an OIDC token from GitHub at publish time. + # npm mints its provenance token from GitHub OIDC at publish time. id-token: write + # `trust` reads the tagged commit's check runs. + checks: read + actions: read concurrency: group: release-${{ github.ref }} cancel-in-progress: false env: - # `github.ref_name` is the tag name (e.g. `v0.9.0`) for tag-push triggers - # and `main` for workflow_dispatch. The dispatch input takes priority so - # an explicitly-passed tag always wins. + # The dispatch input wins over the pushed ref so an explicit tag is always + # the one released. TARGET_TAG: ${{ github.event.inputs.tag || github.ref_name }} - NODE_VERSION: '24.x' - PNPM_VERSION: '11.4.0' jobs: - # Run the full CI gate (typecheck -> lint -> test -> build -> site-build) at - # the exact tagged commit before anything is built for release. - checks: - name: Verify at tag - permissions: - contents: read - pull-requests: write - uses: ./.github/workflows/_ci-checks.yml - with: - node-version: '24.x' - pnpm-version: '11.4.0' - ref: ${{ github.event.inputs.tag || github.ref_name }} - # Pass caller secrets through (CODECOV_TOKEN) — see ci.yml. - secrets: inherit - - # ── Stage 1: PREPARE/VERIFY ──────────────────────────────────────────────── - # Build every lockstep package exactly once, then pack/hash/attw/ - # external-consumer/full-zip WITHOUT rebuilding them, and upload the resulting - # artifacts. Any failure here means no publish, no latest, no tag, no release. - prepare: - name: Prepare release artifacts (build-once) - needs: checks + trust: + name: Trust the green CI run at the tag runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 5 + outputs: + sha: ${{ steps.resolve.outputs.sha }} steps: - - name: Checkout - uses: actions/checkout@v6 + - uses: actions/checkout@v6 with: fetch-depth: 0 ref: ${{ env.TARGET_TAG }} - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: ${{ env.PNPM_VERSION }} - run_install: false + - id: resolve + env: + GH_TOKEN: ${{ github.token }} + run: | + sha="$(git rev-parse "${TARGET_TAG}^{commit}")" + echo "sha=$sha" >> "$GITHUB_OUTPUT" - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: ${{ env.NODE_VERSION }} - check-latest: true - cache: pnpm - cache-dependency-path: pnpm-lock.yaml + pkg="v$(node -p "require('./package.json').version")" + if [ "$TARGET_TAG" != "$pkg" ]; then + echo "::error::Tag '$TARGET_TAG' does not match package.json version '$pkg'." + exit 1 + fi - - name: Install dependencies - run: pnpm bootstrap + if ! git merge-base --is-ancestor "$sha" origin/main; then + echo "::error::$TARGET_TAG ($sha) is not on main. Releases are cut from main only." + exit 1 + fi - # Refuse to release unless the resolved tag matches package.json. - - name: Verify tag matches package.json version - run: | - PKG_VERSION="v$(node -p "require('./package.json').version")" - if [ "$TARGET_TAG" != "$PKG_VERSION" ]; then - echo "::error::Tag '$TARGET_TAG' does not match package.json version '$PKG_VERSION'." + verdict="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${sha}/check-runs?check_name=verdict&per_page=100" \ + --jq '[.check_runs[] | select(.conclusion == "success")] | length')" + if [ "$verdict" = "0" ]; then + echo "::error::No successful 'verdict' check run on $sha. Let CI finish on main before tagging." exit 1 fi - echo "Tag and package.json agree on $PKG_VERSION." + echo "$TARGET_TAG = $sha, on main, verdict green." + + # Build every lockstep package exactly once, then pack/hash/attw/consumers/ + # full-zip without rebuilding, and upload the result. Any failure here means + # nothing is published. + prepare: + name: Prepare release artifacts + needs: trust + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ env.TARGET_TAG }} + - uses: ./.github/actions/setup - # Lockstep + peer-range coherence + deterministic publish-order matrix. - name: Verify release package matrix run: | pnpm verify:lockstep pnpm verify:release-matrix - # Build core + extensions exactly once. `release:prepare --build` invokes - # `pnpm build` + the extension builds, then packs WITHOUT rebuilding. - - name: Build site (vendored ESM for the Full ZIP) + - name: Build core, extensions and the site env: EXOJS_PACKAGE_PATH: .. + # One line per package: verify:release-matrix checks each against the + # lockstep list, so a package added without a build line fails there. run: | pnpm build pnpm --filter @codexo/exojs-particles build @@ -110,24 +111,27 @@ jobs: pnpm --filter @codexo/exojs-react build pnpm site:build - # The site build can regenerate tracked content with platform-dependent - # output (this killed the v0.12.0 and first v0.13.0 release runs with - # "Working tree is dirty" in freezeRevision). The tag is the source of - # truth and everything packed comes from untracked dist/ output, so log - # the drift for diagnosis and reset tracked files to the tag state. - - name: Reset tracked build side-effects (keep tag tree pristine) + # Deploy Pages ships the site of a successful release from this artifact. + - uses: actions/upload-artifact@v4 + with: + name: site-dist-${{ needs.trust.outputs.sha }} + path: site/dist/ + retention-days: 1 + if-no-files-found: error + + # The site build regenerates tracked content with platform-dependent + # output, and the release freeze refuses a dirty tree. The tag is the + # source of truth and everything packed comes from untracked dist/, so + # the drift is logged and reset. + - name: Reset tracked build side-effects run: | - echo "Tracked drift after builds (empty = clean):" git status --porcelain git checkout -- . - # Build-once prepare: pack 4 tarballs (no rebuild) -> hash -> manifest + - # checksums -> attw (bundler) -> external consumers -> Full GitHub ZIP. - name: Prepare release (pack, hash, attw, consumers, full-zip) run: pnpm release:prepare - - name: Upload release artifacts - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v4 with: name: release-artifacts path: | @@ -137,53 +141,27 @@ jobs: if-no-files-found: error retention-days: 7 - # ── Stage 2: PUBLISH ─────────────────────────────────────────────────────── - # Consumes ONLY the artifacts built by `prepare`. Never builds or repacks: the - # release:publish step re-hashes the downloaded tarballs against the manifest - # (build-once guard) and aborts on any drift. Publishes each package straight to - # `latest` in lockstep order (Core first, then the extensions); a partial failure - # stops the chain and is safe to re-run (already-published versions are skipped). publish: - name: Publish to npm (build-once artifacts) + name: Publish to npm and GitHub needs: prepare runs-on: ubuntu-latest timeout-minutes: 30 steps: - - name: Checkout - uses: actions/checkout@v6 + - uses: actions/checkout@v6 with: fetch-depth: 0 ref: ${{ env.TARGET_TAG }} - - - name: Setup pnpm - uses: pnpm/action-setup@v6 + - uses: ./.github/actions/setup + - uses: actions/setup-node@v6 with: - version: ${{ env.PNPM_VERSION }} - run_install: false - - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: ${{ env.NODE_VERSION }} - check-latest: true + node-version: '24.x' registry-url: 'https://registry.npmjs.org' - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - # Tooling only (tsx + release scripts). Does NOT build the runtime packages - # — the tarballs are consumed as-is from the prepare stage. - - name: Install tooling - run: pnpm bootstrap - - name: Download release artifacts - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v4 with: name: release-artifacts path: .release - # Re-hash guard + ordered publish straight to `latest` (Core first, then the - # extensions). Idempotent: a re-run skips versions already on npm; a partial - # failure stops the chain and publishes nothing further. - name: Publish coordinated release (build-once, dist-tag -> latest) run: pnpm release:publish --execute diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index fe8ec771f..dd71974ac 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -4,11 +4,13 @@ on: pull_request: paths: - '.github/workflows/*.yml' + - '.github/actions/**' - '.github/dependabot.yml' push: - branches: [main, docs] + branches: [main, next] paths: - '.github/workflows/*.yml' + - '.github/actions/**' - '.github/dependabot.yml' workflow_dispatch: diff --git a/.husky/pre-push b/.husky/pre-push index 1df3711c9..0a3faecdb 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -4,9 +4,9 @@ # Branch pushes: `verify:quick` — the static CI-parity gates: typecheck (core # + guides + examples + extension packages), lint:all, # format:check, docs:api:check and the site gates. Then the test -# lanes the pushed range actually requires, chosen by -# `scripts/ci/select-lanes.ts` — the same module CI's detector -# job uses, so the two agree by construction. `--tests-only` +# lanes the pushed range actually requires, chosen from +# `scripts/ci/lanes.ts` — the same table CI's plan job reads, +# so the two agree by construction. `--tests-only` # drops the gate lanes verify:quick just ran, so nothing runs # twice. # diff --git a/AGENTS.md b/AGENTS.md index 7aee13ed0..8b34975c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,7 @@ Before completion, run `pnpm lanes` to see which lanes the change requires, run those, and run `git diff --check`. Do not run a full suite immediately before pushing. The pre-push hook already -runs `verify:quick` plus the lanes `scripts/ci/select-lanes.ts` selects for +runs `verify:quick` plus the lanes `scripts/ci/lanes.ts` selects for the pushed range, so a full local run beforehand is the same work twice. Do not weaken, delete, skip, or baseline a failing test or gate merely to make diff --git a/scripts/ci/lanes.ts b/scripts/ci/lanes.ts new file mode 100644 index 000000000..934d4811b --- /dev/null +++ b/scripts/ci/lanes.ts @@ -0,0 +1,296 @@ +import { pathToFileURL } from 'node:url'; + +import { effectiveLanes, selectAreas, type EffectiveLanes, type LaneAreas } from './select-lanes.ts'; + +/** + * The lane table - the single description of what CI and the pre-push hook run. + * + * `ci.yml` has one matrix job per stage and reads its entries from `planCi`; + * `scripts/lanes.ts` runs the same table locally. A lane is added here and + * nowhere else. The workflow never names a lane. + * + * Dependency-free and erasable TypeScript, like `select-lanes.ts`: the `plan` + * job runs this with plain `node` before any install, so nothing outside + * `node:` may be imported and the syntax must be type-strippable. + */ + +export type Stage = 'gates' | 'test' | 'verify'; + +export interface Lane { + /** Matrix entry name, also the JUnit artifact and Codecov flag. */ + id: string; + stage: Stage; + /** The effective lane that enables this entry; `always` runs on every event. */ + when: keyof EffectiveLanes | 'always'; + /** What the developer runs locally. */ + run: string; + /** Replaces `run` on CI: reporters, display wrappers and environment. */ + ciRun?: string; + /** Replaces `ciRun` when the run collects coverage (pushes to a long-lived branch). */ + coverageRun?: string; + /** Playwright browser to install on the runner. */ + browser?: 'chromium' | 'firefox'; + /** Extra apt packages the runner needs. */ + apt?: readonly string[]; + /** Needs the Naga WGSL validator on PATH. */ + naga?: boolean; + /** Needs the built dist artifact. */ + dist?: boolean; + /** Needs a browser locally too (skipped by `lanes --quick`). */ + local?: 'browser' | 'gate'; + /** Runs on CI only: its assertions hold for the runner's software rasteriser, not a developer's GPU. */ + ciOnly?: boolean; + /** Emits `test-results/.junit.xml` for the skip budget and Codecov. */ + junit?: boolean; + /** Pull requests only. */ + pullRequestOnly?: boolean; + timeoutMinutes?: number; +} + +const junit = (id: string): string => `--reporter=default --reporter=junit --outputFile.junit=./test-results/${id}.junit.xml`; + +export const LANES: readonly Lane[] = [ + { id: 'typecheck', stage: 'gates', when: 'typecheck', run: 'pnpm gates typecheck', local: 'gate' }, + { id: 'lint', stage: 'gates', when: 'lint', run: 'pnpm gates lint', local: 'gate' }, + { id: 'sync', stage: 'gates', when: 'always', run: 'pnpm gates sync', local: 'gate' }, + + { + id: 'unit', + stage: 'test', + when: 'unit', + run: 'pnpm test && pnpm test:alloc', + // The WGSL tests validate through Naga when it is on PATH and skip + // otherwise; CI installs it and refuses the skip. + ciRun: `EXOJS_REQUIRE_NAGA=1 pnpm test ${junit('unit')} && pnpm test:alloc`, + coverageRun: `EXOJS_REQUIRE_NAGA=1 pnpm test:coverage ${junit('unit')} && pnpm test:alloc`, + naga: true, + junit: true, + }, + { + id: 'webgl', + stage: 'test', + when: 'browserWebgl2', + run: 'pnpm test:browser:webgl && pnpm test:browser:build && pnpm test:browser:assets', + ciRun: `pnpm test:browser:webgl ${junit('webgl')} && pnpm test:browser:build && pnpm test:browser:assets`, + coverageRun: + `pnpm test:browser:webgl ${junit('webgl')} --coverage --coverage.reporter=lcov --coverage.reporter=text-summary ` + + '--coverage.thresholds.statements=0 --coverage.thresholds.branches=0 --coverage.thresholds.functions=0 --coverage.thresholds.lines=0 ' + + '&& pnpm test:browser:build && pnpm test:browser:assets', + browser: 'chromium', + local: 'browser', + junit: true, + }, + { + id: 'webgpu', + stage: 'test', + when: 'browserWebgpu', + run: 'pnpm test:browser:webgpu', + // Mesa lavapipe is the only WebGPU adapter a GPU-less runner can offer, and + // Chromium exposes it only to a headed browser, hence xvfb. + ciRun: 'VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json EXOJS_WEBGPU_CI_HEADED=1 ' + `xvfb-run -a pnpm test:browser:webgpu ${junit('webgpu')}`, + browser: 'chromium', + apt: ['mesa-vulkan-drivers', 'xvfb'], + local: 'browser', + junit: true, + }, + { + id: 'firefox', + stage: 'test', + when: 'browserFirefox', + run: 'pnpm test:browser:webgl:firefox', + // Firefox only exposes WebGL2 to a headed session; the WebGPU run after it + // is informational and never fails the lane. + ciRun: + 'EXOJS_FIREFOX_CI_HEADED=1 LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe ' + + `xvfb-run -a pnpm test:browser:webgl:firefox ${junit('firefox')} && (pnpm test:browser:webgpu:firefox || true)`, + browser: 'firefox', + apt: ['xvfb'], + local: 'browser', + ciOnly: true, + junit: true, + }, + { + id: 'audio', + stage: 'test', + when: 'browserAudio', + run: 'pnpm test:browser:audio', + ciRun: `pnpm test:browser:audio ${junit('audio')}`, + browser: 'chromium', + local: 'browser', + junit: true, + }, + { + id: 'tilemap', + stage: 'test', + when: 'browserTilemapWorker', + run: 'pnpm test:browser:tilemap', + ciRun: `pnpm test:browser:tilemap ${junit('tilemap')}`, + browser: 'chromium', + local: 'browser', + junit: true, + }, + { + id: 'bench', + stage: 'test', + when: 'benchStructural', + run: 'pnpm gate:bench:structural', + browser: 'chromium', + local: 'browser', + timeoutMinutes: 30, + }, + + { + id: 'package', + stage: 'verify', + when: 'packageVerify', + run: + 'pnpm size && pnpm size:summary && pnpm verify:exports && pnpm verify:declaration-imports && pnpm verify:lockstep && pnpm verify:release-matrix ' + + '&& pnpm pack --dry-run && pnpm --filter "@codexo/exojs-build" --filter "@codexo/exojs-particles" --filter "@codexo/exojs-tilemap" ' + + '--filter "@codexo/exojs-tiled" --filter "@codexo/exojs-physics" --filter "@codexo/exojs-tilemap-physics" --filter "@codexo/exojs-audio-fx" ' + + '--filter "@codexo/exojs-aseprite" --filter "@codexo/exojs-ldtk" --filter "@codexo/exojs-react" pack --dry-run && pnpm verify:publint', + dist: true, + }, + { + id: 'release', + stage: 'verify', + when: 'releaseDryRun', + run: 'pnpm release:prepare --build --skip-zip', + pullRequestOnly: true, + }, +]; + +/** A `test`/`verify` matrix entry as the workflow consumes it. */ +export interface MatrixEntry { + id: string; + run: string; + browser: string; + apt: string; + naga: boolean; + dist: boolean; + junit: boolean; + coverage: boolean; + timeoutMinutes: number; +} + +export interface CiPlan { + areas: LaneAreas; + gates: MatrixEntry[]; + test: MatrixEntry[]; + verify: MatrixEntry[]; + /** Run the build job (dist artifact). */ + build: boolean; + /** Run the site job (site artifact). */ + site: boolean; + /** Smoke the example catalog against the site artifact. */ + smoke: boolean; + /** Smoke one example per category rather than the whole catalog. */ + smokeSample: boolean; + /** Evaluate the skip budget over the test lanes' JUnit reports. */ + skipBudget: boolean; + /** Collect and upload coverage. */ + coverage: boolean; +} + +export interface PlanInput { + eventName: string; + /** Files a pull request changed; ignored on every other event. */ + changedFiles: readonly string[]; + /** Branch the event ran on; coverage is collected on the long-lived ones. */ + refName: string; +} + +const COVERAGE_BRANCHES = new Set(['main', 'next']); + +const ALL_AREAS: LaneAreas = { engine: true, site: true, audioFx: true, tilemapWorker: true, exampleCatalog: true, benchStructural: true, release: true }; + +const toEntry = (lane: Lane, coverage: boolean): MatrixEntry => ({ + id: lane.id, + run: (coverage && lane.coverageRun) || lane.ciRun || lane.run, + browser: lane.browser ?? '', + apt: (lane.apt ?? []).join(' '), + naga: lane.naga ?? false, + dist: lane.dist ?? false, + junit: lane.junit ?? false, + coverage: coverage && lane.coverageRun !== undefined, + timeoutMinutes: lane.timeoutMinutes ?? 20, +}); + +export const selectLanes = (effective: EffectiveLanes, isPullRequest: boolean): Lane[] => + LANES.filter(lane => lane.when === 'always' || effective[lane.when]).filter(lane => !lane.pullRequestOnly || isPullRequest); + +/** + * Everything `ci.yml` needs to know, from the event alone. A push, a tag or a + * dispatch validates every area; only a pull request narrows to what it + * changed. + */ +export const planCi = ({ eventName, changedFiles, refName }: PlanInput): CiPlan => { + const isPullRequest = eventName === 'pull_request'; + const areas = isPullRequest ? selectAreas(changedFiles) : ALL_AREAS; + const effective = effectiveLanes(areas); + const coverage = eventName === 'push' && COVERAGE_BRANCHES.has(refName); + const lanes = selectLanes(effective, isPullRequest); + const stage = (name: Stage): MatrixEntry[] => lanes.filter(lane => lane.stage === name).map(lane => toEntry(lane, coverage)); + // The smoke drives the site job's artifact, so a catalog change builds the + // site even when nothing under site/ changed. + const site = areas.site || areas.exampleCatalog; + + return { + areas, + gates: stage('gates'), + test: stage('test'), + verify: stage('verify'), + build: areas.engine || site, + site, + smoke: areas.exampleCatalog, + smokeSample: isPullRequest, + skipBudget: effective.unit, + coverage, + }; +}; + +const parseChangedFiles = (raw: string | undefined): string[] => { + const text = (raw ?? '').trim(); + if (text === '') return []; + if (text.startsWith('[')) { + try { + const parsed: unknown = JSON.parse(text); + if (Array.isArray(parsed)) return parsed.map(String); + } catch { + // Not JSON after all - fall through to the newline form. + } + } + return text + .split(/\r?\n/) + .map(line => line.trim()) + .filter(Boolean); +}; + +/** + * CLI entry for the `plan` job: reads the event from the environment and + * prints one `key=value` line per plan field for `$GITHUB_OUTPUT`. Matrices + * are JSON; every other value is a plain `true`/`false`. + */ +const main = (): void => { + const plan = planCi({ + eventName: process.env['EVENT_NAME'] ?? '', + changedFiles: parseChangedFiles(process.env['CHANGED_FILES']), + refName: process.env['REF_NAME'] ?? '', + }); + + const { areas, gates, test, verify, ...flags } = plan; + process.stderr.write(`plan: ${JSON.stringify({ areas, lanes: [...gates, ...test, ...verify].map(entry => entry.id), ...flags })}\n`); + + const lines = [ + `gates=${JSON.stringify(gates)}`, + `test=${JSON.stringify(test)}`, + `verify=${JSON.stringify(verify)}`, + `hasTest=${test.length > 0}`, + `hasVerify=${verify.length > 0}`, + ...Object.entries(flags).map(([key, value]) => `${key}=${value}`), + ]; + process.stdout.write(`${lines.join('\n')}\n`); +}; + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(); +} diff --git a/scripts/ci/select-lanes.ts b/scripts/ci/select-lanes.ts index 582be9155..841cd8522 100644 --- a/scripts/ci/select-lanes.ts +++ b/scripts/ci/select-lanes.ts @@ -1,11 +1,9 @@ -import { pathToFileURL } from 'node:url'; - /** * CI lane selection - the single source of truth for which validation lanes a * set of changed files must trigger. * * Consumed by: - * - the "Detect changes" job in .github/workflows/_ci-checks.yml (plain + * - scripts/ci/lanes.ts, which the `plan` job in .github/workflows/ci.yml runs (plain * `node`, no `pnpm install`); and * - test/ci/select-lanes.test.ts, which asserts representative changed-file * sets against `selectAreas` / `effectiveLanes`. @@ -20,7 +18,7 @@ import { pathToFileURL } from 'node:url'; * Background / the defect this prevents: * a PR touching only `packages/exojs-tilemap/**` or `packages/exojs-tiled/**` * used to leave `engine` false, so the unit, package-verify and browser lanes - * were skipped while Required CI still went green. The extension packages are + * were skipped while the required check still went green. The extension packages are * runtime engine code (their source is imported by the in-repo unit AND browser * tests via the vitest aliases), so a change to them must run the full engine * validation set - not just the docs/site lane. @@ -34,6 +32,7 @@ export interface LaneAreas { tilemapWorker: boolean; exampleCatalog: boolean; benchStructural: boolean; + release: boolean; } /** The concrete CI lanes those areas enable. */ @@ -51,6 +50,7 @@ export interface EffectiveLanes { siteBuild: boolean; benchStructural: boolean; exampleSmoke: boolean; + releaseDryRun: boolean; } /** @@ -229,6 +229,18 @@ const isBenchStructuralPath = (file: string): boolean => { return false; }; +/** + * Release area: the release tooling and every manifest it packs. Gates the + * release dry run, which builds and packs everything a release would. + */ +const isReleasePath = (file: string): boolean => { + if (file.startsWith('scripts/release/')) return true; + if (file === 'package.json' || file === 'pnpm-lock.yaml') return true; + if (/^packages\/[^/]+\/package\.json$/.test(file)) return true; + if (file === '.github/workflows/release.yml') return true; + return false; +}; + export const selectAreas = (changedFiles: readonly string[]): LaneAreas => { let engine = false; let site = false; @@ -236,6 +248,7 @@ export const selectAreas = (changedFiles: readonly string[]): LaneAreas => { let tilemapWorker = false; let exampleCatalog = false; let benchStructural = false; + let release = false; for (const raw of changedFiles) { // Normalise Windows separators and trim stray whitespace/blank entries. const file = String(raw).replace(/\\/g, '/').trim(); @@ -246,14 +259,15 @@ export const selectAreas = (changedFiles: readonly string[]): LaneAreas => { if (!tilemapWorker && isTilemapWorkerPath(file)) tilemapWorker = true; if (!exampleCatalog && isExampleCatalogPath(file)) exampleCatalog = true; if (!benchStructural && isBenchStructuralPath(file)) benchStructural = true; - if (engine && site && audioFx && tilemapWorker && exampleCatalog && benchStructural) break; + if (!release && isReleasePath(file)) release = true; + if (engine && site && audioFx && tilemapWorker && exampleCatalog && benchStructural && release) break; } - return { engine, site, audioFx, tilemapWorker, exampleCatalog, benchStructural }; + return { engine, site, audioFx, tilemapWorker, exampleCatalog, benchStructural, release }; }; /** - * Map effective areas to the concrete CI lanes. This MIRRORS the job `if:` gates - * in _ci-checks.yml - keep the two in sync: + * Map effective areas to the concrete CI lanes. The lane table in `lanes.ts` + * reads these to decide what each stage's matrix contains: * - typecheck + lint are ungated (always run, on every PR); * - unit/coverage, package-verify and all three browser lanes gate on `engine` * (the WebGPU + Firefox browser lanes still run when engine is true; they are @@ -267,7 +281,7 @@ export const selectAreas = (changedFiles: readonly string[]): LaneAreas => { * bench harness, or the committed counter baseline). */ export const effectiveLanes = (areas: LaneAreas): EffectiveLanes => { - const { engine, site, audioFx, tilemapWorker, exampleCatalog, benchStructural } = areas; + const { engine, site, audioFx, tilemapWorker, exampleCatalog, benchStructural, release } = areas; return { typecheck: true, lint: true, @@ -282,57 +296,6 @@ export const effectiveLanes = (areas: LaneAreas): EffectiveLanes => { siteBuild: site, exampleSmoke: exampleCatalog, benchStructural, + releaseDryRun: release, }; }; - -/** - * Parse the changed-file list emitted by dorny/paths-filter (`list-files: json`) - * - tolerant of an empty value or a newline-delimited list. - */ -const parseChangedFiles = (raw: string | undefined): string[] => { - const text = (raw ?? '').trim(); - if (text === '') return []; - if (text.startsWith('[')) { - try { - const parsed = JSON.parse(text); - if (Array.isArray(parsed)) return parsed.map(String); - } catch { - // Fall through to newline parsing below. - } - } - return text - .split(/\r?\n/) - .map(line => line.trim()) - .filter(Boolean); -}; - -/** - * CLI entry: read the event name + changed-file list from the environment and - * print `engine=` / `site=` lines for `$GITHUB_OUTPUT`. - * - * On any non-`pull_request` event the changed-file list is irrelevant and every - * area runs: a push to main, a tag release (via release.yml) or a manual - * dispatch is always validated in full, never partially. - */ -const main = (): void => { - const eventName = process.env['EVENT_NAME'] ?? ''; - const areas = - eventName === 'pull_request' - ? selectAreas(parseChangedFiles(process.env['CHANGED_FILES'])) - : { engine: true, site: true, audioFx: true, tilemapWorker: true, exampleCatalog: true, benchStructural: true }; - - // Human-readable trace to the job log (stderr keeps it out of $GITHUB_OUTPUT). - process.stderr.write( - `select-lanes: event=${eventName || 'unknown'} engine=${areas.engine} site=${areas.site} audioFx=${areas.audioFx} tilemapWorker=${areas.tilemapWorker} exampleCatalog=${areas.exampleCatalog} benchStructural=${areas.benchStructural}\n`, - ); - process.stdout.write( - `engine=${areas.engine}\nsite=${areas.site}\naudioFx=${areas.audioFx}\ntilemapWorker=${areas.tilemapWorker}\nexampleCatalog=${areas.exampleCatalog}\nbenchStructural=${areas.benchStructural}\n`, - ); -}; - -// Only run the CLI when executed directly (`node scripts/ci/select-lanes.ts`), -// never when imported by the test suite. -const invokedPath = process.argv[1]; -if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) { - main(); -} diff --git a/scripts/ci/verdict.ts b/scripts/ci/verdict.ts new file mode 100644 index 000000000..a12610989 --- /dev/null +++ b/scripts/ci/verdict.ts @@ -0,0 +1,50 @@ +/** + * The one required check. Reads the `needs` context and the plan, and fails + * when a job the plan asked for did not succeed - including one that was + * skipped, which is how a path-filter regression would otherwise pass. + * + * Dependency-free: runs with plain `node` in a job that installs nothing. + */ + +interface JobResult { + result: 'success' | 'failure' | 'cancelled' | 'skipped'; +} + +const needs = JSON.parse(process.env['NEEDS'] ?? '{}') as Record; +const plan = JSON.parse(process.env['PLAN'] ?? '{}') as Record; + +/** Which plan flag says a job must have run. `plan` itself always must. */ +const REQUIRED_WHEN: Record = { + plan: true, + gates: true, + test: 'hasTest', + 'skip-budget': 'skipBudget', + build: 'build', + verify: 'hasVerify', + site: 'site', + smoke: 'smoke', +}; + +let failed = false; + +for (const [job, { result }] of Object.entries(needs)) { + const when = REQUIRED_WHEN[job]; + const required = when === true || (when !== undefined && plan[when] === 'true'); + const ok = result === 'success' || (result === 'skipped' && !required); + process.stdout.write(`${ok ? 'ok ' : 'FAIL'} ${job.padEnd(12)} ${result}${required ? '' : ' (not required)'}\n`); + if (!ok) failed = true; +} + +for (const job of Object.keys(REQUIRED_WHEN)) { + if (!(job in needs)) { + process.stdout.write(`FAIL ${job.padEnd(12)} missing from needs - add it to the verdict job\n`); + failed = true; + } +} + +if (failed) { + process.stdout.write('::error::A required CI job failed, was cancelled, or was skipped although the plan asked for it.\n'); + process.exit(1); +} + +process.stdout.write('All required CI jobs satisfied.\n'); diff --git a/scripts/create-package.ts b/scripts/create-package.ts index 3407a9acd..30c4d3c88 100644 --- a/scripts/create-package.ts +++ b/scripts/create-package.ts @@ -14,7 +14,7 @@ * * NOT auto-wired (enumerated YAML / a different runtime / a manual bootstrap) - * printed as a concrete, copy-pasteable checklist at the end: - * - .github/workflows/_ci-checks.yml + release.yml `--filter` lines + * - .github/workflows/ci.yml + release.yml `--filter` lines * - vitest.config.ts createJsdomTestProject entry (+ aliasConfig if imported) * - root package.json typecheck:packages / test / test:coverage lists * - the npm placeholder publish + Trusted-Publisher (OIDC) bootstrap from @@ -482,10 +482,8 @@ MANUAL CHECKLIST — not auto-edited (enumerated YAML / different runtime / npm - "test", "test:coverage" → add --project=exojs-${name} (to run its tests by default) - "verify:publint" → add ${filterFlag} (only if you want publint to gate it) -3) .github/workflows/_ci-checks.yml — add ${filterFlag} to these three steps: - - "Typecheck extension packages" - - "Build extension packages" - - "Extension package dry runs" +3) .github/workflows/ci.yml — add ${filterFlag} to the "Build extension packages" + step; scripts/ci/lanes.ts — add it to the package lane's pack --dry-run 4) .github/workflows/release.yml — add this build line to the PREPARE job (verify:release-matrix ENFORCES it, so a forgotten line fails CI): diff --git a/scripts/lanes.ts b/scripts/lanes.ts index 7b2a56a95..028f267d9 100644 --- a/scripts/lanes.ts +++ b/scripts/lanes.ts @@ -1,111 +1,30 @@ -/** - * Run only the validation lanes a working copy's changes actually require. - * - * The path-to-lane decision is not made here: it comes from - * `scripts/ci/select-lanes.ts`, the same module the CI detector job uses, so a - * local run and a pull request agree by construction. What this script adds is - * the two things CI gets for free - the changed-file list (from git rather than - * from `dorny/paths-filter`) and a concrete command per lane. - * - * Usage: - * pnpm lanes list the lanes this working copy needs - * pnpm lanes --run run them, stopping at the first failure - * pnpm lanes --run --quick the same, minus the lanes that need a browser - * pnpm lanes --run --tests-only only the suites, leaving the static gates out - * pnpm lanes --base compare against instead of origin/HEAD - * pnpm lanes --all every lane, as a push to the default branch gets - * - * The changed-file set spans the merge base with `--base` through the working - * tree: committed, staged and unstaged changes plus untracked files all count, - * because all of them are about to be pushed or are already being tested. - */ - import { spawnSync } from 'node:child_process'; import { pathToFileURL } from 'node:url'; -import { effectiveLanes, selectAreas } from './ci/select-lanes.ts'; +import { selectLanes, type Lane } from './ci/lanes.ts'; +import { effectiveLanes, selectAreas, type LaneAreas } from './ci/select-lanes.ts'; /** - * Which lane an entry belongs to. `'always'` covers the checks CI runs on every - * pull request without gating them on a path - they have no key in the - * selector's vocabulary precisely because there is nothing to decide. - */ -export type LaneKey = keyof ReturnType | 'always'; - -/** One runnable step of a validation lane. */ -export interface Lane { - /** Lane this entry runs for. */ - readonly key: LaneKey; - /** Label used in the plan output. */ - readonly name: string; - /** Command to run, argv-style. */ - readonly command: readonly string[]; - /** Whether the lane drives a real browser, and so is skipped by `--quick`. */ - readonly browser?: boolean; - /** - * Whether this lane is one of the static gates `verify:quick` already runs. - * `--tests-only` drops these, which is how the pre-push hook combines the two - * without running any gate twice. - */ - readonly gate?: boolean; -} - -/** - * The local counterpart of each CI job. Deliberately not a copy of the workflow - * commands: the CI variants add JUnit reporters, coverage flags and artifact - * paths that only matter to the runner. What has to match is which lane runs - * for which change, and that comes from the shared selector. + * Local lane runner - the pre-push hook's half of the lane table. * - * `coverage` has no entry - it is the same suite as `unit`, measured. Running it - * locally would double the wall time for no extra signal, and the allocation - * gate it shares a job with reads wrong under instrumentation anyway. + * Prints the lanes the changed files require and runs them with `--run`. The + * selection is `scripts/ci/lanes.ts`, the same table CI plans from, so the + * two never disagree about what a change must pass. * + * Usage: + * pnpm lanes # list what this change requires + * pnpm lanes --run # run it + * pnpm lanes --run --quick # skip browser lanes + * pnpm lanes --run --tests-only # skip the gate lanes verify:quick already ran + * pnpm lanes --run --all # every lane, whatever changed + * pnpm lanes --base # diff against another base (default origin/HEAD) */ -export const LOCAL_LANES: readonly Lane[] = [ - { key: 'typecheck', name: 'typecheck gates', command: ['pnpm', 'gates', 'typecheck'], gate: true }, - { key: 'lint', name: 'lint gates', command: ['pnpm', 'gates', 'lint'], gate: true }, - { key: 'always', name: 'sync gates', command: ['pnpm', 'gates', 'sync'], gate: true }, - { key: 'unit', name: 'unit tests', command: ['pnpm', 'test'] }, - { key: 'unit', name: 'allocation gate', command: ['pnpm', 'test:alloc'] }, - { key: 'browserWebgl2', name: 'browser: Chromium WebGL2', command: ['pnpm', 'test:browser:webgl'], browser: true }, - { key: 'browserWebgl2', name: 'browser: inline worklet/worker sources', command: ['pnpm', 'test:browser:build'], browser: true }, - { key: 'browserWebgl2', name: 'browser: IndexedDB cache store', command: ['pnpm', 'test:browser:assets'], browser: true }, - { key: 'browserWebgpu', name: 'browser: Chromium WebGPU', command: ['pnpm', 'test:browser:webgpu'], browser: true }, - { key: 'browserAudio', name: 'browser: audio worklets', command: ['pnpm', 'test:browser:audio'], browser: true }, - { key: 'browserTilemapWorker', name: 'browser: tilemap worker', command: ['pnpm', 'test:browser:tilemap'], browser: true }, - { key: 'siteBuild', name: 'site gates', command: ['pnpm', 'gates', 'site'], gate: true }, - // Drives headless Chromium on the software rasterizer, so `--quick` skips it - // like any other browser lane. It is cheap enough to keep in the local plan - // (~1 minute measured) only because the gate excludes the archetypes whose - // software FILL cost dominates everything else - see `structuralGate.ts`. - { key: 'benchStructural', name: 'bench: structural counter gate', command: ['pnpm', 'gate:bench:structural'], browser: true }, - // The harness serves `site/dist`, so locally the build is part of the lane - - // smoking a stale dist is the false green this lane exists to prevent. CI - // gets the build for free by reusing the site-build job's artifact. - // - // `--sample` matches what a pull request runs: one example per catalog - // category, a sixth of the wall time. A push to a feature branch triggers no - // CI at all, so this is the stage that mirrors, and the full catalog runs on - // the push to `next` and in the merge queue. - // - // The renderer is left on auto rather than pinned to WebGL2 the way CI pins - // it: a developer machine has a real GPU, so this is the stage that actually - // exercises the examples on WebGPU. - { - key: 'exampleSmoke', - name: 'example catalog smoke (one per category)', - command: ['pnpm', 'site:build', '&&', 'pnpm', 'test:examples:smoke', '--sample'], - browser: true, - }, -]; const git = (...args: string[]): string => { const result = spawnSync('git', args, { encoding: 'utf8' }); - if (result.status !== 0) { throw new Error(`git ${args.join(' ')} failed: ${result.stderr.trim()}`); } - return result.stdout; }; @@ -115,11 +34,6 @@ const lines = (output: string): string[] => .map(line => line.trim()) .filter(Boolean); -/** - * Every file this working copy has touched relative to `base`. Falls back to the - * base ref itself when there is no merge base, which is what a branch that has - * never been pushed and shares no history looks like. - */ const changedFiles = (base: string): string[] => { const mergeBase = spawnSync('git', ['merge-base', base, 'HEAD'], { encoding: 'utf8' }); const from = mergeBase.status === 0 ? mergeBase.stdout.trim() : base; @@ -134,10 +48,20 @@ const changedFiles = (base: string): string[] => { const readFlag = (argv: readonly string[], flag: string): string | undefined => { const index = argv.indexOf(flag); - return index === -1 ? undefined : argv[index + 1]; }; +const ALL_AREAS: LaneAreas = { engine: true, site: true, audioFx: true, tilemapWorker: true, exampleCatalog: true, benchStructural: true, release: true }; + +/** The catalog smoke has no CI lane entry: CI smokes the site job's artifact instead. */ +const SMOKE_LANE: Lane = { + id: 'smoke', + stage: 'verify', + when: 'exampleSmoke', + run: 'pnpm site:build && pnpm test:examples:smoke --sample', + local: 'browser', +}; + const main = (): void => { const argv = process.argv.slice(2); const run = argv.includes('--run'); @@ -147,46 +71,44 @@ const main = (): void => { const base = readFlag(argv, '--base') ?? 'origin/HEAD'; const files = all ? [] : changedFiles(base); - const areas = all ? { engine: true, site: true, audioFx: true, tilemapWorker: true, exampleCatalog: true, benchStructural: true } : selectAreas(files); + const areas = all ? ALL_AREAS : selectAreas(files); const effective = effectiveLanes(areas); - const selected = LOCAL_LANES.filter(lane => lane.key === 'always' || effective[lane.key]) - .filter(lane => !(quick && lane.browser)) - .filter(lane => !(testsOnly && lane.gate)); + // The site gates run locally where CI runs them inside the site job. + const siteGates: Lane = { id: 'site', stage: 'gates', when: 'siteBuild', run: 'pnpm gates site', local: 'gate' }; - const scope = all ? 'every lane' : `${files.length} changed file(s) since ${base}`; + // The verify stage packs and publints against a built dist; that stays CI's. + const selected = [ + ...selectLanes(effective, false).filter(lane => lane.stage !== 'verify' && !lane.ciOnly), + ...(effective.siteBuild ? [siteGates] : []), + ...(effective.exampleSmoke ? [SMOKE_LANE] : []), + ] + .filter(lane => !(quick && lane.local === 'browser')) + .filter(lane => !(testsOnly && lane.local === 'gate')); + const scope = all ? 'every lane' : `${files.length} changed file(s) since ${base}`; process.stdout.write(`lanes: ${scope}\n`); process.stdout.write( `lanes: engine=${areas.engine} site=${areas.site} audioFx=${areas.audioFx} tilemapWorker=${areas.tilemapWorker} exampleCatalog=${areas.exampleCatalog} benchStructural=${areas.benchStructural}\n\n`, ); for (const lane of selected) { - process.stdout.write(` ${lane.command.join(' ')}${lane.browser ? ' (browser)' : ''}\n`); + process.stdout.write(` ${lane.run}${lane.local === 'browser' ? ' (browser)' : ''}\n`); } - if (selected.length === 0) { process.stdout.write(' (nothing to run)\n'); } if (!run) { process.stdout.write('\nlanes: pass --run to execute these.\n'); - return; } for (const lane of selected) { - process.stdout.write(`\n=== ${lane.name} ===\n\n`); - - // One command string through a shell, rather than an argv array: on Windows - // `pnpm` is a `.cmd` shim that node refuses to exec directly, and passing an - // argv array alongside `shell: true` is what node warns about as an - // injection risk. Every string here comes from the table above, never from - // user input, so there is nothing to inject. - const result = spawnSync(lane.command.join(' '), { stdio: 'inherit', shell: true }); - + process.stdout.write(`\n=== ${lane.id} ===\n\n`); + const result = spawnSync(lane.run, { stdio: 'inherit', shell: true }); if (result.status !== 0) { - process.stderr.write(`\nlanes: ${lane.name} failed (exit ${result.status ?? 'signal'}).\n`); + process.stderr.write(`\nlanes: ${lane.id} failed (exit ${result.status ?? 'signal'}).\n`); process.exit(result.status ?? 1); } } @@ -194,9 +116,7 @@ const main = (): void => { process.stdout.write('\nlanes: all selected lanes passed.\n'); }; -// Only run the CLI when executed directly, never when imported by the parity test. const invokedPath = process.argv[1]; - if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) { main(); } diff --git a/scripts/release/RELEASING.md b/scripts/release/RELEASING.md index a6220dc00..22fc88633 100644 --- a/scripts/release/RELEASING.md +++ b/scripts/release/RELEASING.md @@ -122,8 +122,9 @@ first time it reaches that package. Bootstrap it ahead of time instead: `manifest.ts`, `prepare.ts`, `run.ts`, the `verify-*` gates and the external-consumer smoke all derive from. Then mirror it in the two places that cannot import that TS module: add its directory to `RUNTIME_PACKAGES` in - `scripts/ci/select-lanes.ts`, and add its `--filter` to the build/typecheck/pack - steps in `.github/workflows/_ci-checks.yml` and the build step in `release.yml` + `scripts/ci/select-lanes.ts`, and add its `--filter` to the build step in + `.github/workflows/ci.yml`, the `package` lane in `scripts/ci/lanes.ts` and the + build step in `release.yml` (`verify:release-matrix` enforces the `release.yml` build lines, so a forgotten one fails CI rather than silently skipping the package). diff --git a/scripts/release/lockstep-packages.ts b/scripts/release/lockstep-packages.ts index 91d545cb6..a35692bde 100644 --- a/scripts/release/lockstep-packages.ts +++ b/scripts/release/lockstep-packages.ts @@ -9,7 +9,7 @@ * * NOT derivable from here (different runtimes - kept in sync manually, guarded * by `verify:release-matrix` where possible): - * - `.github/workflows/release.yml` / `_ci-checks.yml` build/typecheck/pack + * - `.github/workflows/release.yml` / `ci.yml` build/typecheck/pack * steps (YAML, enumerated `--filter`s; release.yml build lines are asserted * by `verify:release-matrix`). * - `scripts/ci/select-lanes.ts` RUNTIME_PACKAGES (dependency-free ESM that diff --git a/test/ci/gate-parity.test.ts b/test/ci/gate-parity.test.ts index 9464e30c1..978077142 100644 --- a/test/ci/gate-parity.test.ts +++ b/test/ci/gate-parity.test.ts @@ -4,113 +4,44 @@ import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; import { GATE_GROUPS, type GateGroup } from '../../scripts/ci/gate-groups'; +import { LANES } from '../../scripts/ci/lanes'; /** - * Locks the required GitHub CI jobs (`.github/workflows/_ci-checks.yml`) to the - * SAME gate set as the local `verify:quick` pre-push hook. Both sides run the - * lists in `scripts/ci/gate-groups.ts` - the hook as `pnpm gates all`, CI as one - * `pnpm gates ` per job - so a gate cannot drift out of CI by being - * spelled out in only one of the two places, which is how the two gates fell - * apart before. - * - * A group's `pnpm gates ` invocation is not enough on its own: it also - * has to live in the job `EXPECTED_JOB_FOR_GROUP` says it belongs to, AND that - * job has to be a dependency of `required-ci`. Otherwise a group can run in a - * job nobody requires - green everywhere, but silently optional - which is the - * same drift class the original job-block assertion guarded against, one level - * up: not "gate missing from CI" but "gate runs in CI, just not where a merge - * is blocked on it". - * - * What CAN still drift is a group nobody claims: adding a group here does not - * create the CI job that runs it, and an unclaimed group would silently stop - * running in CI while `verify:quick` keeps it green locally. That is the one - * manual step left, and it is what these assertions cover. + * The gate groups in `gate-groups.ts` are what the pre-push hook runs as + * `verify:quick`. CI must run every one of them too, and exactly once: the + * `gates` matrix takes the ungated groups from the lane table, and the `site` + * job runs the `site` group after the dist it needs has been built. */ const repoRoot = resolve(import.meta.dirname!, '../..'); -const workflowPath = resolve(repoRoot, '.github/workflows/_ci-checks.yml'); -const workflow = readFileSync(workflowPath, 'utf8'); +const workflow = readFileSync(resolve(repoRoot, '.github/workflows/ci.yml'), 'utf8'); const packageJson = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8')) as { scripts: Record; }; -/** Every `pnpm gates ` argument the workflow invokes, in file order. */ -const invokedGroups = [...workflow.matchAll(/pnpm gates ([\w:-]+)/g)].map(match => match[1]!); - const groupNames = Object.keys(GATE_GROUPS) as GateGroup[]; +const laneGroups = LANES.filter(lane => lane.stage === 'gates').map(lane => /pnpm gates ([\w:-]+)/.exec(lane.run)?.[1]); +const workflowGroups = [...workflow.matchAll(/pnpm gates ([\w:-]+)/g)].map(match => match[1]!); +const invokedGroups = [...laneGroups, ...workflowGroups]; -/** - * The CI job each gate group is expected to run in. Kept as an explicit - * per-group table (rather than re-deriving it from the workflow) so a group - * quietly moving to the wrong job - or a new group shipping with no entry - * here - shows up as a failing assertion instead of passing by construction. - */ -const EXPECTED_JOB_FOR_GROUP = { - typecheck: 'typecheck', - lint: 'lint', - sync: 'sync-checks', - site: 'site-build', -} as const satisfies Record; - -/** Extracts the `jobs.` block's raw YAML text (up to the next top-level job key or EOF). */ -const extractJobBlock = (source: string, jobName: string): string => { - const headerRe = new RegExp(`\\n {2}${jobName}:\\n`); - const startMatch = headerRe.exec(source); - if (!startMatch) { - throw new Error(`job "${jobName}" not found in ${workflowPath}`); - } - - const rest = source.slice(startMatch.index + startMatch[0].length); - const nextJobMatch = /\n {2}[a-zA-Z][\w-]*:\n/.exec(rest); - - return nextJobMatch ? rest.slice(0, nextJobMatch.index) : rest; -}; - -/** The job names listed in `jobs.required-ci.needs`, as an exact-match array. */ -const extractRequiredCiNeeds = (source: string): string[] => { - const block = extractJobBlock(source, 'required-ci'); - const needsMatch = /needs:\s*\[([\s\S]*?)\]/.exec(block); - if (!needsMatch) { - throw new Error(`"needs" array not found in the required-ci job block of ${workflowPath}`); - } - - return needsMatch[1]! - .split(',') - .map(entry => entry.trim()) - .filter(entry => entry.length > 0); -}; - -describe('CI gate jobs cover every gate group', () => { - it.each(groupNames)('group `%s` is invoked by a CI job', group => { +describe('CI runs every gate group', () => { + it.each(groupNames)('group `%s` is invoked by a lane or a job', group => { expect(invokedGroups).toContain(group); }); it('invokes no group that does not exist', () => { const unknown = invokedGroups.filter(group => group !== 'all' && !groupNames.includes(group as GateGroup)); - expect(unknown).toEqual([]); }); - it('runs each group in exactly one job, so no gate runs twice per CI run', () => { + it('runs each group exactly once per CI run', () => { const duplicated = groupNames.filter(group => invokedGroups.filter(invoked => invoked === group).length > 1); - expect(duplicated).toEqual([]); }); -}); - -const groupJobPairs = groupNames.map(group => [group, EXPECTED_JOB_FOR_GROUP[group]] as const); - -describe('each gate group runs in the CI job that owns it', () => { - it.each(groupJobPairs)('group `%s` is invoked inside job `%s`, not merely somewhere in the workflow', (group, jobName) => { - const jobBlock = extractJobBlock(workflow, jobName); - - expect(jobBlock).toMatch(new RegExp(`pnpm gates ${group}\\b`)); - }); - it.each(groupJobPairs)('group `%s` is owned by job `%s`, which `required-ci` depends on', (_group, jobName) => { - const requiredNeeds = extractRequiredCiNeeds(workflow); - - expect(requiredNeeds).toContain(jobName); + it('keeps the `site` group in the site job, where the built dist exists, and every other group in the gates matrix', () => { + expect(workflowGroups).toEqual(['site']); + expect(laneGroups.sort()).toEqual(groupNames.filter(group => group !== 'site').sort()); }); }); @@ -127,27 +58,17 @@ describe('the local pre-push hook runs the same gate set as CI', () => { ); }); -describe('the Typecheck job covers the type-level gates verify:quick knows about', () => { - // These gates protect distinct source surfaces; typecheck:packages and - // typecheck:test joined the original set when the lists were unified. - // Naming them keeps a silent deletion visible as a failing test rather than - // as a gate that quietly stops running on both sides at once. +describe('the typecheck group covers the type-level gates verify:quick knows about', () => { it.each(['typecheck', 'typecheck:guides', 'typecheck:examples', 'typecheck:type-tests'])('`%s` is in the typecheck group', script => { expect(GATE_GROUPS.typecheck).toContain(script); }); - // The opt-in all-in-one IIFE entry has no gate of its own: it is a - // consumer-shaped re-export surface, so it rides along in the example - // program. The production build only compiles it when EXOJS_FULL_BUNDLE=1, - // so dropping it from this include silently moves a stale named export back - // to being CI-build-only. it('type-checks the full-bundle entry as part of the example program', () => { const examplesConfig = readFileSync(resolve(repoRoot, 'tsconfig.examples.json'), 'utf8'); - expect(examplesConfig).toContain('"scripts/exo-full.entry.ts"'); }); - it('keeps `typecheck:site` out of the ungated typecheck job — it needs the built dist', () => { + it('keeps `typecheck:site` out of the ungated typecheck group - it needs the built dist', () => { expect(GATE_GROUPS.typecheck).not.toContain('typecheck:site'); expect(GATE_GROUPS.site).toContain('typecheck:site'); }); diff --git a/test/ci/lane-commands.test.ts b/test/ci/lane-commands.test.ts index 7a6b7440f..b4bdd7f50 100644 --- a/test/ci/lane-commands.test.ts +++ b/test/ci/lane-commands.test.ts @@ -3,95 +3,63 @@ import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; +import { LANES } from '../../scripts/ci/lanes.ts'; import { effectiveLanes } from '../../scripts/ci/select-lanes.ts'; -import { LOCAL_LANES } from '../../scripts/lanes'; /** - * Locks `pnpm lanes` to the lane vocabulary the CI detector uses. - * - * The selector decides WHICH lanes a change needs; this table decides WHAT each - * of them runs locally. The failure mode the assertions below guard against is a - * lane being added to the selector - and therefore to CI - while the local - * runner silently has nothing to run for it, which turns `pnpm lanes --run` into - * a green result that proves less than it appears to. + * The lane table against the selector and the package manifest: every lane the + * selector can turn on has an entry, every entry runs scripts that exist, and + * the flags the runners rely on are set where they must be. */ const repoRoot = resolve(import.meta.dirname!, '../..'); -/** - * Lanes the local runner deliberately has no command for. `coverage` is the unit - * suite measured; running it locally doubles the wall time for no extra signal, - * and it is covered by the `unit` entry. - */ -const INTENTIONALLY_LOCAL_ONLY_IN_CI = new Set(['coverage', 'browserFirefox', 'packageVerify']); - -const allLaneKeys = Object.keys(effectiveLanes({ engine: true, site: true, audioFx: true, tilemapWorker: true, exampleCatalog: true, benchStructural: true })); +/** Lane keys the table covers elsewhere: `coverage` is a mode of `unit`, the site and smoke keys are jobs of their own. */ +const COVERED_OUTSIDE_THE_TABLE = new Set(['coverage', 'siteBuild', 'exampleSmoke']); -/** - * The package scripts a lane runs. A lane may chain steps with `&&` - the - * example smoke serves the site build's output, so locally the build is part - * of the lane - and every step named in it has to exist. - */ -const laneScripts = (command: readonly string[]): string[] => command.filter((part, index) => command[index - 1] === 'pnpm'); +const allLaneKeys = Object.keys( + effectiveLanes({ engine: true, site: true, audioFx: true, tilemapWorker: true, exampleCatalog: true, benchStructural: true, release: true }), +); -const packageScripts = (): Record => { - const manifest = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8')) as { scripts: Record }; +const scriptsIn = (command: string): string[] => + [...command.matchAll(/\bpnpm (?:--filter "[^"]+" )*([\w:-]+)/g)].map(match => match[1]!).filter(script => script !== 'pack'); - return manifest.scripts; -}; +const packageScripts = JSON.parse(readFileSync(resolve(repoRoot, 'package.json'), 'utf8')) as { scripts: Record }; -describe('local lane commands', () => { +describe('lane table', () => { it('covers every lane the selector can turn on', () => { - const covered = new Set(LOCAL_LANES.map(lane => lane.key as string)); - const uncovered = allLaneKeys.filter(key => !covered.has(key) && !INTENTIONALLY_LOCAL_ONLY_IN_CI.has(key)); - + const covered = new Set(LANES.map(lane => lane.when as string)); + const uncovered = allLaneKeys.filter(key => !covered.has(key) && !COVERED_OUTSIDE_THE_TABLE.has(key)); expect(uncovered).toEqual([]); }); it('names no lane the selector does not know', () => { - for (const lane of LOCAL_LANES) { - expect([...allLaneKeys, 'always']).toContain(lane.key); + for (const lane of LANES) { + expect([...allLaneKeys, 'always']).toContain(lane.when); } }); - it('runs the ungated gate groups that have no lane key of their own', () => { - // `gates sync` is ungated in CI - no path decides whether it runs - so the - // selector has no key for it and the local runner has to claim it - // explicitly, or `pnpm lanes --run` would silently skip the API-doc and - // example-sync checks. - const groups = LOCAL_LANES.filter(lane => lane.command[1] === 'gates').map(lane => lane.command[2]); - - expect(groups).toContain('sync'); + it('runs the sync gates on every event', () => { + expect(LANES.find(lane => lane.run === 'pnpm gates sync')?.when).toBe('always'); }); it('runs only package scripts that exist', () => { - const scripts = packageScripts(); - - for (const lane of LOCAL_LANES) { - expect(lane.command[0]).toBe('pnpm'); - - const named = laneScripts(lane.command); - - expect(named.length).toBeGreaterThan(0); - - for (const script of named) { - expect(Object.keys(scripts)).toContain(script); + for (const lane of LANES) { + for (const command of [lane.run, lane.ciRun, lane.coverageRun].filter((value): value is string => value !== undefined)) { + const named = scriptsIn(command); + expect(named.length, `${lane.id}: ${command}`).toBeGreaterThan(0); + for (const script of named) { + expect(Object.keys(packageScripts.scripts), `${lane.id} runs \`pnpm ${script}\``).toContain(script); + } } } }); - it('marks every browser-driven lane so --quick can skip it', () => { - for (const lane of LOCAL_LANES) { - // `test:examples:smoke` drives a real Chromium too - it boots the whole - // example catalog - and so does the bench structural gate, which runs the - // benchmark harness on the software rasterizer. The flag has to follow what - // a lane actually does, not just the `test:browser*` naming the vitest - // projects happen to use. - const drivesBrowser = laneScripts(lane.command).some( - script => script.startsWith('test:browser') || script === 'test:examples:smoke' || script === 'gate:bench:structural', - ); - - expect(lane.browser ?? false).toBe(drivesBrowser); + it('marks every browser-driven lane so --quick can skip it and CI installs the browser', () => { + for (const lane of LANES) { + const drivesBrowser = scriptsIn(lane.run).some(script => script.startsWith('test:browser') || script === 'gate:bench:structural'); + expect(lane.local === 'browser', `${lane.id} local`).toBe(drivesBrowser); + expect(lane.browser !== undefined, `${lane.id} browser`).toBe(drivesBrowser); } }); }); diff --git a/test/ci/pages-deployment.test.ts b/test/ci/pages-deployment.test.ts index 53d0f45a6..c475cebe2 100644 --- a/test/ci/pages-deployment.test.ts +++ b/test/ci/pages-deployment.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest'; * Locks the build-once/deploy-once contract for GitHub Pages. * * The site is built and validated exactly once, by the `site-build` job in - * `_ci-checks.yml`, which publishes `site/dist/**` as an artifact named after + * `ci.yml`, which publishes `site/dist/**` as an artifact named after * the commit it was built from. `deploy-pages.yml` then downloads exactly that * artifact - from exactly the workflow run that triggered it - and deploys it. * @@ -20,7 +20,8 @@ import { describe, expect, it } from 'vitest'; const repoRoot = resolve(import.meta.dirname!, '../..'); const readWorkflow = (name: string) => readFileSync(resolve(repoRoot, '.github/workflows', name), 'utf8'); -const ciChecks = readWorkflow('_ci-checks.yml'); +const ci = readWorkflow('ci.yml'); +const release = readWorkflow('release.yml'); const deployPages = readWorkflow('deploy-pages.yml'); /** The artifact name is derived from the commit on both sides of the handover. */ @@ -36,8 +37,8 @@ const jobBlock = (workflow: string, job: string) => { return next === -1 ? rest : rest.slice(0, next); }; -describe('site artifact production (_ci-checks.yml)', () => { - const siteBuild = jobBlock(ciChecks, 'site-build'); +describe('site artifact production (ci.yml)', () => { + const siteBuild = jobBlock(ci, 'site'); it('uploads the built site as a commit-identified artifact', () => { expect(siteBuild).toContain('actions/upload-artifact@v4'); @@ -59,6 +60,14 @@ describe('site artifact production (_ci-checks.yml)', () => { }); }); +describe('site artifact production at a release (release.yml)', () => { + it('uploads the site under the tagged commit so Deploy Pages finds it', () => { + const prepare = jobBlock(release, 'prepare'); + expect(prepare).toContain('name: site-dist-${{ needs.trust.outputs.sha }}'); + expect(prepare).toContain('path: site/dist/'); + }); +}); + describe('Pages deployment (deploy-pages.yml)', () => { it('builds no source code of its own', () => { for (const forbidden of ['pnpm install', 'pnpm build', 'pnpm site:build', 'pnpm --filter', 'pnpm/action-setup', 'actions/setup-node', 'actions/checkout']) { diff --git a/test/ci/plan.test.ts b/test/ci/plan.test.ts new file mode 100644 index 000000000..aaa861f9b --- /dev/null +++ b/test/ci/plan.test.ts @@ -0,0 +1,110 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { type CiPlan, LANES, planCi } from '../../scripts/ci/lanes'; + +/** + * What `ci.yml` receives from the `plan` job for representative events. The + * verdict job fails a skipped job the plan asked for, so these are also the + * assertions that a path-filter regression cannot pass unnoticed. + */ + +const repoRoot = resolve(import.meta.dirname!, '../..'); +const workflow = readFileSync(resolve(repoRoot, '.github/workflows/ci.yml'), 'utf8'); + +const pullRequest = (changedFiles: string[]): CiPlan => planCi({ eventName: 'pull_request', changedFiles, refName: 'feature' }); +const ids = (entries: Array<{ id: string }>): string[] => entries.map(entry => entry.id); + +describe('lane table', () => { + it('has a unique id per lane', () => { + const all = LANES.map(lane => lane.id); + expect(new Set(all).size).toBe(all.length); + }); + + it('gives every browser lane a JUnit report or a reason not to', () => { + for (const lane of LANES.filter(lane => lane.stage === 'test' && lane.id !== 'bench')) { + expect(lane.junit, `${lane.id} feeds the skip budget`).toBe(true); + expect(lane.ciRun, `${lane.id} writes its report on CI`).toContain(`test-results/${lane.id}.junit.xml`); + } + }); + + it('never mentions a lane by name in the workflow', () => { + for (const lane of LANES) { + expect(workflow).not.toMatch(new RegExp(`\\b${lane.run.split(' ').slice(0, 3).join(' ')}\\b`)); + } + }); +}); + +describe('plan for a push to a long-lived branch', () => { + const plan = planCi({ eventName: 'push', changedFiles: [], refName: 'next' }); + + it('runs every stage in full with coverage', () => { + expect(ids(plan.gates)).toEqual(['typecheck', 'lint', 'sync']); + expect(ids(plan.test)).toEqual(['unit', 'webgl', 'webgpu', 'firefox', 'audio', 'tilemap', 'bench']); + expect(ids(plan.verify)).toEqual(['package']); + expect(plan).toMatchObject({ build: true, site: true, smoke: true, smokeSample: false, skipBudget: true, coverage: true }); + }); + + it('instruments the lanes that upload coverage and no other', () => { + expect(plan.test.filter(entry => entry.coverage).map(entry => entry.id)).toEqual(['unit', 'webgl']); + expect(plan.test.find(entry => entry.id === 'unit')?.run).toContain('test:coverage'); + }); + + it('collects no coverage on a push to any other branch', () => { + expect(planCi({ eventName: 'push', changedFiles: [], refName: 'release/0.15.x' }).coverage).toBe(false); + }); +}); + +describe('plan for a pull request', () => { + it('runs everything for an engine change, uninstrumented, with a sampled smoke', () => { + const plan = pullRequest(['src/rendering/webgl2/backend.ts']); + expect(ids(plan.test)).toEqual(['unit', 'webgl', 'webgpu', 'firefox', 'bench']); + expect(ids(plan.verify)).toEqual(['package']); + expect(plan).toMatchObject({ build: true, site: true, smoke: true, smokeSample: true, skipBudget: true, coverage: false }); + expect(plan.test.find(entry => entry.id === 'unit')?.run).not.toContain('coverage'); + }); + + it('adds the release dry run when the release tooling or a packed manifest changes', () => { + expect(ids(pullRequest(['scripts/release/prepare.ts']).verify)).toEqual(['package', 'release']); + expect(ids(pullRequest(['packages/exojs-tiled/package.json']).verify)).toEqual(['package', 'release']); + expect(ids(planCi({ eventName: 'push', changedFiles: [], refName: 'next' }).verify)).toEqual(['package']); + }); + + it('runs only the gates for a docs-only change', () => { + const plan = pullRequest(['README.md']); + expect(ids(plan.gates)).toEqual(['typecheck', 'lint', 'sync']); + expect(plan.test).toEqual([]); + expect(plan.verify).toEqual([]); + expect(plan).toMatchObject({ build: false, site: false, smoke: false, skipBudget: false }); + }); + + it('adds the audio lane for an audio-fx change and the tilemap lane for a tilemap change', () => { + expect(ids(pullRequest(['packages/exojs-audio-fx/src/reverb.ts']).test)).toContain('audio'); + expect(ids(pullRequest(['packages/exojs-tilemap/src/worker.ts']).test)).toContain('tilemap'); + expect(ids(pullRequest(['src/scene/node.ts']).test)).not.toContain('audio'); + }); + + it('builds the site without the engine lanes for a site-only change', () => { + const plan = pullRequest(['site/src/pages/index.astro']); + expect(plan.test).toEqual([]); + expect(plan).toMatchObject({ build: true, site: true, smoke: true }); + }); +}); + +describe('the matrix entries carry what the setup action needs', () => { + const plan = planCi({ eventName: 'push', changedFiles: [], refName: 'main' }); + + it('names the browser and the apt packages per lane', () => { + const byId = Object.fromEntries(plan.test.map(entry => [entry.id, entry])); + expect(byId['webgpu']).toMatchObject({ browser: 'chromium', apt: 'mesa-vulkan-drivers xvfb' }); + expect(byId['firefox']).toMatchObject({ browser: 'firefox', apt: 'xvfb' }); + expect(byId['unit']).toMatchObject({ browser: '', apt: '', naga: true }); + }); + + it('asks for the dist only where the lane packs it', () => { + expect(plan.verify.find(entry => entry.id === 'package')?.dist).toBe(true); + expect(plan.test.every(entry => !entry.dist)).toBe(true); + }); +}); diff --git a/test/ci/select-lanes.test.ts b/test/ci/select-lanes.test.ts index 60eaa3fe8..be4d46704 100644 --- a/test/ci/select-lanes.test.ts +++ b/test/ci/select-lanes.test.ts @@ -4,7 +4,7 @@ import { effectiveLanes, selectAreas } from '../../scripts/ci/select-lanes.ts'; // Deterministic coverage for the CI path-to-lane policy. The logic under test is // scripts/ci/select-lanes.ts - the SAME module the "Detect changes" job in -// .github/workflows/_ci-checks.yml runs - so these assertions exercise the real +// .github/workflows/ci.yml runs - so these assertions exercise the real // lane-selection decision, not a copy of it. /** Areas + concrete lanes for a set of changed files. */ @@ -112,7 +112,7 @@ describe('CI lane selection — engine/site areas', () => { it('workflow change triggers broad validation (engine + site)', () => { expect(selectAreas(['.github/workflows/ci.yml'])).toMatchObject({ engine: true, site: true, audioFx: true, tilemapWorker: true }); - expect(selectAreas(['.github/workflows/_ci-checks.yml'])).toMatchObject({ engine: true, site: true, audioFx: true, tilemapWorker: true }); + expect(selectAreas(['.github/workflows/ci.yml'])).toMatchObject({ engine: true, site: true, audioFx: true, tilemapWorker: true }); }); it('lockfile / workspace-topology change triggers broad validation (engine + site)', () => { @@ -178,7 +178,7 @@ describe('CI lane selection — package-only change must not skip engine lanes', // A change touching only files under the two extension packages (tiled, // tilemap), with no core engine files. If `engine` stayed false here, the // unit, package-verify and all three browser lanes would be SKIPPED while - // Required CI still went green. This locks in the corrected behavior. + // the required check still went green. This locks in the corrected behavior. const EXTENSION_PACKAGE_ONLY_FILES = [ 'packages/exojs-tiled/README.md', 'packages/exojs-tiled/src/TiledMap.ts', @@ -337,7 +337,7 @@ describe('CI lane selection - example-smoke lane', () => { }); it('workflow / lockfile / workspace changes run the lane', () => { - expect(decide('.github/workflows/_ci-checks.yml').lanes.exampleSmoke).toBe(true); + expect(decide('.github/workflows/ci.yml').lanes.exampleSmoke).toBe(true); expect(decide('pnpm-lock.yaml').lanes.exampleSmoke).toBe(true); expect(decide('pnpm-workspace.yaml').lanes.exampleSmoke).toBe(true); }); diff --git a/test/rendering/wgsl-naga-validation.test.ts b/test/rendering/wgsl-naga-validation.test.ts index 1f0661579..fc3585d67 100644 --- a/test/rendering/wgsl-naga-validation.test.ts +++ b/test/rendering/wgsl-naga-validation.test.ts @@ -6,7 +6,7 @@ * a second, independently written WGSL front end whose accepted language is * measurably narrower. `browser-webgpu-firefox` exists for exactly that reason, * but it needs a headed session with a real display, so CI runs it - * non-blocking and without an adapter (see `_ci-checks.yml`). Nothing that + * non-blocking and without an adapter (see the `unit` lane in `scripts/ci/lanes.ts`). Nothing that * blocks a merge currently sees WGSL through anything but Tint. * * A concrete instance of the gap: a function taking a diff --git a/vitest.config.ts b/vitest.config.ts index 6264a4850..5a3a2a5c1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -69,7 +69,7 @@ const browserBase = { // CI opts into headed mode via EXOJS_WEBGPU_CI_HEADED=1 - Mesa lavapipe needs a // real display to report a real Vulkan adapter instead of falling back to // SwiftShader, and CI supplies one via xvfb (see `browser-tests-webgpu-chromium` -// in `_ci-checks.yml`). Without this gate, `headless: false` would pop a real, +// in `scripts/ci/lanes.ts`). Without this gate, `headless: false` would pop a real, // visible Chromium window on every local `pnpm test:browser:webgpu` run. // - WebGPU Firefox: headed - Firefox only exposes a WebGPU adapter in a headed session. const headed = process.env['EXOJS_BROWSER_HEADED'] === '1'; @@ -113,7 +113,7 @@ export default defineConfig({ ], exclude: ['src/**/*.d.ts', 'packages/*/src/**/*.d.ts'], // Hard regression gate for the `unit-tests` job (already required in - // `_ci-checks.yml`) - `.codecov.yml` posts project/patch coverage statuses + // `scripts/ci/lanes.ts`) - `.codecov.yml` posts project/patch coverage statuses // but they are NOT wired up as required checks, so a coverage drop // currently merges silently. These thresholds fail `pnpm test:coverage` // itself (the exact command the CI job runs) below the floor. @@ -379,7 +379,7 @@ export default defineConfig({ // (verified against a real Windows/NVIDIA adapter). `headless` stays true // by default so local dev never pops a visible browser window; CI opts // into `headless: false` via `EXOJS_WEBGPU_CI_HEADED=1` (see - // `browser-tests-webgpu-chromium` in `_ci-checks.yml`). + // the `webgpu` lane in `scripts/ci/lanes.ts`). { ...browserBase, test: {