Files
iptvnator/.github/workflows/ci.yml
650da4a1d3 ci(test): type-check Jest spec programs and gate it in CI (#1705)
* build(test): make spec tsconfigs resolve what Jest resolves

Lib spec tsconfigs used module: commonjs with node10 resolution, which cannot
see Angular's exports-only secondary entry points, and dropped global.d.ts, so
tsc reported thousands of resolution errors and no window.electron typing.
Switch them to module: preserve with bundler resolution (ts-jest still forces
CommonJS emit outside ESM mode), add global.d.ts to every spec program, type
jest.unstable_mockModule for the ESM workspace, include the ui-epg and
ui-playback specs that jest.web-esm.workspace.ts runs under the web spec
config, and drop the snack-bar stub that shadowed the real Material types.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* ci(test): gate spec type-checking with typecheck:spec

Add tools/typecheck/spec-typecheck.mjs, which runs tsc --noEmit over every
tsconfig.spec.json with a small pool and fails on any diagnostic, wire it into
the unit-and-typecheck job after typecheck:ci, and document the gate and the
spec tsconfig conventions in the validation map. Also bring the non-Tier-A
spec configs (remote-control-web, ui-remote-control, stalker-mock-server) to
the same conventions so the gate covers the whole workspace.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test: fix the spec type errors surfaced by typecheck:spec

With the spec programs resolving modules and ambient typings correctly,
tsc reported 432 genuine errors across the Tier A projects: read-only
capability flags assigned on Partial<> doubles, signal-store values used as
types, fixtures missing required fields, index-signature property access,
partial bridge doubles cast through incompatible shapes, and deferred
resolvers narrowed to never. Type the doubles instead of casting to any:
writable mapped types for capability flags, InstanceType<typeof StalkerStore>,
typed jest.fn signatures, protectedState: false on test signal stores, and
completed fixtures. Production changes are limited to bracket access for
index-signature properties under the libs' noPropertyAccessFromIndexSignature
setting and two narrowing guards in the global favorites loader.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(playback): use the ESM setup's jest global in the controls fixtures

The fixture imported jest from @jest/globals, which is not a direct
dependency. Jest provides that module at runtime, so tests passed, but on a
clean pnpm install tsc cannot resolve it and typecheck:spec failed in CI.
The ESM test setup already installs import.meta.jest as the global, typed
by @types/jest, as the other ESM specs use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: type the parental lock doubles merged since the gate was written

The parental lock feature (#1601) and the Stalker actor route landed on master
with spec doubles declared as zero-argument jest.fn()s that the tests then
drive with the real arguments, plus a copy of the ResizableDirective override
imported from a library that does not export it. Give the doubles the lock
service's real signatures, drop the dead override as in the sibling layout
specs, use bracket access for the actor route's personId param, and keep the
Stalker layout spec within the 1200-line limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 20:54:27 +02:00

491 lines
21 KiB
YAML

name: CI
on:
push:
branches:
- master
pull_request:
branches:
- master
workflow_dispatch:
# Superseded PR pushes cancel their still-running checks. Non-PR runs get a
# unique group (run_id) because GitHub keeps at most one pending run per
# group even with cancel-in-progress: false — a shared ref group would let a
# rapid master push silently replace a queued sibling and leave a merged
# commit without a lint/test record.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
jobs:
actionlint:
name: Workflow lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@v7
# Image pinned by digest (tag 1.7.12). False positives are
# suppressed in .github/actionlint.yaml; shellcheck runs at
# warning+ severity so style/info notes in long release scripts
# don't fail CI while real quoting/logic bugs still do.
- name: Run actionlint
uses: docker://rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667
with:
args: -color
env:
SHELLCHECK_OPTS: --severity=warning
release-note-gate:
name: Release note gate
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout code
uses: actions/checkout@v7
# The gate scripts are dependency-free Node, so this job skips
# pnpm install entirely and stays cheap.
- name: Validate release note format
run: node tools/release/build-release-notes.mjs --validate
# Labels are fetched live rather than read from the (stale) event
# payload, so applying `no-release-note` and re-running the check
# works without a new push. Policy lives in a unit-tested script,
# not in workflow bash.
- name: Require a release note for user-visible changes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \
--paginate --jq '[.[] | {filename, status}]' |
jq -s 'add // []' > /tmp/pr-files.json
gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/labels?per_page=100" \
--paginate --jq '[.[].name]' |
jq -s 'add // []' > /tmp/pr-labels.json
jq -n \
--slurpfile files /tmp/pr-files.json \
--slurpfile labels /tmp/pr-labels.json \
'{files: $files[0], labels: $labels[0]}' |
node tools/release/check-release-note-gate.mjs
lint:
name: Lint
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
# nx affected needs the merge-base with the PR target branch.
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
# PRs lint only affected projects for faster feedback; root config
# or lockfile changes make every project affected, so the
# module-boundary and max-lines rules cannot be dodged this way.
- name: Lint affected projects (PR)
if: github.event_name == 'pull_request'
run: pnpm nx affected --target=lint --base=origin/${{ github.base_ref }} --head=HEAD --parallel=3 --output-style=static
env:
CI: true
NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false
- name: Lint all projects (master)
if: github.event_name != 'pull_request'
run: pnpm nx run-many --target=lint --all --parallel=3 --output-style=static
env:
CI: true
NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false
initial-bytes-ratchet:
name: Initial bytes ratchet
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
# The ratchet below compares a measurement with the baselines file
# of the same commit, so it cannot see a change that grows the
# payload and raises the baseline to match. Compare the file with
# the revision this one is measured against instead: the target
# branch for a pull request, the previous head for a master push,
# master for a manual dispatch. Any raised limit, widened tolerance
# or removed entry fails.
- name: Refuse baseline increases against the previous revision
env:
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
BEFORE_SHA: ${{ github.event.before }}
run: |
set -euo pipefail
case "$EVENT_NAME" in
pull_request)
git fetch --no-tags --depth=1 origin "$BASE_REF"
;;
push)
if [ -z "$BEFORE_SHA" ] || [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then
echo "No previous revision for this push; nothing to compare against."
exit 0
fi
git fetch --no-tags --depth=1 origin "$BEFORE_SHA"
;;
*)
git fetch --no-tags --depth=1 origin master
;;
esac
git show "FETCH_HEAD:tools/performance/journey-baselines.json" > /tmp/base-journey-baselines.json 2>/dev/null ||
rm -f /tmp/base-journey-baselines.json
node tools/performance/check-baseline-direction.mjs \
--base /tmp/base-journey-baselines.json \
--head tools/performance/journey-baselines.json
# The production configuration is what users download; measuring
# any other build would ratchet a number nobody ships.
- name: Build web app (production)
run: pnpm nx build web --skip-nx-cache
env:
CI: true
NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false
# Fails when renderer.initialBytes exceeds the value committed in
# tools/performance/journey-baselines.json. Baselines only move
# down, with the printed measurement as evidence; the contract is
# docs/architecture/performance-journeys.md.
- name: Check renderer.initialBytes against the baseline
run: pnpm run perf:initial-bytes:check
- name: Upload journey summary
if: always()
uses: actions/upload-artifact@v7
with:
name: performance-journey-summary
path: dist/performance/
retention-days: 14
# Decides whether a pull request can move the performance journeys, so
# the journeys job below does not spend a runner on docs-only changes.
# Pushes to master and manual dispatches always run them.
performance-journeys-scope:
name: Performance journeys scope
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
run: ${{ steps.scope.outputs.run }}
steps:
# The PR checkout is the merge commit: its first parent is the
# target branch, so two commits are enough to diff the PR.
- name: Checkout code
if: github.event_name == 'pull_request'
uses: actions/checkout@v7
with:
fetch-depth: 2
# Anything can move a journey (application code, the harness, root
# build inputs such as .nvmrc, nx.json or tsconfig.base.json), so
# the filter lists what cannot: the same paths the E2E workflow
# ignores, plus release notes.
- name: Detect journey-relevant changes
id: scope
env:
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" != "pull_request" ]; then
echo "run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
relevant="$(git diff --name-only HEAD^1 HEAD |
grep -vE '\.md$|^docs/|^\.plans/|^\.codex/|^\.claude/|^\.changes/|^apps/website/' || true)"
if [ -n "$relevant" ]; then
echo "Journey-relevant changes:"
echo "$relevant"
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "No journey-relevant changes; skipping the performance journeys."
echo "run=false" >> "$GITHUB_OUTPUT"
fi
# Runs the journey benchmarks (docs/architecture/performance-journeys.md)
# on the canonical Linux runner and uploads the summary as evidence.
performance-journeys:
name: Performance journeys
needs: performance-journeys-scope
if: needs.performance-journeys-scope.outputs.run == 'true'
runs-on: ubuntu-latest
# The electron-performance build is the bulk of the time; the launch
# journey itself is six fresh Electron processes plus one seeding run.
timeout-minutes: 30
# Warn-only for the first two weeks of plan item B3: a failure is
# visible on the run but does not fail the workflow.
continue-on-error: true
env:
NX_SKIP_NX_CACHE: true
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
# The journeys drive Electron through Playwright's _electron API
# and never launch a Playwright browser, so no `playwright
# install`. The runner image ships Electron's shared libraries and
# xvfb; fail fast with a clear message if an image update drops one.
- name: Check Electron runtime dependencies
run: |
command -v xvfb-run || { echo "::error::xvfb-run is missing on the runner"; exit 1; }
missing="$(ldd node_modules/electron/dist/electron | grep 'not found' || true)"
if [ -n "$missing" ]; then
echo "::error::Electron is missing shared libraries:"
echo "$missing"
exit 1
fi
# The Nx target builds electron-backend:build-performance first
# and playwright.journeys.config.ts starts the Xtream mock server.
- name: Run the performance journeys
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x960x24" pnpm run perf:journeys
env:
CI: true
NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false
# Every run writes a fresh timestamped directory, so a clean
# checkout must hold exactly one summary.
- name: Locate the journey summary
id: summary
run: |
set -euo pipefail
mapfile -t summaries < <(find dist/performance/journeys -mindepth 2 -maxdepth 2 -name summary.json)
if [ "${#summaries[@]}" -ne 1 ]; then
echo "::error::Expected one journey summary, found ${#summaries[@]}"
exit 1
fi
echo "path=${summaries[0]}" >> "$GITHUB_OUTPUT"
- name: Report journey measurements
env:
SUMMARY: ${{ steps.summary.outputs.path }}
run: |
set -euo pipefail
jq -r '
"## Performance journeys (\(.harness.platform), \(.harness.measuredIterations) measured iterations)", "",
(.journeys | to_entries[] | .key as $journey | .value as $j |
"### `\($journey)`", "",
"| Measurement | Value | Iterations |",
"| --- | ---: | --- |",
(($j.counters // {}) | to_entries[] |
($j.counterStability[.key] // {}) as $s |
"| `\(.key)` | \(.value) | \(($s.values // []) | map(tostring) | join(", "))\(if $s.stable == false then " (unstable)" else "" end) |"),
(($j.wallClock // {}) | to_entries[] | "| `\(.key)` | \(.value) | |"),
"")
' "$SUMMARY" | tee -a "$GITHUB_STEP_SUMMARY"
- name: Upload journey summaries
if: always()
uses: actions/upload-artifact@v7
with:
name: performance-journeys
path: dist/performance/journeys/
if-no-files-found: warn
retention-days: 14
unit-and-typecheck:
name: Unit Tests and Typechecks
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
# The scope step lists the PR's changed files through the API.
pull-requests: read
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Install pnpm
uses: pnpm/action-setup@v6.0.10
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Validate agent guidance
run: pnpm run agents:validate
- name: Validate Nx dependency version policy
run: pnpm run deps:nx:validate
- name: Validate Vite dev-server transform filter patch
run: pnpm run deps:vite:test
- name: Validate electron-builder keychain password patch
run: pnpm run deps:electron-builder:test
- name: Validate stylesheet Nx inputs
run: pnpm run styles:inputs:validate
- name: Typecheck web and Electron entry points
run: pnpm run typecheck:ci
- name: Typecheck Jest spec programs
run: pnpm run typecheck:spec:test && pnpm run typecheck:spec
- name: Check i18n drift
run: pnpm run i18n:check
# A pull request whose changes cannot reach any Tier A test (docs,
# notes, other workflows, website, E2E and mock-server apps, release
# and packaging tooling, a scripts-only package.json edit) skips the
# suite. The allowlist lives in a unit-tested script; anything it
# does not know runs everything, and master always runs everything.
- name: Decide unit coverage scope
id: scope
env:
EVENT_NAME: ${{ github.event_name }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
CHANGED_FILES: ${{ github.event.pull_request.changed_files }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" != "pull_request" ]; then
echo "Not a pull request; running the full suite."
echo "run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# The list-files endpoint stops at 3,000 files; a truncated
# list could hide a file that needs the suite.
if [ "${CHANGED_FILES:-0}" -ge 3000 ]; then
echo "PR changes ${CHANGED_FILES} files, beyond the API listing limit; running the full suite."
echo "run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
git fetch --no-tags --depth=1 origin "$BASE_SHA"
gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files?per_page=100" \
--paginate --jq '.[] | .filename, (.previous_filename // empty)' |
node tools/coverage/unit-coverage-scope.mjs --base FETCH_HEAD --github-output
# Jest's transform cache (TypeScript/Angular transpilation plus
# coverage instrumentation, keyed by file content) is persisted
# between runs. Only master pushes and maintainer dispatches save
# it; pull requests restore it and never write, so a PR cannot plant
# an entry that a later master run would read.
- name: Restore Jest transform cache
if: steps.scope.outputs.run == 'true' && github.event_name != 'push'
uses: actions/cache/restore@v6
with:
path: ${{ runner.temp }}/jest-cache
key: jest-transform-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'tsconfig.base.json', 'jest.preset.js', 'jest.web-esm.workspace.ts', 'apps/web/jest.config.ts') }}-${{ github.run_id }}
restore-keys: |
jest-transform-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'tsconfig.base.json', 'jest.preset.js', 'jest.web-esm.workspace.ts', 'apps/web/jest.config.ts') }}-
- name: Run Tier A unit coverage suite
if: steps.scope.outputs.run == 'true'
run: pnpm run coverage:ci
env:
CI: true
NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false
JEST_CACHE_DIRECTORY: ${{ runner.temp }}/jest-cache
- name: Validate coverage tooling (suite skipped)
if: steps.scope.outputs.run != 'true'
run: pnpm run coverage:tools:test && pnpm run coverage:policy:check
# Master pushes start from an empty cache, so the saved cache holds
# exactly the current tree and does not grow run over run.
- name: Save Jest transform cache
if: >-
steps.scope.outputs.run == 'true' &&
(github.event_name == 'workflow_dispatch' ||
(github.event_name == 'push' && github.ref == 'refs/heads/master'))
uses: actions/cache/save@v6
with:
path: ${{ runner.temp }}/jest-cache
key: jest-transform-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'tsconfig.base.json', 'jest.preset.js', 'jest.web-esm.workspace.ts', 'apps/web/jest.config.ts') }}-${{ github.run_id }}
- name: Run Tier B/C validation commands
run: node tools/coverage/check-coverage-policy.mjs --run-non-tier-a
env:
CI: true
NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false
- name: Upload unit coverage artifact
if: always() && steps.scope.outputs.run == 'true'
uses: actions/upload-artifact@v7
with:
name: unit-coverage
path: |
coverage/merged/
retention-days: 14
- name: Upload unit coverage to Codecov
if: always() && steps.scope.outputs.run == 'true'
uses: codecov/codecov-action@v7
with:
files: ./coverage/merged/lcov.info,./coverage/merged/cobertura-coverage.xml
flags: unit
name: iptvnator-unit
fail_ci_if_error: false
handle_no_reports_found: true
disable_search: true
token: ${{ secrets.CODECOV_TOKEN }}