feat: add RigPanel UI to drive a retarget (clip picker, diagnostic preview, write handoff) #13857
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| # No push trigger. Every change reaches `main` through a pull request whose | |
| # own CI gate must be green, so re-running the suite on the merge commit only | |
| # repeats work. The nightly schedule below is the main-branch health signal, | |
| # and the main -> release pull request runs the complete suite before a | |
| # release ships. | |
| pull_request: | |
| branches: [main, release] | |
| schedule: | |
| - cron: '17 9 * * *' | |
| workflow_dispatch: | |
| workflow_call: | |
| inputs: | |
| full: | |
| description: Run the complete server, client, DB, lint, build, and smoke suite | |
| required: false | |
| type: boolean | |
| default: true | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| jobs: | |
| impact: | |
| name: Plan test impact | |
| runs-on: ubuntu-latest | |
| # Still live despite the removed push trigger: a called workflow inherits | |
| # the caller's event, and release.yml calls this one from a push. | |
| if: github.event_name != 'push' || !contains(github.event.head_commit.message, '[skip ci]') | |
| outputs: | |
| full: ${{ steps.plan.outputs.full }} | |
| reason: ${{ steps.plan.outputs.reason }} | |
| server_mode: ${{ steps.plan.outputs.server_mode }} | |
| server_files: ${{ steps.plan.outputs.server_files }} | |
| server_sources: ${{ steps.plan.outputs.server_sources }} | |
| client_mode: ${{ steps.plan.outputs.client_mode }} | |
| client_files: ${{ steps.plan.outputs.client_files }} | |
| client_sources: ${{ steps.plan.outputs.client_sources }} | |
| db: ${{ steps.plan.outputs.db }} | |
| lint_mode: ${{ steps.plan.outputs.lint_mode }} | |
| lint_files: ${{ steps.plan.outputs.lint_files }} | |
| build: ${{ steps.plan.outputs.build }} | |
| smoke: ${{ steps.plan.outputs.smoke }} | |
| windows: ${{ steps.plan.outputs.windows }} | |
| windows_mode: ${{ steps.plan.outputs.windows_mode }} | |
| windows_files: ${{ steps.plan.outputs.windows_files }} | |
| windows_sources: ${{ steps.plan.outputs.windows_sources }} | |
| # JSON arrays of shard indexes — `[1]` for a scoped plan, `[1..n]` for a | |
| # full one. Each leaf job builds its matrix from these, because a | |
| # job-level `if` cannot read `matrix` and so cannot skip extra shards on | |
| # its own. See FULL_SUITE_SHARDS in scripts/ci-test-plan.js. | |
| server_shards: ${{ steps.plan.outputs.server_shards }} | |
| client_shards: ${{ steps.plan.outputs.client_shards }} | |
| windows_shards: ${{ steps.plan.outputs.windows_shards }} | |
| suite_reasons: ${{ steps.plan.outputs.suite_reasons }} | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| # Depth 2, not full history: a pull_request checkout is the merge ref, | |
| # so depth 2 carries both of its parents — which is everything the | |
| # planner (a three-dot diff against the base parent) and `git | |
| # ls-files` need. Cloning every commit in the repo buys nothing more. | |
| fetch-depth: 2 | |
| # Reads the base commit off HEAD^1 rather than the event payload, so a | |
| # depth-2 clone is enough for every three-dot diff downstream. Exports | |
| # CI_BASE_SHA to the rest of the job. | |
| - name: Resolve the pull-request diff base | |
| run: node scripts/ci-base-sha.js | |
| - name: Select affected test surfaces | |
| id: plan | |
| env: | |
| # `inputs.full` is only decisive for a caller that invokes this | |
| # reusable workflow from a pull_request-triggered workflow — every | |
| # call site in this repo comes from a push, which the first clause | |
| # already covers. It stays honored so the declared input is real. | |
| CI_FORCE_FULL: ${{ github.event_name != 'pull_request' || inputs.full == true }} | |
| # The planner turns a PR into `release` into a full plan: that PR is | |
| # the single gate a release ships behind, so it never runs scoped. | |
| CI_BASE_REF: ${{ github.base_ref }} | |
| run: node scripts/ci-test-plan.js | |
| - name: Publish plan summary | |
| env: | |
| PLAN_REASON: ${{ steps.plan.outputs.reason }} | |
| SERVER_MODE: ${{ steps.plan.outputs.server_mode }} | |
| CLIENT_MODE: ${{ steps.plan.outputs.client_mode }} | |
| DB_MODE: ${{ steps.plan.outputs.db }} | |
| LINT_MODE: ${{ steps.plan.outputs.lint_mode }} | |
| BUILD_MODE: ${{ steps.plan.outputs.build }} | |
| SMOKE_MODE: ${{ steps.plan.outputs.smoke }} | |
| WINDOWS_MODE: ${{ steps.plan.outputs.windows }} | |
| WINDOWS_TEST_MODE: ${{ steps.plan.outputs.windows_mode }} | |
| SERVER_SHARDS: ${{ steps.plan.outputs.server_shards }} | |
| CLIENT_SHARDS: ${{ steps.plan.outputs.client_shards }} | |
| WINDOWS_SHARDS: ${{ steps.plan.outputs.windows_shards }} | |
| SUITE_REASONS: ${{ steps.plan.outputs.suite_reasons }} | |
| run: | | |
| { | |
| echo "### CI impact plan" | |
| echo | |
| echo "- Reason: \`${PLAN_REASON}\`" | |
| echo "- Server tests: \`${SERVER_MODE}\` (shards \`${SERVER_SHARDS}\`)" | |
| echo "- Client tests: \`${CLIENT_MODE}\` (shards \`${CLIENT_SHARDS}\`)" | |
| echo "- DB tests: \`${DB_MODE}\`" | |
| echo "- Client lint: \`${LINT_MODE}\`" | |
| echo "- Client build: \`${BUILD_MODE}\`" | |
| echo "- Server smoke: \`${SMOKE_MODE}\`" | |
| echo "- Windows server tests: \`${WINDOWS_MODE}\` (\`${WINDOWS_TEST_MODE}\`, shards \`${WINDOWS_SHARDS}\`)" | |
| echo "- Suite selection reasons: \`${SUITE_REASONS}\`" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| server: | |
| # Sharded on a full plan — see docs/GITHUB_ACTIONS.md "Full-suite sharding". | |
| # The matrix comes from the planner; once-only steps pin to shard 1, and the | |
| # transform-artifact cache key carries the shard so parallel saves don't race. | |
| name: Server tests (${{ matrix.shard }}/${{ strategy.job-total }}) | |
| needs: impact | |
| if: needs.impact.outputs.server_mode != 'skip' | |
| runs-on: ubuntu-latest | |
| strategy: | |
| matrix: | |
| shard: ${{ fromJSON(needs.impact.outputs.server_shards) }} | |
| permissions: | |
| contents: read | |
| actions: write | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| persist-credentials: false | |
| # See the impact job: depth 2 is the whole PR merge ref, which is all | |
| # the planner needs to identify changed sources. | |
| fetch-depth: 2 | |
| - name: Resolve the pull-request diff base | |
| run: node scripts/ci-base-sha.js | |
| - name: Use Node.js 24.x | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: 24.x | |
| cache: npm | |
| cache-dependency-path: server/package-lock.json | |
| # setup-node's npm cache only preserves ~/.npm, the tarball cache: `npm ci` | |
| # still wipes and repopulates a 570 MB node_modules on all three server | |
| # jobs. Cache the installed tree instead and skip the install on a hit. | |
| # | |
| # No restore-keys. The install below is skipped on a hit, so a near-miss | |
| # restore would silently ship a node_modules built from a different | |
| # lockfile. Exact key or nothing. package.json is in the hash alongside | |
| # the lockfile so that skipping `npm ci` does not also skip its | |
| # manifest-vs-lockfile check, and the key carries the Node *major* rather | |
| # than the resolved patch, because NODE_MODULE_VERSION is stable across | |
| # patches and keying on the patch would discard the entry on every Node | |
| # 24.x release. The transform-artifact cache below keeps its own key and | |
| # its own restore-keys; this one is about dependencies. | |
| # | |
| # Hit rate comes from the nightly full run: there is no push trigger on | |
| # `main`, so the 09:17 UTC schedule is what seeds these caches on the | |
| # default branch, which is the only scope every PR branch can read. | |
| # See docs/GITHUB_ACTIONS.md. | |
| - name: Cache server node_modules | |
| id: server-modules | |
| uses: actions/cache@v6 | |
| with: | |
| path: server/node_modules | |
| key: server-modules-${{ runner.os }}-${{ runner.arch }}-node24-${{ hashFiles('server/package-lock.json', 'server/package.json', 'server/.npmrc', 'scripts/trusted-rebuilds.js') }} | |
| # A restored tree is only reusable if it was rebuilt before it was saved, | |
| # and that is not answerable by looking at the tree — with today's | |
| # versions the rebuild is nearly a no-op, so every allowlisted package | |
| # imports fine on a never-rebuilt one. The rebuild step below leaves an | |
| # explicit mark; this reads it back. | |
| # | |
| # `continue-on-error` on purpose: a bad entry must not wedge every PR | |
| # until a human clears the cache by hand. It fails, the two steps below | |
| # see a non-success outcome, and the job builds the tree it needs. It does | |
| # not *replace* the bad entry — cache keys are immutable and actions/cache | |
| # skips the save on an exact hit — so that entry keeps costing each run a | |
| # reinstall until the key moves or it ages out. That is precisely the | |
| # pre-cache cost: the worst case degrades to the old behaviour, never to a | |
| # red repo. | |
| # | |
| # `outcome != 'success'` covers all three cases in one expression: on a | |
| # cache miss this step is skipped (outcome `skipped`), on a hit it is | |
| # `success` or `failure`. | |
| - name: Check the restored node_modules was rebuilt | |
| id: server-modules-usable | |
| if: steps.server-modules.outputs.cache-hit == 'true' | |
| continue-on-error: true | |
| run: node scripts/trusted-rebuild-stamp.js check server | |
| - name: Install server dependencies | |
| if: steps.server-modules-usable.outcome != 'success' | |
| run: npm ci --prefix server | |
| # server/.npmrc pins ignore-scripts=true, so the allowlisted packages need | |
| # this explicit rebuild. Same condition as the install: whenever this job | |
| # builds the tree it will cache, it must build a rebuilt one — and the | |
| # mark is written in the same block, so a failed rebuild cannot leave one | |
| # behind for a later job to trust. | |
| # | |
| # `shell: bash` is load-bearing, not style. windows-server would default | |
| # to pwsh, where a *native* command's non-zero exit neither throws nor | |
| # stops the block (`$PSNativeCommandUseErrorActionPreference` is false) | |
| # and only the last command's code becomes the step result — so a failed | |
| # rebuild would run the mark anyway, reset the exit code to 0, and publish | |
| # a green, marked, un-rebuilt tree. Under `bash -e` the block stops at | |
| # the rebuild, the job fails, and actions/cache (`post-if: success()`) | |
| # never saves it. | |
| - name: Rebuild trusted native dependencies | |
| if: steps.server-modules-usable.outcome != 'success' | |
| shell: bash | |
| env: | |
| # Skip onnxruntime-node's CUDA execution provider. Its postinstall | |
| # downloads it on linux-x64 whenever the .so is absent — several | |
| # hundred MB, on two jobs, every run — and no hosted runner has an | |
| # NVIDIA GPU to use it with. The CPU binaries ship in the tarball, so | |
| # inference still works; this is the single largest cost in the job. | |
| ONNXRUNTIME_NODE_INSTALL_CUDA: skip | |
| run: | | |
| node scripts/trusted-rebuilds.js server | |
| node scripts/trusted-rebuild-stamp.js write server | |
| # After the install: `npm ci` wipes node_modules, so a restore ordered | |
| # ahead of it is lost. Vite/Vitest write transform artifacts under | |
| # node_modules/.vite (and historically .vitest); reusing them skips | |
| # re-transforming unchanged files. Separate from the cache above, and | |
| # deliberately: this is where a near miss is safe, so this is the entry | |
| # that keeps restore-keys and stays warm across a lockfile bump. | |
| - name: Cache Vitest transform artifacts | |
| uses: actions/cache@v6 | |
| with: | |
| path: | | |
| server/node_modules/.vite | |
| server/node_modules/.vitest | |
| key: vitest-server-${{ runner.os }}-${{ hashFiles('server/package-lock.json', 'server/vitest.config.js', 'scripts/vitestCiPool.js') }}-${{ matrix.shard }}of${{ strategy.job-total }} | |
| restore-keys: | | |
| vitest-server-${{ runner.os }}-${{ hashFiles('server/package-lock.json') }}- | |
| vitest-server-${{ runner.os }}- | |
| # `autofixer/` is a real install target — its own package.json, its own | |
| # tracked lockfile, its own .npmrc — installed by `npm run setup` and by | |
| # scripts/ensure-deps.js on every `npm start`. Nothing else in CI resolves | |
| # it, so a lockfile that no longer installs (a yanked version, a corrupted | |
| # integrity hash, a manifest/lockfile mismatch) would ship green and only | |
| # break on a user's machine at setup time. The parity assertions in | |
| # server/dependency-overrides.test.js are static — they parse the JSON; | |
| # only `npm ci` proves the tree still resolves. | |
| # | |
| # Deliberately uncached and deliberately ungated within the job: a cache | |
| # hit that skipped the install would skip the very check this step exists | |
| # for, and the job as a whole already gates on the planner's server_mode | |
| # (autofixer/ is one of the server runner's roots in ci-test-plan.js, so a | |
| # change here selects this job). Unlike the once-only steps that pin to | |
| # shard 1, it runs on every shard: autofixer/*.test.js is globbed by the | |
| # server runner, and which shard picks up a file that imports `express` is | |
| # not knowable from here. 67 packages, well under a second. | |
| # | |
| # No rebuild step follows. autofixer/.npmrc pins ignore-scripts=true and | |
| # scripts/trusted-rebuilds.js deliberately lists no rebuilds for this | |
| # workspace, because nothing it depends on ships a native addon. | |
| - name: Install autofixer dependencies | |
| run: npm ci --prefix autofixer | |
| - name: Check server entry-point syntax | |
| run: node --check server/index.js | |
| - name: Run server tests | |
| if: needs.impact.outputs.server_mode != 'skip' | |
| env: | |
| CI_TEST_MODE: ${{ needs.impact.outputs.server_mode }} | |
| CI_TEST_FILES: ${{ needs.impact.outputs.server_files }} | |
| CI_TEST_SOURCES: ${{ needs.impact.outputs.server_sources }} | |
| CI_SHARD: ${{ matrix.shard }}/${{ strategy.job-total }} | |
| # Keep successful-test output signal-dense. Developers can reproduce | |
| # locally without this flag when assertion context needs app logs. | |
| PORTOS_TEST_QUIET: 1 | |
| # The documented local fallback; setting it avoids one warning per | |
| # isolated test file without changing DB selection or credentials. | |
| PGPASSWORD: portos | |
| run: node scripts/run-ci-tests.js server | |
| # Smoke-boot does not need Postgres: NODE_ENV=test selects the file | |
| # backend. It does need the native rebuild (server boot loads node-pty), | |
| # which is why it lives on this job rather than a third installer. | |
| - name: Smoke-boot server | |
| if: needs.impact.outputs.smoke == 'true' && matrix.shard == 1 | |
| run: npm run smoke | |
| - name: Cancel sibling CI jobs after failure | |
| if: failure() && github.event_name == 'pull_request' | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: node scripts/cancel-current-ci-run.js | |
| client: | |
| # Sharded like the server job; lint, build, and the bundle budget run on shard 1. | |
| name: Client tests and build (${{ matrix.shard }}/${{ strategy.job-total }}) | |
| needs: impact | |
| if: needs.impact.outputs.client_mode != 'skip' || needs.impact.outputs.build == 'true' || needs.impact.outputs.lint_mode != 'skip' | |
| runs-on: ubuntu-latest | |
| strategy: | |
| matrix: | |
| shard: ${{ fromJSON(needs.impact.outputs.client_shards) }} | |
| permissions: | |
| contents: read | |
| actions: write | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| persist-credentials: false | |
| # See the impact job: depth 2 is the whole PR merge ref, which is all | |
| # the planner needs to identify changed sources. | |
| fetch-depth: 2 | |
| - name: Resolve the pull-request diff base | |
| run: node scripts/ci-base-sha.js | |
| - name: Use Node.js 24.x | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: 24.x | |
| cache: npm | |
| cache-dependency-path: client/package-lock.json | |
| - name: Install client dependencies | |
| run: npm ci --prefix client | |
| - name: Cache Vitest transform artifacts | |
| uses: actions/cache@v6 | |
| with: | |
| path: | | |
| client/node_modules/.vite | |
| client/node_modules/.vitest | |
| key: vitest-client-${{ runner.os }}-${{ hashFiles('client/package-lock.json', 'client/vitest.config.js', 'scripts/vitestCiPool.js') }}-${{ matrix.shard }}of${{ strategy.job-total }} | |
| restore-keys: | | |
| vitest-client-${{ runner.os }}-${{ hashFiles('client/package-lock.json') }}- | |
| vitest-client-${{ runner.os }}- | |
| - name: Lint client | |
| if: needs.impact.outputs.lint_mode != 'skip' && matrix.shard == 1 | |
| env: | |
| CI_LINT_MODE: ${{ needs.impact.outputs.lint_mode }} | |
| CI_LINT_FILES: ${{ needs.impact.outputs.lint_files }} | |
| run: node scripts/run-ci-lint.js | |
| - name: Run client tests | |
| if: needs.impact.outputs.client_mode != 'skip' | |
| env: | |
| CI_TEST_MODE: ${{ needs.impact.outputs.client_mode }} | |
| CI_TEST_FILES: ${{ needs.impact.outputs.client_files }} | |
| CI_TEST_SOURCES: ${{ needs.impact.outputs.client_sources }} | |
| CI_SHARD: ${{ matrix.shard }}/${{ strategy.job-total }} | |
| run: node scripts/run-ci-tests.js client | |
| - name: Build client | |
| if: needs.impact.outputs.build == 'true' && matrix.shard == 1 | |
| run: npm run build --prefix client | |
| # The Scalar bundle budget can only be measured against a real build, and it | |
| # skips itself when client/dist is absent — so it runs here, not in the unit | |
| # test job. See client/src/pages/ApiExplorer.bundle.test.js. | |
| - name: Check API Explorer bundle budget | |
| if: needs.impact.outputs.build == 'true' && matrix.shard == 1 | |
| run: npm run test --prefix client -- ApiExplorer.bundle | |
| - name: Cancel sibling CI jobs after failure | |
| if: failure() && github.event_name == 'pull_request' | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: node scripts/cancel-current-ci-run.js | |
| database: | |
| name: DB tests | |
| needs: impact | |
| if: needs.impact.outputs.db == 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| actions: write | |
| services: | |
| postgres: | |
| image: pgvector/pgvector:pg17 | |
| env: | |
| POSTGRES_USER: portos | |
| POSTGRES_PASSWORD: portos | |
| POSTGRES_DB: portos | |
| ports: | |
| - 5432:5432 | |
| options: >- | |
| --health-cmd "pg_isready -U portos" | |
| --health-interval 10s | |
| --health-timeout 5s | |
| --health-retries 5 | |
| env: | |
| PGHOST: localhost | |
| PGPORT: 5432 | |
| PGUSER: portos | |
| PGPASSWORD: portos | |
| PORTOS_REQUIRE_DB: '1' | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| persist-credentials: false | |
| - name: Use Node.js 24.x | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: 24.x | |
| cache: npm | |
| cache-dependency-path: server/package-lock.json | |
| # Same entry and same contract as the server job — see it for the key, and | |
| # for why the install and rebuild below are gated on the mark rather than | |
| # on the cache hit. | |
| - name: Cache server node_modules | |
| id: server-modules | |
| uses: actions/cache@v6 | |
| with: | |
| path: server/node_modules | |
| key: server-modules-${{ runner.os }}-${{ runner.arch }}-node24-${{ hashFiles('server/package-lock.json', 'server/package.json', 'server/.npmrc', 'scripts/trusted-rebuilds.js') }} | |
| # A restored tree is only reusable if it was rebuilt before it was saved, | |
| # and that is not answerable by looking at the tree — with today's | |
| # versions the rebuild is nearly a no-op, so every allowlisted package | |
| # imports fine on a never-rebuilt one. The rebuild step below leaves an | |
| # explicit mark; this reads it back. | |
| # | |
| # `continue-on-error` on purpose: a bad entry must not wedge every PR | |
| # until a human clears the cache by hand. It fails, the two steps below | |
| # see a non-success outcome, and the job builds the tree it needs. It does | |
| # not *replace* the bad entry — cache keys are immutable and actions/cache | |
| # skips the save on an exact hit — so that entry keeps costing each run a | |
| # reinstall until the key moves or it ages out. That is precisely the | |
| # pre-cache cost: the worst case degrades to the old behaviour, never to a | |
| # red repo. | |
| # | |
| # `outcome != 'success'` covers all three cases in one expression: on a | |
| # cache miss this step is skipped (outcome `skipped`), on a hit it is | |
| # `success` or `failure`. | |
| - name: Check the restored node_modules was rebuilt | |
| id: server-modules-usable | |
| if: steps.server-modules.outputs.cache-hit == 'true' | |
| continue-on-error: true | |
| run: node scripts/trusted-rebuild-stamp.js check server | |
| - name: Install server dependencies | |
| if: steps.server-modules-usable.outcome != 'success' | |
| run: npm ci --prefix server | |
| # server/.npmrc pins ignore-scripts=true, so the allowlisted packages need | |
| # this explicit rebuild. Same condition as the install: whenever this job | |
| # builds the tree it will cache, it must build a rebuilt one — and the | |
| # mark is written in the same block, so a failed rebuild cannot leave one | |
| # behind for a later job to trust. | |
| # | |
| # `shell: bash` is load-bearing, not style. windows-server would default | |
| # to pwsh, where a *native* command's non-zero exit neither throws nor | |
| # stops the block (`$PSNativeCommandUseErrorActionPreference` is false) | |
| # and only the last command's code becomes the step result — so a failed | |
| # rebuild would run the mark anyway, reset the exit code to 0, and publish | |
| # a green, marked, un-rebuilt tree. Under `bash -e` the block stops at | |
| # the rebuild, the job fails, and actions/cache (`post-if: success()`) | |
| # never saves it. | |
| - name: Rebuild trusted native dependencies | |
| if: steps.server-modules-usable.outcome != 'success' | |
| shell: bash | |
| env: | |
| # Skip onnxruntime-node's CUDA execution provider. Its postinstall | |
| # downloads it on linux-x64 whenever the .so is absent — several | |
| # hundred MB, on two jobs, every run — and no hosted runner has an | |
| # NVIDIA GPU to use it with. The CPU binaries ship in the tarball, so | |
| # inference still works; this is the single largest cost in the job. | |
| ONNXRUNTIME_NODE_INSTALL_CUDA: skip | |
| run: | | |
| node scripts/trusted-rebuilds.js server | |
| node scripts/trusted-rebuild-stamp.js write server | |
| # After the install: `npm ci` wipes node_modules, so a restore ordered | |
| # ahead of it is lost. Vite/Vitest write transform artifacts under | |
| # node_modules/.vite (and historically .vitest); reusing them skips | |
| # re-transforming unchanged files. Separate from the cache above, and | |
| # deliberately: this is where a near miss is safe, so this is the entry | |
| # that keeps restore-keys and stays warm across a lockfile bump. | |
| - name: Cache Vitest transform artifacts | |
| uses: actions/cache@v6 | |
| with: | |
| path: | | |
| server/node_modules/.vite | |
| server/node_modules/.vitest | |
| key: vitest-server-${{ runner.os }}-${{ hashFiles('server/package-lock.json', 'server/vitest.config.js', 'server/vitest.config.db.js', 'scripts/vitestCiPool.js') }} | |
| restore-keys: | | |
| vitest-server-${{ runner.os }}-${{ hashFiles('server/package-lock.json') }}- | |
| vitest-server-${{ runner.os }}- | |
| - name: Provision test database (portos_test) | |
| run: npm run setup:db:test --prefix server | |
| - name: Run DB-backed tests (portos_test) | |
| run: | | |
| set +e | |
| start=$(date +%s) | |
| npm run test:db:ci --prefix server | |
| status=$? | |
| elapsed=$(( $(date +%s) - start )) | |
| echo "⏱ server db suite: ${elapsed}s" | tee -a "$GITHUB_STEP_SUMMARY" | |
| exit $status | |
| env: | |
| PGDATABASE: portos_test | |
| - name: Cancel sibling CI jobs after failure | |
| if: failure() && github.event_name == 'pull_request' | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: node scripts/cancel-current-ci-run.js | |
| windows-server: | |
| # Sharded like the server job. | |
| name: Windows server unit tests (${{ matrix.shard }}/${{ strategy.job-total }}) | |
| needs: impact | |
| if: needs.impact.outputs.windows == 'true' | |
| runs-on: windows-latest | |
| strategy: | |
| matrix: | |
| shard: ${{ fromJSON(needs.impact.outputs.windows_shards) }} | |
| permissions: | |
| contents: read | |
| actions: write | |
| steps: | |
| - uses: actions/checkout@v7 | |
| with: | |
| persist-credentials: false | |
| # See the impact job: depth 2 is the whole PR merge ref, which is all | |
| # the planner needs to identify changed sources. | |
| fetch-depth: 2 | |
| - name: Resolve the pull-request diff base | |
| run: node scripts/ci-base-sha.js | |
| - name: Use Node.js 24.x | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: 24.x | |
| cache: npm | |
| cache-dependency-path: server/package-lock.json | |
| # Same entry and same contract as the server job — see it for the key, and | |
| # for why the install and rebuild below are gated on the mark rather than | |
| # on the cache hit. | |
| - name: Cache server node_modules | |
| id: server-modules | |
| uses: actions/cache@v6 | |
| with: | |
| path: server/node_modules | |
| key: server-modules-${{ runner.os }}-${{ runner.arch }}-node24-${{ hashFiles('server/package-lock.json', 'server/package.json', 'server/.npmrc', 'scripts/trusted-rebuilds.js') }} | |
| # A restored tree is only reusable if it was rebuilt before it was saved, | |
| # and that is not answerable by looking at the tree — with today's | |
| # versions the rebuild is nearly a no-op, so every allowlisted package | |
| # imports fine on a never-rebuilt one. The rebuild step below leaves an | |
| # explicit mark; this reads it back. | |
| # | |
| # `continue-on-error` on purpose: a bad entry must not wedge every PR | |
| # until a human clears the cache by hand. It fails, the two steps below | |
| # see a non-success outcome, and the job builds the tree it needs. It does | |
| # not *replace* the bad entry — cache keys are immutable and actions/cache | |
| # skips the save on an exact hit — so that entry keeps costing each run a | |
| # reinstall until the key moves or it ages out. That is precisely the | |
| # pre-cache cost: the worst case degrades to the old behaviour, never to a | |
| # red repo. | |
| # | |
| # `outcome != 'success'` covers all three cases in one expression: on a | |
| # cache miss this step is skipped (outcome `skipped`), on a hit it is | |
| # `success` or `failure`. | |
| - name: Check the restored node_modules was rebuilt | |
| id: server-modules-usable | |
| if: steps.server-modules.outputs.cache-hit == 'true' | |
| continue-on-error: true | |
| run: node scripts/trusted-rebuild-stamp.js check server | |
| - name: Install server dependencies | |
| if: steps.server-modules-usable.outcome != 'success' | |
| run: npm ci --prefix server | |
| # server/.npmrc pins ignore-scripts=true, so the allowlisted packages need | |
| # this explicit rebuild. Same condition as the install: whenever this job | |
| # builds the tree it will cache, it must build a rebuilt one — and the | |
| # mark is written in the same block, so a failed rebuild cannot leave one | |
| # behind for a later job to trust. | |
| # | |
| # `shell: bash` is load-bearing, not style. windows-server would default | |
| # to pwsh, where a *native* command's non-zero exit neither throws nor | |
| # stops the block (`$PSNativeCommandUseErrorActionPreference` is false) | |
| # and only the last command's code becomes the step result — so a failed | |
| # rebuild would run the mark anyway, reset the exit code to 0, and publish | |
| # a green, marked, un-rebuilt tree. Under `bash -e` the block stops at | |
| # the rebuild, the job fails, and actions/cache (`post-if: success()`) | |
| # never saves it. | |
| - name: Rebuild trusted native dependencies | |
| if: steps.server-modules-usable.outcome != 'success' | |
| shell: bash | |
| env: | |
| # Skip onnxruntime-node's CUDA execution provider. Its postinstall | |
| # downloads it on linux-x64 whenever the .so is absent — several | |
| # hundred MB, on two jobs, every run — and no hosted runner has an | |
| # NVIDIA GPU to use it with. The CPU binaries ship in the tarball, so | |
| # inference still works; this is the single largest cost in the job. | |
| ONNXRUNTIME_NODE_INSTALL_CUDA: skip | |
| run: | | |
| node scripts/trusted-rebuilds.js server | |
| node scripts/trusted-rebuild-stamp.js write server | |
| # After the install: `npm ci` wipes node_modules, so a restore ordered | |
| # ahead of it is lost. Vite/Vitest write transform artifacts under | |
| # node_modules/.vite (and historically .vitest); reusing them skips | |
| # re-transforming unchanged files. Separate from the cache above, and | |
| # deliberately: this is where a near miss is safe, so this is the entry | |
| # that keeps restore-keys and stays warm across a lockfile bump. | |
| - name: Cache Vitest transform artifacts | |
| uses: actions/cache@v6 | |
| with: | |
| path: | | |
| server/node_modules/.vite | |
| server/node_modules/.vitest | |
| key: vitest-server-${{ runner.os }}-${{ hashFiles('server/package-lock.json', 'server/vitest.config.js', 'scripts/vitestCiPool.js') }}-${{ matrix.shard }}of${{ strategy.job-total }} | |
| restore-keys: | | |
| vitest-server-${{ runner.os }}-${{ hashFiles('server/package-lock.json') }}- | |
| vitest-server-${{ runner.os }}- | |
| - name: Check server entry-point syntax | |
| run: node --check server/index.js | |
| - name: Run server tests on Windows | |
| env: | |
| CI_TEST_MODE: ${{ needs.impact.outputs.windows_mode }} | |
| CI_TEST_FILES: ${{ needs.impact.outputs.windows_files }} | |
| CI_TEST_SOURCES: ${{ needs.impact.outputs.windows_sources }} | |
| CI_SHARD: ${{ matrix.shard }}/${{ strategy.job-total }} | |
| PORTOS_TEST_QUIET: 1 | |
| PGPASSWORD: portos | |
| run: node scripts/run-ci-tests.js server | |
| - name: Cancel sibling CI jobs after failure | |
| if: failure() && github.event_name == 'pull_request' | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: node scripts/cancel-current-ci-run.js | |
| gate: | |
| name: CI Gate | |
| if: always() | |
| needs: [impact, server, client, database, windows-server] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Require every selected job to pass | |
| env: | |
| IMPACT_RESULT: ${{ needs.impact.result }} | |
| SERVER_RESULT: ${{ needs.server.result }} | |
| CLIENT_RESULT: ${{ needs.client.result }} | |
| DATABASE_RESULT: ${{ needs.database.result }} | |
| WINDOWS_SERVER_RESULT: ${{ needs.windows-server.result }} | |
| run: | | |
| node -e ' | |
| const results = { | |
| impact: process.env.IMPACT_RESULT, | |
| server: process.env.SERVER_RESULT, | |
| client: process.env.CLIENT_RESULT, | |
| database: process.env.DATABASE_RESULT, | |
| windows_server: process.env.WINDOWS_SERVER_RESULT, | |
| }; | |
| const failed = Object.entries(results) | |
| .filter(([, result]) => !["success", "skipped"].includes(result)); | |
| if (failed.length) { | |
| console.error("Selected CI jobs did not pass:", failed); | |
| process.exit(1); | |
| } | |
| console.log("CI gate passed:", results); | |
| ' | |
| full-gate: | |
| # A second check run published ONLY when the impact plan chose the complete | |
| # suite. release.yml keys on this name to skip re-running CI on the push to | |
| # `release`; the aggregate "CI Gate" above cannot serve that purpose, | |
| # because an impact-scoped PR run publishes a green "CI Gate" too. | |
| # It mirrors the aggregate gate rather than re-aggregating every job, so it | |
| # costs one comparison instead of a second fan-in. | |
| name: Full CI Gate | |
| if: always() && needs.impact.outputs.full == 'true' | |
| needs: [impact, gate] | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Require the aggregate gate to have passed | |
| env: | |
| GATE_RESULT: ${{ needs.gate.result }} | |
| run: | | |
| if [ "$GATE_RESULT" != "success" ]; then | |
| echo "The complete suite ran but CI Gate did not pass ($GATE_RESULT)." | |
| exit 1 | |
| fi | |
| echo "Complete suite passed — this tree does not need re-testing on release." |