Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 88 additions & 44 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,14 @@ jobs:
CHANGED=$(git diff --name-only "$BASE" HEAD || true)
echo "changed files:"; echo "$CHANGED"

# Shared foundations → run everything. (Root config, shared types/util/json, the execution
# spine, and the root orchestrators GameManager/City/Clock are imported across all modules.)
SHARED_RE='^(src/types/|src/util/|src/json/|src/app/game/execution/|src/app/game/GameManager\.ts|src/app/game/City\.ts|src/app/game/Clock\.ts|package\.json|package-lock\.json|tsconfig\.json|jest\.config\.js|\.babelrc|babel\.config\.js|scripts/|\.github/workflows/)'
# Shared foundations → run EVERY module's tests. This is a correctness net, not laziness: these
# are imported by / affect the compilation of every module, so a change here can break any
# module's tests. It is deliberately limited to things that change TEST OUTCOMES — shared source
# (types/util/json, the execution spine, the root orchestrators GameManager/City/Clock) and the
# test toolchain config (package(-lock), tsconfig, jest.config, babel). It does NOT include CI
# workflow files, scripts/, lint config, or docs — changing those can't change a module test's
# result, so a PR touching only those (like a docs/CI-config change) runs NO module tests.
SHARED_RE='^(src/types/|src/util/|src/json/|src/app/game/execution/|src/app/game/GameManager\.ts|src/app/game/City\.ts|src/app/game/Clock\.ts|package\.json|package-lock\.json|tsconfig\.json|jest\.config\.js|\.babelrc|babel\.config\.js)'

SELECTED=""
if echo "$CHANGED" | grep -qE "$SHARED_RE"; then
Expand All @@ -71,8 +76,9 @@ jobs:
fi

# Emit a JSON array (empty array when nothing test-relevant changed → the test job is skipped).
JSON=$(printf '%s\n' $SELECTED | grep -v '^$' | sort -u \
| jq -R . | jq -cs .)
# Use jq to drop blanks + dedupe — NOT `grep -v '^$'`, which exits 1 when SELECTED is empty
# (e.g. a docs-only PR) and, under `set -o pipefail`, would fail the whole step.
JSON=$(printf '%s\n' $SELECTED | jq -R 'select(length > 0)' | jq -cs 'unique')
echo "selected modules: $JSON"
echo "modules=$JSON" >> "$GITHUB_OUTPUT"

Expand Down Expand Up @@ -144,43 +150,85 @@ jobs:
- name: Generation perf-regression gates
run: npx jest --selectProjects perf --runInBand

# One concurrent job per affected test module (dynamic matrix) — fast, granular pass/fail. Each runs
# with --coverage and uploads its own per-module coverage report (measured by its own tests) for the
# `coverage` job to gate.
test:
# One concurrent job per test module — fast, granular pass/fail; each runs its module's jest project
# with --coverage and uploads its own per-module report for the `coverage` job to gate. Shared steps
# live in the reusable ./.github/workflows/test-module.yml; here we have one caller per module, each
# gated by a per-module `if` so modules this PR did NOT affect show up as SKIPPED in the checks list
# (not merely absent). `changes` decides which are affected (see that job). A static matrix can't do
# this — `matrix` isn't available in a job-level `if` — so keep this list in sync with `changes`.
test-world:
needs: changes
if: needs.changes.outputs.modules != '[]'
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
module: ${{ fromJSON(needs.changes.outputs.modules) }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run ${{ matrix.module }} tests with coverage
run: npx jest --selectProjects ${{ matrix.module }} --coverage --coverageReporters=json --coverageReporters=lcovonly --coverageDirectory coverage
- name: Upload ${{ matrix.module }} coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.module }}
path: |
coverage/coverage-final.json
coverage/lcov.info
if-no-files-found: ignore
if: ${{ contains(fromJSON(needs.changes.outputs.modules), 'world') }}
uses: ./.github/workflows/test-module.yml
with: { module: world }
test-agents:
needs: changes
if: ${{ contains(fromJSON(needs.changes.outputs.modules), 'agents') }}
uses: ./.github/workflows/test-module.yml
with: { module: agents }
test-population:
needs: changes
if: ${{ contains(fromJSON(needs.changes.outputs.modules), 'population') }}
uses: ./.github/workflows/test-module.yml
with: { module: population }
test-events:
needs: changes
if: ${{ contains(fromJSON(needs.changes.outputs.modules), 'events') }}
uses: ./.github/workflows/test-module.yml
with: { module: events }
test-actions:
needs: changes
if: ${{ contains(fromJSON(needs.changes.outputs.modules), 'actions') }}
uses: ./.github/workflows/test-module.yml
with: { module: actions }
test-execution:
needs: changes
if: ${{ contains(fromJSON(needs.changes.outputs.modules), 'execution') }}
uses: ./.github/workflows/test-module.yml
with: { module: execution }
test-economy:
needs: changes
if: ${{ contains(fromJSON(needs.changes.outputs.modules), 'economy') }}
uses: ./.github/workflows/test-module.yml
with: { module: economy }
test-skills:
needs: changes
if: ${{ contains(fromJSON(needs.changes.outputs.modules), 'skills') }}
uses: ./.github/workflows/test-module.yml
with: { module: skills }
test-objects:
needs: changes
if: ${{ contains(fromJSON(needs.changes.outputs.modules), 'objects') }}
uses: ./.github/workflows/test-module.yml
with: { module: objects }
test-history:
needs: changes
if: ${{ contains(fromJSON(needs.changes.outputs.modules), 'history') }}
uses: ./.github/workflows/test-module.yml
with: { module: history }
test-save:
needs: changes
if: ${{ contains(fromJSON(needs.changes.outputs.modules), 'save') }}
uses: ./.github/workflows/test-module.yml
with: { module: save }
test-data:
needs: changes
if: ${{ contains(fromJSON(needs.changes.outputs.modules), 'data') }}
uses: ./.github/workflows/test-module.yml
with: { module: data }
test-util:
needs: changes
if: ${{ contains(fromJSON(needs.changes.outputs.modules), 'util') }}
uses: ./.github/workflows/test-module.yml
with: { module: util }

# Coverage gate: does NOT run tests — it downloads every module's report from the `test` jobs and
# fails if ANY module's OWNED-file statement coverage is below COVERAGE_THRESHOLD (jest.config.js,
# currently 80%). scripts/coverage-gate.mjs filters each report to the module's own files first —
# Jest's collectCoverageFrom is additive, so an unfiltered report is diluted by transitively-required
# files from other modules. All modules currently clear 80%, so this is BLOCKING (in ci-success's needs).
coverage:
needs: [changes, test]
needs: [changes, test-world, test-agents, test-population, test-events, test-actions, test-execution, test-economy, test-skills, test-objects, test-history, test-save, test-data, test-util]
if: always() && needs.changes.outputs.modules != '[]'
runs-on: ubuntu-latest
timeout-minutes: 10
Expand Down Expand Up @@ -220,22 +268,18 @@ jobs:
# any required upstream job failed or was cancelled (skipped is fine). `audit` is intentionally NOT
# here (advisory — dev-tooling advisories shouldn't block).
ci-success:
needs: [changes, typecheck, build, lint, perf, test, coverage]
needs: [changes, typecheck, build, lint, perf, coverage, test-world, test-agents, test-population, test-events, test-actions, test-execution, test-economy, test-skills, test-objects, test-history, test-save, test-data, test-util]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Verify no required job failed
# join(needs.*.result) = the results of every job listed in `needs` (audit is excluded — advisory).
# skipped is fine (an unaffected module / a skipped coverage gate); only failure/cancelled block.
run: |
set -euo pipefail
for r in \
"${{ needs.changes.result }}" \
"${{ needs.typecheck.result }}" \
"${{ needs.build.result }}" \
"${{ needs.lint.result }}" \
"${{ needs.perf.result }}" \
"${{ needs.test.result }}" \
"${{ needs.coverage.result }}"; do
set -eu
echo "results: ${{ join(needs.*.result, ' ') }}"
for r in ${{ join(needs.*.result, ' ') }}; do
if [ "$r" = "failure" ] || [ "$r" = "cancelled" ]; then
echo "A required job did not succeed (result: $r)"; exit 1
fi
Expand Down
37 changes: 37 additions & 0 deletions .github/workflows/test-module.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: test-module

# Reusable workflow: run ONE module's Jest project with coverage and upload its per-module report for
# the `coverage` gate. ci.yml calls this once per module, each with a per-module `if`, so a module the
# PR didn't affect shows as SKIPPED in the checks list. (A static matrix can't do that — `matrix` is not
# available in a job-level `if` — and a dynamic matrix omits unaffected modules entirely rather than
# showing them skipped. Hence one small caller job per module, all sharing these steps.)

on:
workflow_call:
inputs:
module:
description: The test module (jest project) to run — e.g. economy, events, util.
required: true
type: string

jobs:
run:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- name: Run ${{ inputs.module }} tests with coverage
run: npx jest --selectProjects ${{ inputs.module }} --coverage --coverageReporters=json --coverageReporters=lcovonly --coverageDirectory coverage
- name: Upload ${{ inputs.module }} coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-${{ inputs.module }}
path: |
coverage/coverage-final.json
coverage/lcov.info
if-no-files-found: ignore
Loading
Loading