Skip to content

fix: thread ProviderModelSelector's loading gate to the remaining provider pickers #13839

fix: thread ProviderModelSelector's loading gate to the remaining provider pickers

fix: thread ProviderModelSelector's loading gate to the remaining provider pickers #13839

Workflow file for this run

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."