From 9615311bb7afaca031f0c77c9b06c4495b65dde3 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 22 Oct 2026 10:31:00 +0700 Subject: [PATCH 01/12] Prototype nx graph-only affected detection (ticket 12) Adds an Nx root (package.json, settings-only pnpm-workspace.yaml, lockfile, nx.json) with @nx/dotnet, a project.json for frontend/viewer, and .github/scripts/affected.sh behind `task ci:affected`, which prints one JSON object of affected .NET projects, viewer/extension/everything flags and a fallback reason. Builds and tests still run through dotnet and pnpm. Co-Authored-By: Claude Fable 5.1 --- .github/scripts/affected.sh | 109 ++++ .gitignore | 4 + Taskfile.yml | 6 + frontend/viewer/project.json | 8 + nx.json | 22 + package.json | 14 + pnpm-lock.yaml | 1050 ++++++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 20 + 8 files changed, 1233 insertions(+) create mode 100755 .github/scripts/affected.sh create mode 100644 frontend/viewer/project.json create mode 100644 nx.json create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml diff --git a/.github/scripts/affected.sh b/.github/scripts/affected.sh new file mode 100755 index 0000000000..c8cc8618d3 --- /dev/null +++ b/.github/scripts/affected.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Prints ONE JSON object describing what the current branch affects, for the PR +# workflow's detect job and for `task ci:affected` (same answer in both places). +# +# { +# "projects": [".NET project names affected, transitively, per nx's graph"], +# "viewer": true|false, // frontend/viewer affected (nx project "viewer") +# "extension": true|false, // platform.bible-extension/** changed +# "everything": true|false, // a shared file changed: run the whole PR tier +# "tiers": [], // escalation tiers from path rules (not yet) +# "fallback": null | "reason" // set when detection failed; everything is then true +# } +# +# Diff range: merge-base of ${AFFECTED_BASE:-origin/develop} and ${AFFECTED_HEAD:-HEAD}, +# to that head. Only committed changes count, like GitHub's three-dot PR diff. +# Needs a full-depth checkout (fetch-depth: 0) and no token: everything is local git. +# +# .NET names and the viewer boolean come from nx (`nx show projects --affected`, +# @nx/dotnet plugin, graph-only). This script keeps only the glob rules nx does +# not express: the shared files that mark everything affected, and the extension. +# +# Any failure still prints valid JSON with "everything": true and a "fallback" +# reason, and exits 0, so a broken detector can only over-run CI, never skip it. + +set -u + +cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" || exit 0 + +base_ref="${AFFECTED_BASE:-origin/develop}" +head_ref="${AFFECTED_HEAD:-HEAD}" + +# Shared files: a change to any of these marks everything affected (map decision). +# Matched as bash globs against repo-relative paths. +everything_globs=( + 'global.json' + 'LexBox.slnx' + '*.slnf' + 'backend/Directory.Build.props' + 'backend/Directory.Build.targets' + 'backend/Directory.Packages.props' + 'backend/Harmony*.props' + 'frontend/pnpm-lock.yaml' + 'frontend/pnpm-workspace.yaml' + # The detector's own inputs: nx config and this script. + 'nx.json' + 'package.json' + 'pnpm-lock.yaml' + 'pnpm-workspace.yaml' + '.github/scripts/affected.sh' +) +extension_globs=('platform.bible-extension/*') + +fallback() { + # $1: reason (kept to printable ASCII, quotes stripped, so it is safe inside JSON) + local reason + reason=$(printf '%s' "$1" | tr -cd '[:print:]' | tr -d '"\\' | cut -c1-300) + printf '{"projects":[],"viewer":true,"extension":true,"everything":true,"tiers":[],"fallback":"%s"}\n' "$reason" + exit 0 +} + +matches_any() { + # $1: path, rest: globs. `*` in a glob also crosses `/` here (pattern matching + # in [[ ]] does not treat `/` specially), so 'dir/*' means 'dir/**'. + local path=$1; shift + local g + for g in "$@"; do + # shellcheck disable=SC2053 + [[ $path == $g ]] && return 0 + done + return 1 +} + +merge_base=$(git merge-base "$base_ref" "$head_ref" 2>&1) \ + || fallback "git merge-base $base_ref $head_ref failed: $merge_base" + +changed=$(git diff --name-only "$merge_base" "$head_ref" 2>&1) \ + || fallback "git diff $merge_base..$head_ref failed: $changed" + +everything=false +extension=false +while IFS= read -r f; do + [ -z "$f" ] && continue + matches_any "$f" "${everything_globs[@]}" && everything=true + matches_any "$f" "${extension_globs[@]}" && extension=true +done <<<"$changed" + +nx=./node_modules/.bin/nx +[ -x "$nx" ] || fallback "nx is not installed: run 'pnpm install' at the repo root" + +# nx prints the JSON array as the last stdout line; banners go to stderr. +nx_out=$("$nx" show projects --affected --base="$merge_base" --head="$head_ref" --json 2>/dev/null) \ + || fallback "nx show projects --affected failed (exit $?)" +nx_json=$(printf '%s\n' "$nx_out" | grep '^\[' | tail -n 1) +[ -n "$nx_json" ] || fallback "nx printed no JSON array" + +# Assemble the result with node (nx already requires it), so names are escaped properly. +NX_JSON="$nx_json" EVERYTHING="$everything" EXTENSION="$extension" node -e ' + const names = JSON.parse(process.env.NX_JSON); + const viewer = names.includes("viewer"); + const projects = names.filter((n) => n !== "viewer").sort(); + process.stdout.write(JSON.stringify({ + projects, + viewer, + extension: process.env.EXTENSION === "true", + everything: process.env.EVERYTHING === "true", + tiers: [], + fallback: null, + }) + "\n"); +' || fallback "could not assemble JSON from nx output" diff --git a/.gitignore b/.gitignore index f9286a2e5b..f56638f16c 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,7 @@ AGENTS.local.md .claude/scheduled_tasks.lock .claude/agent-memory-local/ screenshots/ + +# nx (affected detection only): cache/workspace-data and the root node_modules +.nx/ +/node_modules/ diff --git a/Taskfile.yml b/Taskfile.yml index 5d094c73c0..b74dbb75d9 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -114,6 +114,12 @@ tasks: internal: true deps: [ ui:verify, fw-ui:verify ] + ci:affected: + desc: 'Print one JSON object of what this branch affects vs origin/develop (nx graph + shared-file globs); AFFECTED_BASE= overrides the base. Same script as the PR workflow.' + silent: true + cmds: + - bash .github/scripts/affected.sh + check-generated: desc: 'Runs every committed-artifact drift check CI runs (EF model x2, gql schema, OpenAPI, FwLite TS types); reports all, fails if any fail.' silent: true diff --git a/frontend/viewer/project.json b/frontend/viewer/project.json new file mode 100644 index 0000000000..85c9782b28 --- /dev/null +++ b/frontend/viewer/project.json @@ -0,0 +1,8 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "viewer", + "projectType": "application", + "sourceRoot": "frontend/viewer/src", + "tags": ["pnpm"], + "_comment": "Registers the FW Lite viewer as an nx project for affected detection only. The viewer lives in the frontend/ pnpm workspace, which nx cannot see from the repo root, hence this file. No nx targets are defined; pnpm scripts stay the way to build and test it." +} diff --git a/nx.json b/nx.json new file mode 100644 index 0000000000..93e627c51c --- /dev/null +++ b/nx.json @@ -0,0 +1,22 @@ +{ + "$schema": "./node_modules/nx/schemas/nx-schema.json", + "_comment": "nx is used graph-only here: `nx show projects --affected` answers which .NET projects and which pnpm packages a diff touches (see .github/scripts/affected.sh). Builds and tests still run through dotnet and pnpm; no nx targets are run and nothing is cached.", + "_sharedGlobals_comment": "namedInputs.sharedGlobals: a change to any of these marks every .NET project affected. @nx/dotnet's analyzer already declares them as per-project evaluation inputs (verified: same answer with the list removed); listing them keeps the rule explicit and independent of the plugin. They only reach projects whose targets use the `default` input, i.e. the .NET ones, not frontend/viewer: the frontend lockfile and other non-.NET shared files are handled by .github/scripts/affected.sh instead.", + "defaultBase": "develop", + "plugins": [ + { + "plugin": "@nx/dotnet", + "options": {} + } + ], + "namedInputs": { + "default": ["{projectRoot}/**/*", "sharedGlobals"], + "sharedGlobals": [ + "{workspaceRoot}/global.json", + "{workspaceRoot}/backend/Directory.Build.props", + "{workspaceRoot}/backend/Directory.Build.targets", + "{workspaceRoot}/backend/Directory.Packages.props", + "{workspaceRoot}/backend/Harmony*.props" + ] + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000..fcc5cc0a45 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "languageforge-lexbox", + "private": true, + "description": "Repo-root package: hosts nx + @nx/dotnet for affected-project detection only (graph-only; builds and tests still run through dotnet and pnpm). The web apps live in the separate pnpm workspace under frontend/.", + "packageManager": "pnpm@12.3.4", + "engines": { + "node": ">=26", + "pnpm": ">=12" + }, + "devDependencies": { + "@nx/dotnet": "23.3.0", + "nx": "23.3.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000000..a156821133 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1050 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.3.4 + version: 12.3.4 + +packages: + + '@pnpm/exe.darwin-arm64@12.3.4': + resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.3.4': + resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.3.4': + resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.3.4': + resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.3.4': + resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.3.4': + resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.3.4': + resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.3.4': + resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==} + cpu: [x64] + os: [win32] + + pnpm@12.3.4: + resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.3.4': + optional: true + + '@pnpm/exe.darwin-x64@12.3.4': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.3.4': + optional: true + + '@pnpm/exe.linux-arm64@12.3.4': + optional: true + + '@pnpm/exe.linux-x64-musl@12.3.4': + optional: true + + '@pnpm/exe.linux-x64@12.3.4': + optional: true + + '@pnpm/exe.win32-arm64@12.3.4': + optional: true + + '@pnpm/exe.win32-x64@12.3.4': + optional: true + + pnpm@12.3.4: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.3.4 + '@pnpm/exe.darwin-x64': 12.3.4 + '@pnpm/exe.linux-arm64': 12.3.4 + '@pnpm/exe.linux-arm64-musl': 12.3.4 + '@pnpm/exe.linux-x64': 12.3.4 + '@pnpm/exe.linux-x64-musl': 12.3.4 + '@pnpm/exe.win32-arm64': 12.3.4 + '@pnpm/exe.win32-x64': 12.3.4 + +--- +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@nx/dotnet': + specifier: 23.3.0 + version: 23.3.0(nx@23.3.0) + nx: + specifier: 23.3.0 + version: 23.3.0 + +packages: + + '@clack/core@1.4.3': + resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.7.0': + resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} + engines: {node: '>= 20.12.0'} + + '@emnapi/core@1.4.5': + resolution: {integrity: sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q==} + + '@emnapi/runtime@1.4.5': + resolution: {integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==} + + '@emnapi/wasi-threads@1.0.4': + resolution: {integrity: sha512-PJR+bOmMOPH8AtcTGAyYNiuJ3/Fcoj2XN/gBEWzDIKh254XO+mM9XoXHk5GNEhodxeMznbg7BlRojVbKN+gC6g==} + + '@jest/diff-sequences@30.0.1': + resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@napi-rs/wasm-runtime@0.2.4': + resolution: {integrity: sha512-9zESzOO5aDByvhIAsOy9TbpZ0Ur2AJbUI7UT73kcUTS2mxAMHOBaa1st/jAymNoCtvrit99kkzT1FZuXVcgfIQ==} + + '@nx/devkit@23.3.0': + resolution: {integrity: sha512-xGSV4gCwQsVdt49VRcfxd2L31dAO/9t9Isv2bM5O89vyEzwd/tkW/IQwUzWn3OdhCnHayd37S1tDyJ5DilCv7w==} + peerDependencies: + nx: '>= 22 <= 24 || ^23.0.0-0' + + '@nx/dotnet@23.3.0': + resolution: {integrity: sha512-yxZM0a8Dr5hD2mThaCLbv9yHdfprDoQkiq3KMfcK8PLy8kPF7u+TdF2wZ8FlTcZaWKO/+Hp8JZVBk3ca+3M8TQ==} + + '@nx/nx-darwin-arm64@23.3.0': + resolution: {integrity: sha512-g06RId2Tn99ICwDFisZfDZ2vpM2bXvoCZeNtTWgwLm5mpJ4A7+akI7iXCe50CbzgE4waVRQCOAYpD1zPsKoUVQ==} + cpu: [arm64] + os: [darwin] + + '@nx/nx-darwin-x64@23.3.0': + resolution: {integrity: sha512-6t9P5ZQ3LPuCf+HBDMTGtpKHjVvINL9/blpY4PucKjdC+vnNSL7uTZwng1ePenlNF0WFtLHqxUx/H1XgohRq0w==} + cpu: [x64] + os: [darwin] + + '@nx/nx-freebsd-x64@23.3.0': + resolution: {integrity: sha512-B8Xuh36ZdMJ2SxpFfUITUvCZQTKoGOFHpSactxCx3ymFBUc24Z0B/hQRIYPv43icugcnp71dIwu6IvI66/aqtQ==} + cpu: [x64] + os: [freebsd] + + '@nx/nx-linux-arm-gnueabihf@23.3.0': + resolution: {integrity: sha512-piMlCEI0tbHt1+QSgL8J1szlqBSGKV27nax8uvhTxleR2Evl3Kxa80BDIEObrUU3lz+AS4oKrcUHUeyJGPdCsQ==} + cpu: [arm] + os: [linux] + + '@nx/nx-linux-arm64-gnu@23.3.0': + resolution: {integrity: sha512-SljVcZYwD4qpwseAUsEQmrXxE0vcPdDMEtvKlpkTProofAMeQFNEh4imw458TylOq7YeFVnNQdZcBnTO+1FeIw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@nx/nx-linux-arm64-musl@23.3.0': + resolution: {integrity: sha512-UwzWPtcWxkAE8IOZl4M1eiqsa1yci+zlZ7Vv5ogozhm9jD9EHZVsgeWLrftW22OtU2u+ude/fzBeacuqUDOMtg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@nx/nx-linux-x64-gnu@23.3.0': + resolution: {integrity: sha512-PJerI1Ag/wkPh8XMBKO4Cx6Ao01QJsSk/ycFNmM7vzW19Db3OvsTSKqiql/z96zINI69MqLDZH2iwHMZXbJupQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@nx/nx-linux-x64-musl@23.3.0': + resolution: {integrity: sha512-CGete35FhqkdlBZM/Oq6Ddu+yQ7UHMRV/ZYCoKeNU4/YIDIWze8VblF7PGKLU4k5JN4bRY1MmDmryHqVIMXMBA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@nx/nx-win32-arm64-msvc@23.3.0': + resolution: {integrity: sha512-T/ChrK9uzt9/29TQzc8FGkRGDJC130ltM77eKI3Z67OjyD4SKHhsiTu5GameH1BU72jcF4QEKFf74PoI9JzbuA==} + cpu: [arm64] + os: [win32] + + '@nx/nx-win32-x64-msvc@23.3.0': + resolution: {integrity: sha512-ermx8Qy8qf0ZIEDiGPqIXxQ0OPEL8VVUOyvAsxDec8aft3pCFjX8LrFhlfnAfM6MBFs5Bnc9Xl9D8TMzFBgo9w==} + cpu: [x64] + os: [win32] + + '@tybys/wasm-util@0.9.0': + resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} + + '@yarnpkg/lockfile@1.1.0': + resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} + + '@zkochan/js-yaml@0.0.7': + resolution: {integrity: sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==} + hasBin: true + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + balanced-match@4.0.3: + resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} + engines: {node: 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-spinners@2.6.1: + resolution: {integrity: sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==} + engines: {node: '>=6'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + default-browser-id@5.0.0: + resolution: {integrity: sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==} + engines: {node: '>=18'} + + default-browser@5.2.1: + resolution: {integrity: sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==} + engines: {node: '>=18'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + dotenv-expand@12.0.3: + resolution: {integrity: sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==} + engines: {node: '>=12'} + + dotenv@16.4.7: + resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} + engines: {node: '>=12'} + + ejs@5.0.1: + resolution: {integrity: sha512-COqBPFMxuPTPspXl2DkVYaDS3HtrD1GpzOGkNTJ1IYkifq/r9h8SVEFrjA3D9/VJGOEoMQcrlhpntcSUrM8k6A==} + engines: {node: '>=0.12.18'} + hasBin: true + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@7.0.13: + resolution: {integrity: sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-wsl@3.1.0: + resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} + engines: {node: '>=16'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + lines-and-columns@2.0.3: + resolution: {integrity: sha512-cNOjgCnLB+FnvWWtyRTzmB3POJ+cXxTA81LoW7u8JdmhfXzriropYwpjShnz1QLLWsQwY7nIxoDmcPTwphDK9w==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + nx@23.3.0: + resolution: {integrity: sha512-R7Kl+hURSGiG9uEjc19Cg3o+vGCDHqCVp6mioiBMEsOSol/LgzP+K8TVF06TnnSC8uzxMeB0ymu+LGMJGXnbbw==} + hasBin: true + peerDependencies: + '@swc-node/register': ^1.11.1 + '@swc/core': ^1.15.8 + peerDependenciesMeta: + '@swc-node/register': + optional: true + '@swc/core': + optional: true + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + open@10.1.0: + resolution: {integrity: sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==} + engines: {node: '>=18'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + run-applescript@7.0.0: + resolution: {integrity: sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==} + engines: {node: '>=18'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + smol-toml@1.9.0: + resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} + engines: {node: '>= 18'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + which@3.0.1: + resolution: {integrity: sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + +snapshots: + + '@clack/core@1.4.3': + dependencies: + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + + '@clack/prompts@1.7.0': + dependencies: + '@clack/core': 1.4.3 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.0 + sisteransi: 1.0.5 + + '@emnapi/core@1.4.5': + dependencies: + '@emnapi/wasi-threads': 1.0.4 + tslib: 2.8.1 + + '@emnapi/runtime@1.4.5': + dependencies: + tslib: 2.8.1 + + '@emnapi/wasi-threads@1.0.4': + dependencies: + tslib: 2.8.1 + + '@jest/diff-sequences@30.0.1': {} + + '@napi-rs/wasm-runtime@0.2.4': + dependencies: + '@emnapi/core': 1.4.5 + '@emnapi/runtime': 1.4.5 + '@tybys/wasm-util': 0.9.0 + + '@nx/devkit@23.3.0(nx@23.3.0)': + dependencies: + ejs: 5.0.1 + minimatch: 10.2.5 + nx: 23.3.0 + semver: 7.8.5 + tslib: 2.8.1 + yargs-parser: 21.1.1 + + '@nx/dotnet@23.3.0(nx@23.3.0)': + dependencies: + '@nx/devkit': 23.3.0(nx@23.3.0) + ignore: 7.0.13 + tslib: 2.8.1 + transitivePeerDependencies: + - nx + + '@nx/nx-darwin-arm64@23.3.0': + optional: true + + '@nx/nx-darwin-x64@23.3.0': + optional: true + + '@nx/nx-freebsd-x64@23.3.0': + optional: true + + '@nx/nx-linux-arm-gnueabihf@23.3.0': + optional: true + + '@nx/nx-linux-arm64-gnu@23.3.0': + optional: true + + '@nx/nx-linux-arm64-musl@23.3.0': + optional: true + + '@nx/nx-linux-x64-gnu@23.3.0': + optional: true + + '@nx/nx-linux-x64-musl@23.3.0': + optional: true + + '@nx/nx-win32-arm64-msvc@23.3.0': + optional: true + + '@nx/nx-win32-x64-msvc@23.3.0': + optional: true + + '@tybys/wasm-util@0.9.0': + dependencies: + tslib: 2.8.1 + + '@yarnpkg/lockfile@1.1.0': {} + + '@zkochan/js-yaml@0.0.7': + dependencies: + argparse: 2.0.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + balanced-match@4.0.3: {} + + base64-js@1.5.1: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.3 + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.0.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-spinners@2.6.1: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + default-browser-id@5.0.0: {} + + default-browser@5.2.1: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.0 + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-lazy-prop@3.0.0: {} + + dotenv-expand@12.0.3: + dependencies: + dotenv: 16.4.7 + + dotenv@16.4.7: {} + + ejs@5.0.1: {} + + emoji-regex@8.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + escalade@3.2.0: {} + + escape-string-regexp@1.0.5: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + + fs-constants@1.0.0: {} + + get-caller-file@2.0.5: {} + + has-flag@4.0.0: {} + + ieee754@1.2.1: {} + + ignore@7.0.13: {} + + ignore@7.0.6: {} + + inherits@2.0.4: {} + + is-docker@3.0.0: {} + + is-fullwidth-code-point@3.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@1.0.0: {} + + is-unicode-supported@0.1.0: {} + + is-wsl@3.1.0: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + + json5@2.2.3: {} + + jsonc-parser@3.3.1: {} + + lines-and-columns@2.0.3: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + mimic-fn@2.1.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.12 + + minimist@1.2.8: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + nx@23.3.0: + dependencies: + '@clack/core': 1.4.3 + '@clack/prompts': 1.7.0 + '@emnapi/core': 1.4.5 + '@emnapi/runtime': 1.4.5 + '@emnapi/wasi-threads': 1.0.4 + '@jest/diff-sequences': 30.0.1 + '@napi-rs/wasm-runtime': 0.2.4 + '@tybys/wasm-util': 0.9.0 + '@yarnpkg/lockfile': 1.1.0 + '@zkochan/js-yaml': 0.0.7 + ansi-regex: 5.0.1 + ansi-styles: 4.3.0 + argparse: 2.0.1 + balanced-match: 4.0.3 + base64-js: 1.5.1 + bl: 4.1.0 + brace-expansion: 5.0.12 + buffer: 5.7.1 + bundle-name: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.6.1 + cliui: 8.0.1 + clone: 1.0.4 + color-convert: 2.0.1 + color-name: 1.1.4 + default-browser: 5.2.1 + default-browser-id: 5.0.0 + defaults: 1.0.4 + define-lazy-prop: 3.0.0 + dotenv: 16.4.7 + dotenv-expand: 12.0.3 + ejs: 5.0.1 + emoji-regex: 8.0.0 + end-of-stream: 1.4.5 + escalade: 3.2.0 + escape-string-regexp: 1.0.5 + fast-string-truncated-width: 3.0.3 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.0 + figures: 3.2.0 + fs-constants: 1.0.0 + get-caller-file: 2.0.5 + has-flag: 4.0.0 + ieee754: 1.2.1 + ignore: 7.0.6 + inherits: 2.0.4 + is-docker: 3.0.0 + is-fullwidth-code-point: 3.0.0 + is-inside-container: 1.0.0 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + is-wsl: 3.1.0 + isexe: 2.0.0 + json5: 2.2.3 + jsonc-parser: 3.3.1 + lines-and-columns: 2.0.3 + log-symbols: 4.1.0 + mimic-fn: 2.1.0 + minimatch: 10.2.5 + minimist: 1.2.8 + npm-run-path: 4.0.1 + once: 1.4.0 + onetime: 5.1.2 + open: 10.1.0 + ora: 5.4.1 + path-key: 3.1.1 + picocolors: 1.1.1 + readable-stream: 3.6.2 + require-directory: 2.1.1 + resolve.exports: 2.0.3 + restore-cursor: 3.1.0 + run-applescript: 7.0.0 + safe-buffer: 5.2.1 + semver: 7.8.5 + signal-exit: 3.0.7 + sisteransi: 1.0.5 + smol-toml: 1.9.0 + string-width: 4.2.3 + string_decoder: 1.3.0 + strip-ansi: 6.0.1 + strip-bom: 3.0.0 + supports-color: 7.2.0 + tar-stream: 2.2.0 + tmp: 0.2.7 + tsconfig-paths: 4.2.0 + tslib: 2.8.1 + undici: 7.29.0 + util-deprecate: 1.0.2 + wcwidth: 1.0.1 + which: 3.0.1 + wrap-ansi: 7.0.0 + wrappy: 1.0.2 + y18n: 5.0.8 + yaml: 2.9.0 + yargs: 17.7.2 + yargs-parser: 21.1.1 + optionalDependencies: + '@nx/nx-darwin-arm64': 23.3.0 + '@nx/nx-darwin-x64': 23.3.0 + '@nx/nx-freebsd-x64': 23.3.0 + '@nx/nx-linux-arm-gnueabihf': 23.3.0 + '@nx/nx-linux-arm64-gnu': 23.3.0 + '@nx/nx-linux-arm64-musl': 23.3.0 + '@nx/nx-linux-x64-gnu': 23.3.0 + '@nx/nx-linux-x64-musl': 23.3.0 + '@nx/nx-win32-arm64-msvc': 23.3.0 + '@nx/nx-win32-x64-msvc': 23.3.0 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + open@10.1.0: + dependencies: + default-browser: 5.2.1 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + is-wsl: 3.1.0 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.6.1 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + require-directory@2.1.1: {} + + resolve.exports@2.0.3: {} + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + run-applescript@7.0.0: {} + + safe-buffer@5.2.1: {} + + semver@7.8.5: {} + + signal-exit@3.0.7: {} + + sisteransi@1.0.5: {} + + smol-toml@1.9.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-bom@3.0.0: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tmp@0.2.7: {} + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + undici@7.29.0: {} + + util-deprecate@1.0.2: {} + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + which@3.0.1: + dependencies: + isexe: 2.0.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + y18n@5.0.8: {} + + yaml@2.9.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000000..d866d2a294 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,20 @@ +# Repo-root pnpm config for the nx affected-detection tooling (see package.json). +# +# This is NOT the web-app workspace: that one stays at frontend/pnpm-workspace.yaml +# (pnpm picks the nearest pnpm-workspace.yaml walking up from the cwd, so commands +# run under frontend/ keep using it and its own lockfile). This file deliberately +# declares no `packages`, so the root workspace contains only the root package. +# nx discovers frontend/viewer through frontend/viewer/project.json instead. + +# Same supply-chain policy as frontend/pnpm-workspace.yaml. +minimumReleaseAge: 1440 +engineStrict: true + +# Same store layout as the frontend workspace (shared across worktrees). +virtualStoreType: global + +# nx's postinstall only pre-warms its project-graph cache; pnpm 12 refuses to +# install until each build script is allowed or denied, and graph-only use +# does not need it (nx builds the cache on first run instead). +allowBuilds: + nx: false From 6aead473a765982b2ca6a4a396a439f98bbe0353 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 22 Oct 2026 12:33:57 +0700 Subject: [PATCH 02/12] Move CI escalation rules into the nx graph (ticket 06) The affected script no longer keeps any globs: shared files reach the viewer and the Platform.Bible extension through a never-run `ci-affected` target with `default` inputs, the extension becomes an nx project, and escalation tiers become `tier-` pseudo-projects under .github/ci-tiers/ with `inputs` globs (or `implicitDependencies`) as their rules. The script reports tier names as `tiers`, drops them when everything is affected, and computes `everything` from the full project list. Co-Authored-By: Claude Fable 5.1 --- .github/ci-tiers/README.md | 32 +++++++ .github/ci-tiers/android/project.json | 7 ++ .github/ci-tiers/apple/project.json | 7 ++ .github/ci-tiers/e2e/project.json | 17 ++++ .github/ci-tiers/full/project.json | 12 +++ .github/ci-tiers/maui-windows/project.json | 12 +++ .github/ci-tiers/slow-tests/project.json | 16 ++++ .github/ci-tiers/webkit/project.json | 7 ++ .github/scripts/affected.sh | 99 ++++++++-------------- frontend/viewer/project.json | 11 ++- nx.json | 13 ++- platform.bible-extension/project.json | 17 ++++ 12 files changed, 185 insertions(+), 65 deletions(-) create mode 100644 .github/ci-tiers/README.md create mode 100644 .github/ci-tiers/android/project.json create mode 100644 .github/ci-tiers/apple/project.json create mode 100644 .github/ci-tiers/e2e/project.json create mode 100644 .github/ci-tiers/full/project.json create mode 100644 .github/ci-tiers/maui-windows/project.json create mode 100644 .github/ci-tiers/slow-tests/project.json create mode 100644 .github/ci-tiers/webkit/project.json create mode 100644 platform.bible-extension/project.json diff --git a/.github/ci-tiers/README.md b/.github/ci-tiers/README.md new file mode 100644 index 0000000000..0e7e0fa5aa --- /dev/null +++ b/.github/ci-tiers/README.md @@ -0,0 +1,32 @@ +# CI escalation tiers + +Each folder here is an nx pseudo-project named `tier-`. It has no code and +runs nothing: it exists so that `nx show projects --affected` (via +`.github/scripts/affected.sh`) lists `tier-` when a pull request should +pull that tier's full-tier jobs into its PR run. The `ci:` label does the +same thing by hand, and `ci:full` sets every tier. + +A tier's rule is in its `project.json`, in one of two forms: + +- **Path rule (the default):** a `ci-affected` target whose `inputs` are + `{workspaceRoot}/...` globs. The tier fires when a file matching a glob + changes, and only then (an edit upstream of those paths does not fire it). + The target is never run; only its `inputs` are read. +- **Project rule:** `implicitDependencies` on project names. The tier fires when + any listed project is affected, **transitively**: nx walks the reverse graph, + so a change upstream of a listed project (e.g. `MiniLcm`, `LexCore`, the + analyzers) fires the tier too. Use it when that is what you want. + +Label-only tiers (`apple`, `android`, `webkit`) have an empty rule and fire only +by label. + +Two rules the script adds on top of the graph: + +- When a shared file marks everything affected (`nx.json`'s `sharedGlobals`), + the script reports no tiers: the whole PR tier runs, still Linux-only. Only the + PR workflow file itself escalates, to `full`. +- Editing a tier's `project.json` fires that tier (its own folder changed), so a + rule change proves itself on the PR that makes it. + +A missed escalation during the shadow phase becomes a one-line change to the +tier's `project.json`, reviewed like any other PR. diff --git a/.github/ci-tiers/android/project.json b/.github/ci-tiers/android/project.json new file mode 100644 index 0000000000..66c76d4ac8 --- /dev/null +++ b/.github/ci-tiers/android/project.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "name": "tier-android", + "tags": ["ci-tier"], + "_comment": "Escalation: Android build. Label-only (ci:android) by default; add implicitDependencies here to give it a path rule.", + "implicitDependencies": [] +} diff --git a/.github/ci-tiers/apple/project.json b/.github/ci-tiers/apple/project.json new file mode 100644 index 0000000000..8ff09bc89a --- /dev/null +++ b/.github/ci-tiers/apple/project.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "name": "tier-apple", + "tags": ["ci-tier"], + "_comment": "Escalation: iOS simulator + Mac Catalyst build. Label-only (ci:apple) by default; add implicitDependencies here to give it a path rule.", + "implicitDependencies": [] +} diff --git a/.github/ci-tiers/e2e/project.json b/.github/ci-tiers/e2e/project.json new file mode 100644 index 0000000000..e525edec65 --- /dev/null +++ b/.github/ci-tiers/e2e/project.json @@ -0,0 +1,17 @@ +{ + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "name": "tier-e2e", + "tags": ["ci-tier"], + "_comment": "Escalation: k8s-backed end-to-end tests. Path rule: a direct edit on sync or auth paths (first cut; tune during the shadow phase). (Label: ci:e2e.)", + "targets": { + "ci-affected": { + "executor": "nx:noop", + "inputs": [ + "{workspaceRoot}/backend/FwLite/FwLiteProjectSync/**/*", + "{workspaceRoot}/backend/FwLite/LcmCrdt/**/*", + "{workspaceRoot}/backend/FwLite/FwLiteShared/Auth/**/*", + "{workspaceRoot}/backend/FwLite/FwLiteShared/Sync/**/*" + ] + } + } +} diff --git a/.github/ci-tiers/full/project.json b/.github/ci-tiers/full/project.json new file mode 100644 index 0000000000..a5ae6b5d39 --- /dev/null +++ b/.github/ci-tiers/full/project.json @@ -0,0 +1,12 @@ +{ + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "name": "tier-full", + "tags": ["ci-tier"], + "_comment": "Escalation: every full-tier job. Path-only rule: fires when the PR workflow itself changes. The target is never run; only its inputs matter.", + "targets": { + "ci-affected": { + "executor": "nx:noop", + "inputs": ["{workspaceRoot}/.github/workflows/pr.yaml"] + } + } +} diff --git a/.github/ci-tiers/maui-windows/project.json b/.github/ci-tiers/maui-windows/project.json new file mode 100644 index 0000000000..fbfa1392d0 --- /dev/null +++ b/.github/ci-tiers/maui-windows/project.json @@ -0,0 +1,12 @@ +{ + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "name": "tier-maui-windows", + "tags": ["ci-tier"], + "_comment": "Escalation: Windows MAUI build + MAUI tests. Path rule: a direct edit under the MAUI projects. (Label: ci:maui-windows.)", + "targets": { + "ci-affected": { + "executor": "nx:noop", + "inputs": ["{workspaceRoot}/backend/FwLite/FwLiteMaui*/**/*"] + } + } +} diff --git a/.github/ci-tiers/slow-tests/project.json b/.github/ci-tiers/slow-tests/project.json new file mode 100644 index 0000000000..b8b5913862 --- /dev/null +++ b/.github/ci-tiers/slow-tests/project.json @@ -0,0 +1,16 @@ +{ + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "name": "tier-slow-tests", + "tags": ["ci-tier"], + "_comment": "Escalation: the Slow-trait .NET tests. Path rule: a direct edit in a sync project. (Label: ci:slow-tests.)", + "targets": { + "ci-affected": { + "executor": "nx:noop", + "inputs": [ + "{workspaceRoot}/backend/FwLite/FwLiteProjectSync/**/*", + "{workspaceRoot}/backend/FwLite/LcmCrdt/**/*", + "{workspaceRoot}/backend/FwLite/FwDataMiniLcmBridge/**/*" + ] + } + } +} diff --git a/.github/ci-tiers/webkit/project.json b/.github/ci-tiers/webkit/project.json new file mode 100644 index 0000000000..02cbcaae81 --- /dev/null +++ b/.github/ci-tiers/webkit/project.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "name": "tier-webkit", + "tags": ["ci-tier"], + "_comment": "Escalation: viewer Playwright tests on WebKit. Label-only (ci:webkit) by default; add implicitDependencies here to give it a path rule.", + "implicitDependencies": [] +} diff --git a/.github/scripts/affected.sh b/.github/scripts/affected.sh index c8cc8618d3..7c315e5704 100755 --- a/.github/scripts/affected.sh +++ b/.github/scripts/affected.sh @@ -4,10 +4,12 @@ # # { # "projects": [".NET project names affected, transitively, per nx's graph"], -# "viewer": true|false, // frontend/viewer affected (nx project "viewer") -# "extension": true|false, // platform.bible-extension/** changed -# "everything": true|false, // a shared file changed: run the whole PR tier -# "tiers": [], // escalation tiers from path rules (not yet) +# "viewer": true|false, // nx project "viewer" (frontend/viewer) affected +# "extension": true|false, // nx project "extension" (platform.bible-extension) affected +# "everything": true|false, // every real project is affected (a shared file changed): +# // run the whole PR tier +# "tiers": ["slow-tests", ...], // escalation tiers whose tier- pseudo-project +# // is affected (.github/ci-tiers//project.json) # "fallback": null | "reason" // set when detection failed; everything is then true # } # @@ -15,9 +17,9 @@ # to that head. Only committed changes count, like GitHub's three-dot PR diff. # Needs a full-depth checkout (fetch-depth: 0) and no token: everything is local git. # -# .NET names and the viewer boolean come from nx (`nx show projects --affected`, -# @nx/dotnet plugin, graph-only). This script keeps only the glob rules nx does -# not express: the shared files that mark everything affected, and the extension. +# Every rule lives in the nx graph (nx.json sharedGlobals, project.json files for +# the viewer, the extension and the tiers); this script only runs +# `nx show projects --affected` and shapes its answer. No globs here. # # Any failure still prints valid JSON with "everything": true and a "fallback" # reason, and exits 0, so a broken detector can only over-run CI, never skip it. @@ -29,27 +31,6 @@ cd "$(git rev-parse --show-toplevel 2>/dev/null || pwd)" || exit 0 base_ref="${AFFECTED_BASE:-origin/develop}" head_ref="${AFFECTED_HEAD:-HEAD}" -# Shared files: a change to any of these marks everything affected (map decision). -# Matched as bash globs against repo-relative paths. -everything_globs=( - 'global.json' - 'LexBox.slnx' - '*.slnf' - 'backend/Directory.Build.props' - 'backend/Directory.Build.targets' - 'backend/Directory.Packages.props' - 'backend/Harmony*.props' - 'frontend/pnpm-lock.yaml' - 'frontend/pnpm-workspace.yaml' - # The detector's own inputs: nx config and this script. - 'nx.json' - 'package.json' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' - '.github/scripts/affected.sh' -) -extension_globs=('platform.bible-extension/*') - fallback() { # $1: reason (kept to printable ASCII, quotes stripped, so it is safe inside JSON) local reason @@ -58,52 +39,46 @@ fallback() { exit 0 } -matches_any() { - # $1: path, rest: globs. `*` in a glob also crosses `/` here (pattern matching - # in [[ ]] does not treat `/` specially), so 'dir/*' means 'dir/**'. - local path=$1; shift - local g - for g in "$@"; do - # shellcheck disable=SC2053 - [[ $path == $g ]] && return 0 - done - return 1 +# nx prints its JSON array as the last stdout line; banners go to stderr. +nx_json() { + local out + out=$("$nx" "$@" --json 2>/dev/null) || return 1 + out=$(printf '%s\n' "$out" | grep '^\[' | tail -n 1) + [ -n "$out" ] || return 1 + printf '%s' "$out" } merge_base=$(git merge-base "$base_ref" "$head_ref" 2>&1) \ || fallback "git merge-base $base_ref $head_ref failed: $merge_base" -changed=$(git diff --name-only "$merge_base" "$head_ref" 2>&1) \ - || fallback "git diff $merge_base..$head_ref failed: $changed" - -everything=false -extension=false -while IFS= read -r f; do - [ -z "$f" ] && continue - matches_any "$f" "${everything_globs[@]}" && everything=true - matches_any "$f" "${extension_globs[@]}" && extension=true -done <<<"$changed" - nx=./node_modules/.bin/nx [ -x "$nx" ] || fallback "nx is not installed: run 'pnpm install' at the repo root" -# nx prints the JSON array as the last stdout line; banners go to stderr. -nx_out=$("$nx" show projects --affected --base="$merge_base" --head="$head_ref" --json 2>/dev/null) \ - || fallback "nx show projects --affected failed (exit $?)" -nx_json=$(printf '%s\n' "$nx_out" | grep '^\[' | tail -n 1) -[ -n "$nx_json" ] || fallback "nx printed no JSON array" +all=$(nx_json show projects) \ + || fallback "nx show projects failed" +affected=$(nx_json show projects --affected --base="$merge_base" --head="$head_ref") \ + || fallback "nx show projects --affected failed" # Assemble the result with node (nx already requires it), so names are escaped properly. -NX_JSON="$nx_json" EVERYTHING="$everything" EXTENSION="$extension" node -e ' - const names = JSON.parse(process.env.NX_JSON); - const viewer = names.includes("viewer"); - const projects = names.filter((n) => n !== "viewer").sort(); +ALL="$all" AFFECTED="$affected" node -e ' + const isTier = (n) => n.startsWith("tier-"); + const isSpecial = (n) => n === "viewer" || n === "extension"; + const all = JSON.parse(process.env.ALL); + const affected = JSON.parse(process.env.AFFECTED); + const real = (names) => names.filter((n) => !isTier(n)); + const projects = affected.filter((n) => !isTier(n) && !isSpecial(n)).sort(); + const everything = real(all).every((n) => affected.includes(n)); + // A shared-file change runs the whole PR tier, Linux-only: it never escalates, + // even though nx also marks tiers for files it treats as global (nx.json, lockfile). + const tiers = everything + ? [] + : affected.filter(isTier).map((n) => n.slice("tier-".length)).sort(); process.stdout.write(JSON.stringify({ projects, - viewer, - extension: process.env.EXTENSION === "true", - everything: process.env.EVERYTHING === "true", - tiers: [], + viewer: affected.includes("viewer"), + extension: affected.includes("extension"), + everything, + tiers, fallback: null, }) + "\n"); ' || fallback "could not assemble JSON from nx output" diff --git a/frontend/viewer/project.json b/frontend/viewer/project.json index 85c9782b28..decb3fe6f1 100644 --- a/frontend/viewer/project.json +++ b/frontend/viewer/project.json @@ -4,5 +4,14 @@ "projectType": "application", "sourceRoot": "frontend/viewer/src", "tags": ["pnpm"], - "_comment": "Registers the FW Lite viewer as an nx project for affected detection only. The viewer lives in the frontend/ pnpm workspace, which nx cannot see from the repo root, hence this file. No nx targets are defined; pnpm scripts stay the way to build and test it." + "_comment": "Registers the FW Lite viewer as an nx project for affected detection only. The viewer lives in the frontend/ pnpm workspace, which nx cannot see from the repo root, hence this file. pnpm scripts stay the way to build and test it; the one target below is never run.", + "targets": { + "ci-affected": { + "executor": "nx:noop", + "inputs": ["default"], + "metadata": { + "description": "Never run. Its `default` input (which includes nx.json's sharedGlobals) makes a shared-file change mark this project affected." + } + } + } } diff --git a/nx.json b/nx.json index 93e627c51c..b96a8a4327 100644 --- a/nx.json +++ b/nx.json @@ -1,7 +1,7 @@ { "$schema": "./node_modules/nx/schemas/nx-schema.json", "_comment": "nx is used graph-only here: `nx show projects --affected` answers which .NET projects and which pnpm packages a diff touches (see .github/scripts/affected.sh). Builds and tests still run through dotnet and pnpm; no nx targets are run and nothing is cached.", - "_sharedGlobals_comment": "namedInputs.sharedGlobals: a change to any of these marks every .NET project affected. @nx/dotnet's analyzer already declares them as per-project evaluation inputs (verified: same answer with the list removed); listing them keeps the rule explicit and independent of the plugin. They only reach projects whose targets use the `default` input, i.e. the .NET ones, not frontend/viewer: the frontend lockfile and other non-.NET shared files are handled by .github/scripts/affected.sh instead.", + "_sharedGlobals_comment": "namedInputs.sharedGlobals: a change to any of these marks every real project affected (the PR workflow then runs the whole PR tier). They reach a project only through a target whose `inputs` include `default`: @nx/dotnet gives every .NET project such targets (and its analyzer already lists the Directory.*/global.json files as evaluation inputs on its own); the viewer and the extension get one via the never-run `ci-affected` target in their project.json. Escalation tiers (.github/ci-tiers/*) deliberately have no such target, so shared files do not escalate.", "defaultBase": "develop", "plugins": [ { @@ -13,10 +13,19 @@ "default": ["{projectRoot}/**/*", "sharedGlobals"], "sharedGlobals": [ "{workspaceRoot}/global.json", + "{workspaceRoot}/LexBox.slnx", + "{workspaceRoot}/*.slnf", "{workspaceRoot}/backend/Directory.Build.props", "{workspaceRoot}/backend/Directory.Build.targets", "{workspaceRoot}/backend/Directory.Packages.props", - "{workspaceRoot}/backend/Harmony*.props" + "{workspaceRoot}/backend/Harmony*.props", + "{workspaceRoot}/frontend/pnpm-lock.yaml", + "{workspaceRoot}/frontend/pnpm-workspace.yaml", + "{workspaceRoot}/nx.json", + "{workspaceRoot}/package.json", + "{workspaceRoot}/pnpm-lock.yaml", + "{workspaceRoot}/pnpm-workspace.yaml", + "{workspaceRoot}/.github/scripts/affected.sh" ] } } diff --git a/platform.bible-extension/project.json b/platform.bible-extension/project.json new file mode 100644 index 0000000000..46bb41652e --- /dev/null +++ b/platform.bible-extension/project.json @@ -0,0 +1,17 @@ +{ + "$schema": "../node_modules/nx/schemas/project-schema.json", + "name": "extension", + "projectType": "application", + "sourceRoot": "platform.bible-extension/src", + "tags": ["npm"], + "_comment": "Registers the Platform.Bible extension as an nx project for affected detection only (the PR workflow's extension lint job keys on the name `extension`). npm scripts stay the way to build it; the one target below is never run.", + "targets": { + "ci-affected": { + "executor": "nx:noop", + "inputs": ["default"], + "metadata": { + "description": "Never run. Its `default` input (which includes nx.json's sharedGlobals) makes a shared-file change mark this project affected." + } + } + } +} From 31730593ee75119caf180d0982a4cdc465ed514d Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 22 Oct 2026 13:38:55 +0700 Subject: [PATCH 03/12] Add the PR CI workflow: affected-only Linux jobs with escalation tiers pr.yaml runs on every pull request to develop. `detect` asks the nx graph what the diff touches (.github/scripts/affected.sh, same answer as `task ci:affected`), merges `ci:` labels, and the other jobs run only for what is affected: .NET build of FwLiteCore.slnf with tests on the affected test projects (generated FwLiteAffectedTests.slnf, Category!=Slow), viewer checks and Playwright on Chromium, extension lint; slow-tests, maui-windows, apple, android and e2e are escalations. `PR CI result` is the summary check. Also: NX_DAEMON=false in CI, a root npm Dependabot group for nx, a VITEST_CHROMIUM_ONLY switch for the vitest browser projects, and develop-ui.yaml no longer triggers on viewer-only changes. fw-lite.yaml keeps running on PRs for the shadow phase. Co-Authored-By: Claude Fable 5.1 --- .github/dependabot.yml | 17 + .github/scripts/write-test-slnf.mjs | 16 + .github/workflows/develop-ui.yaml | 3 + .github/workflows/pr.yaml | 686 ++++++++++++++++++++++++++++ .gitignore | 3 + frontend/viewer/vitest.config.ts | 18 +- 6 files changed, 735 insertions(+), 8 deletions(-) create mode 100644 .github/scripts/write-test-slnf.mjs create mode 100644 .github/workflows/pr.yaml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 48af786bab..850f911f97 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -63,6 +63,23 @@ updates: development-dependencies: dependency-type: development + # --- Repo-root npm: nx + @nx/dotnet, used only for affected detection (pr.yaml) --- + - package-ecosystem: npm + directory: / + target-branch: develop + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 2 + labels: + - dependencies + groups: + # nx and its plugins must move together (exact pins, same version). + nx: + patterns: + - nx + - "@nx/*" + # --- GitHub Actions --- - package-ecosystem: github-actions # Workflows plus composite actions (.github/actions/*/action.y*ml) diff --git a/.github/scripts/write-test-slnf.mjs b/.github/scripts/write-test-slnf.mjs new file mode 100644 index 0000000000..15942bf261 --- /dev/null +++ b/.github/scripts/write-test-slnf.mjs @@ -0,0 +1,16 @@ +// Writes FwLiteAffectedTests.slnf at the repo root: the FW Lite test projects named in $TEST_PROJECTS +// (a JSON array of project names from pr.yaml's detect job), taken from FwLiteCore.slnf so the paths +// and the solution reference stay in one place. `dotnet test FwLiteAffectedTests.slnf --no-build` +// then runs exactly those projects, concurrently, after a whole-FwLiteCore.slnf build. +import {readFileSync, writeFileSync} from 'node:fs'; + +const wanted = new Set(JSON.parse(process.env.TEST_PROJECTS ?? '[]')); +const core = JSON.parse(readFileSync('FwLiteCore.slnf', 'utf8')); +const name = (p) => p.split('\\').pop().replace(/\.csproj$/, ''); +const projects = core.solution.projects.filter((p) => wanted.has(name(p))); +const missing = [...wanted].filter((w) => !projects.some((p) => name(p) === w)); +if (missing.length) throw new Error(`not in FwLiteCore.slnf: ${missing.join(', ')}`); +if (projects.length === 0) throw new Error('TEST_PROJECTS selected no test project'); + +writeFileSync('FwLiteAffectedTests.slnf', JSON.stringify({solution: {path: core.solution.path, projects}}, null, 2) + '\n'); +console.log(`FwLiteAffectedTests.slnf: ${projects.map(name).join(', ')}`); diff --git a/.github/workflows/develop-ui.yaml b/.github/workflows/develop-ui.yaml index 86d229fd88..b4bcf45325 100644 --- a/.github/workflows/develop-ui.yaml +++ b/.github/workflows/develop-ui.yaml @@ -3,6 +3,8 @@ on: push: paths: - 'frontend/**' + # The viewer (FW Lite) is covered by pr.yaml / fw-lite.yaml and ships via FwLiteShared/wwwroot, not the UI image. + - '!frontend/viewer/**' - '.github/workflows/develop-ui.yaml' - '.github/workflows/lexbox-ui.yaml' - '.github/workflows/deploy.yaml' @@ -11,6 +13,7 @@ on: pull_request: paths: - 'frontend/**' + - '!frontend/viewer/**' - '.github/workflows/develop-ui.yaml' - '.github/workflows/lexbox-ui.yaml' - '.github/workflows/deploy.yaml' diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml new file mode 100644 index 0000000000..f42fe61f85 --- /dev/null +++ b/.github/workflows/pr.yaml @@ -0,0 +1,686 @@ +name: PR CI +# The PR tier: Linux-only, affected-only checks for every pull request, under 10 minutes for a +# one-project change. `detect` asks the nx graph what the diff touches (.github/scripts/affected.sh, +# same answer as `task ci:affected` locally); the gated jobs run only for what is affected; +# escalation tiers (.github/ci-tiers/README.md) pull full-tier jobs in by path rule or by a +# `ci:` label (`ci:full` = all). `summary` is the one check to require at cut-over. +# Everything else (publishing, signing, stores, Firefox, WebKit by default) stays on develop in +# fw-lite.yaml and platform.bible-extension.yaml. +on: + pull_request: + branches: + - develop + # labeled/unlabeled so adding `ci:` re-runs at once; the concurrency group cancels the superseded run. + types: [opened, synchronize, reopened, labeled, unlabeled] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # nx's own CI default; also skips the daemon's "pull latest nx" network call on start. + NX_DAEMON: 'false' + VIEWER_BUILD_OUTPUT_DIR: backend/FwLite/FwLiteShared/wwwroot/viewer + # Same pin as fw-lite.yaml. + MAUI_WORKLOAD_VERSION: 10.0.401 + +jobs: + detect: + name: Detect affected projects + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + projects: ${{ steps.affected.outputs.projects }} + viewer: ${{ steps.affected.outputs.viewer }} + extension: ${{ steps.affected.outputs.extension }} + everything: ${{ steps.affected.outputs.everything }} + tiers: ${{ steps.affected.outputs.tiers }} + fallback: ${{ steps.affected.outputs.fallback }} + dotnet: ${{ steps.affected.outputs.dotnet }} + test-projects: ${{ steps.affected.outputs.test-projects }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Full history: the diff is merge-base(base branch, PR head)..PR head, computed locally + # with git alone, which also works for fork PRs (no token needed). + fetch-depth: 0 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + package_json_file: 'package.json' + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: './package.json' + cache: 'pnpm' + cache-dependency-path: './pnpm-lock.yaml' + - name: Install nx (repo root) + run: pnpm install --frozen-lockfile + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + # @nx/dotnet evaluates every csproj with MSBuild to build the graph. + dotnet-version: '10.x' + + - name: Compute affected projects and tiers + id: affected + env: + AFFECTED_BASE: origin/${{ github.base_ref }} + AFFECTED_HEAD: ${{ github.event.pull_request.head.sha }} + PR_LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + run: | + set -euo pipefail + result="$(bash .github/scripts/affected.sh)" + echo "affected.sh: $result" + RESULT="$result" node - <<'EOF' + const fs = require('node:fs'); + const r = JSON.parse(process.env.RESULT); + const labels = JSON.parse(process.env.PR_LABELS || '[]'); + const tierNames = fs.readdirSync('.github/ci-tiers', {withFileTypes: true}) + .filter((d) => d.isDirectory()).map((d) => d.name); + // Labels union with the graph's tiers; `full` (label or the PR workflow file) means all of them. + const tiers = new Set(r.tiers); + for (const label of labels) { + if (label === 'ci:full') tiers.add('full'); + else if (label.startsWith('ci:')) tiers.add(label.slice(3)); + } + let everything = r.everything; + if (tiers.has('full')) { everything = true; tierNames.forEach((t) => tiers.add(t)); } + // FW Lite projects = FwLiteCore.slnf; the dotnet job builds it whole and tests the affected test projects. + const core = JSON.parse(fs.readFileSync('FwLiteCore.slnf', 'utf8')).solution.projects + .map((p) => p.split('\\').pop().replace(/\.csproj$/, '')); + const coreTests = core.filter((p) => p.endsWith('.Tests')); + const dotnet = everything || r.projects.some((p) => core.includes(p)); + let testProjects = everything ? coreTests : r.projects.filter((p) => coreTests.includes(p)); + if (dotnet && testProjects.length === 0) testProjects = coreTests; // e.g. only a leaf library changed + const out = { + projects: JSON.stringify(r.projects), + viewer: String(r.viewer || everything), + extension: String(r.extension || everything), + everything: String(everything), + tiers: JSON.stringify([...tiers].sort()), + fallback: r.fallback ?? '', + dotnet: String(dotnet), + 'test-projects': JSON.stringify(testProjects), + }; + fs.appendFileSync(process.env.GITHUB_OUTPUT, Object.entries(out).map(([k, v]) => `${k}=${v}\n`).join('')); + if (r.fallback) console.log(`::warning::affected detection fell back to everything: ${r.fallback}`); + const rows = Object.entries(out).map(([k, v]) => `| ${k} | \`${v}\` |`).join('\n'); + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `### Affected\n\n| key | value |\n|---|---|\n${rows}\n\nLabels: \`${labels.join(', ')}\`\n`); + console.log(out); + EOF + + dotnet: + name: .NET build and tests (affected) + needs: detect + if: needs.detect.outputs.dotnet == 'true' + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.x' + - name: Cache NuGet packages + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('backend/Directory.Packages.props', 'backend/Harmony*.props') }} + restore-keys: | + nuget-${{ runner.os }}- + - name: Install Task + uses: arduino/setup-task@c0bc642852239c2689f73f4ea6459c29405f3c52 # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Dotnet build + # Whole FwLiteCore.slnf (~1 min): keeps the two drift checks below unconditional. Tests are the affected subset. + run: dotnet build FwLiteCore.slnf -p:BuildAndroid=false + + - name: Check for pending EF model changes + run: task fw-lite:has-pending-model-changes -- --no-build + + - name: Check for stale generated TypeScript types + run: task fw-lite:has-stale-generated-types -- --no-build + + - name: Select affected test projects + env: + TEST_PROJECTS: ${{ needs.detect.outputs.test-projects }} + run: node .github/scripts/write-test-slnf.mjs + + - name: Dotnet test (PR tier) + # Slow and Benchmark run elsewhere: the slow-tests job (same selection, Category=Slow) and develop. + # TZ forces a non-UTC timezone so date bugs are observable (see fw-lite.yaml). + env: + TZ: America/New_York + run: dotnet test FwLiteAffectedTests.slnf --logger GitHubActions --logger trx --results-directory "${{ github.workspace }}/TestResults" --no-build -p:BuildAndroid=false --filter "Category!=Slow&Category!=Benchmark" + + - name: Publish test results to TestHistory + if: always() + continue-on-error: true # telemetry only — a TestHistory outage must not fail the build + uses: hahn-kev/TestHistory/.github/actions/upload-results@e5fc439cd7644a145c7553bc3b0077aa643aef8d # master + with: + server-url: ${{ vars.TESTHISTORY_SERVER_URL }} + project-id: ${{ vars.TESTHISTORY_FWLITE_PROJECT_ID }} + api-token: ${{ secrets.TESTHISTORY_API_TOKEN }} + files: TestResults/**/*.trx + label: FW Lite .NET + on-no-files: ignore # build may fail before any tests run + + slow-tests: + name: .NET slow tests (tier slow-tests) + needs: detect + if: contains(fromJSON(needs.detect.outputs.tiers), 'slow-tests') + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.x' + - name: Cache NuGet packages + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('backend/Directory.Packages.props', 'backend/Harmony*.props') }} + restore-keys: | + nuget-${{ runner.os }}- + + - name: Dotnet build + run: dotnet build FwLiteCore.slnf -p:BuildAndroid=false + + - name: Select affected test projects + env: + TEST_PROJECTS: ${{ needs.detect.outputs.test-projects }} + run: node .github/scripts/write-test-slnf.mjs + + - name: Dotnet test (Category=Slow) + env: + TZ: America/New_York + run: dotnet test FwLiteAffectedTests.slnf --logger GitHubActions --logger trx --results-directory "${{ github.workspace }}/TestResults" --no-build -p:BuildAndroid=false --filter "Category=Slow" + + - name: Publish test results to TestHistory + if: always() + continue-on-error: true # telemetry only + uses: hahn-kev/TestHistory/.github/actions/upload-results@e5fc439cd7644a145c7553bc3b0077aa643aef8d # master + with: + server-url: ${{ vars.TESTHISTORY_SERVER_URL }} + project-id: ${{ vars.TESTHISTORY_FWLITE_PROJECT_ID }} + api-token: ${{ secrets.TESTHISTORY_API_TOKEN }} + files: TestResults/**/*.trx + label: FW Lite .NET slow + on-no-files: ignore + + viewer-checks: + name: Viewer checks (svelte-check, eslint, prettier, i18n, vitest, build) + needs: detect + if: needs.detect.outputs.viewer == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + checks: write # publish-unit-test-result-action + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + package_json_file: 'frontend/package.json' + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: './frontend/package.json' + cache: 'pnpm' + cache-dependency-path: './frontend/pnpm-lock.yaml' + - name: Prepare frontend + working-directory: frontend + run: pnpm install + + - name: Ensure all i18n strings have been extracted + working-directory: frontend/viewer + shell: bash + run: | + set -e + # GHA sets -o pipefail when shell is bash but we want to turn it off + set +o pipefail + rm -f src/locales/en.po.old + cp src/locales/en.po src/locales/en.po.old + pnpm i18n:extract + DIFFCOUNT=$(diff -u src/locales/en.po{.old,} | grep '+msgid' | wc -l) + if [ $DIFFCOUNT -gt 0 ]; then + # Unextracted strings found, fail the build + echo '***' + echo $DIFFCOUNT unextracted strings found: + echo '***' + diff -u src/locales/en.po{.old,} | grep '+msgid' | cut -c2- + echo '***' + echo Please run pnpm i18n:extract + exit 1 + fi + rm -f src/locales/en.po.old + + - name: svelte-check (viewer) + working-directory: frontend/viewer + run: pnpm run check + - name: eslint (viewer) + working-directory: frontend/viewer + run: pnpm run lint + - name: Check code formatting with Prettier + working-directory: frontend/viewer + run: pnpm run format:check + + # vitest's browser and storybook projects run under Playwright; Chromium only on the PR tier. + - name: Cache Playwright browsers + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('frontend/pnpm-lock.yaml') }} + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + working-directory: frontend/viewer + run: pnpm exec playwright install --with-deps chromium + - name: Install Playwright system deps + if: steps.playwright-cache.outputs.cache-hit == 'true' + working-directory: frontend/viewer + run: pnpm exec playwright install-deps chromium + - name: vitest (unit, browser, storybook; Chromium only) + working-directory: frontend/viewer + env: + VITEST_CHROMIUM_ONLY: 'true' + run: pnpm run test --retry=2 --reporter=default --reporter=junit --outputFile.junit=test-results/vitest-results.xml + - name: Publish unit test results + uses: EnricoMi/publish-unit-test-result-action@d0a4676d0e0b938bc201470d88276b7c74c712b3 # v2.24.0 + if: always() + with: + check_name: UI unit Tests (PR tier) + files: ./frontend/viewer/test-results/*.xml + action_fail_on_inconclusive: true + comment_mode: off + - name: Publish test results to TestHistory + if: always() + continue-on-error: true # telemetry only — a TestHistory outage must not fail the build + uses: hahn-kev/TestHistory/.github/actions/upload-results@e5fc439cd7644a145c7553bc3b0077aa643aef8d # master + with: + server-url: ${{ vars.TESTHISTORY_SERVER_URL }} + project-id: ${{ vars.TESTHISTORY_FWLITE_PROJECT_ID }} + api-token: ${{ secrets.TESTHISTORY_API_TOKEN }} + files: frontend/viewer/test-results/vitest-results.xml + label: FW Lite Viewer unit + on-no-files: ignore + + - name: Build viewer + working-directory: frontend/viewer + run: pnpm run build + + viewer-ui-tests: + name: Viewer Playwright UI tests + needs: detect + if: needs.detect.outputs.viewer == 'true' + runs-on: ubuntu-latest + timeout-minutes: 25 + env: + # Chromium only on the PR tier; `ci:webkit` (tier webkit) adds WebKit. + WEBKIT: ${{ contains(fromJSON(needs.detect.outputs.tiers), 'webkit') }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + package_json_file: 'frontend/package.json' + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: './frontend/package.json' + cache: 'pnpm' + cache-dependency-path: './frontend/pnpm-lock.yaml' + - name: Install Task + uses: arduino/setup-task@c0bc642852239c2689f73f4ea6459c29405f3c52 # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Prepare frontend + working-directory: frontend + run: pnpm install + - name: Pick browsers + id: browsers + run: | + if [ "$WEBKIT" = 'true' ]; then + echo "list=chromium webkit" >> "$GITHUB_OUTPUT" + echo "args=--project=chromium --project=webkit" >> "$GITHUB_OUTPUT" + echo "key=chromium-webkit" >> "$GITHUB_OUTPUT" + else + echo "list=chromium" >> "$GITHUB_OUTPUT" + echo "args=--project=chromium" >> "$GITHUB_OUTPUT" + echo "key=chromium" >> "$GITHUB_OUTPUT" + fi + - name: Cache Playwright browsers + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ steps.browsers.outputs.key }}-${{ hashFiles('frontend/pnpm-lock.yaml') }} + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + working-directory: frontend + run: pnpm exec playwright install --with-deps ${{ steps.browsers.outputs.list }} + - name: Install Playwright system deps + if: steps.playwright-cache.outputs.cache-hit == 'true' + working-directory: frontend + run: pnpm exec playwright install-deps ${{ steps.browsers.outputs.list }} + - name: Run UI tests + working-directory: frontend/viewer + run: task test:ui-standalone -- ${{ steps.browsers.outputs.args }} + - name: Publish test results to TestHistory + if: always() # run even when the UI tests above failed + continue-on-error: true # telemetry only — a TestHistory outage must not fail the build + uses: hahn-kev/TestHistory/.github/actions/upload-results@e5fc439cd7644a145c7553bc3b0077aa643aef8d # master + with: + server-url: ${{ vars.TESTHISTORY_SERVER_URL }} + project-id: ${{ vars.TESTHISTORY_FWLITE_PROJECT_ID }} + api-token: ${{ secrets.TESTHISTORY_API_TOKEN }} + files: frontend/viewer/tests/ui/test-results/results.xml + label: FW Lite Viewer UI + on-no-files: ignore + + extension-lint: + name: Platform.Bible extension lint + needs: detect + if: needs.detect.outputs.extension == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + # Copied from platform.bible-extension.yaml (which keeps running on develop pushes). + steps: + - name: Checkout git repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: lexbox + persist-credentials: false + + - name: Checkout paranext-core repo to use its sub-packages + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: paranext-core + repository: paranext/paranext-core + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + cache: "npm" + cache-dependency-path: | + lexbox/platform.bible-extension/package-lock.json + paranext-core/package-lock.json + node-version-file: lexbox/platform.bible-extension/package.json + + - name: Install extension dependencies + working-directory: lexbox/platform.bible-extension + run: npm ci --ignore-scripts + + # --ignore-scripts skips core's preinstall (stages the gitignored dev-packages/staging/ that + # platform-bible-react imports) and postinstall (verify), so both run as explicit steps. + - name: Stage core's dev packages + working-directory: paranext-core + run: npm run stage-dev-packages + + - name: Install core dependencies + working-directory: paranext-core + run: npm ci --ignore-scripts + + - name: Verify core's staged dev packages match its lockfile + working-directory: paranext-core + run: npm run verify:dev-packages + + - name: Run format checking + working-directory: lexbox/platform.bible-extension + run: npm run format:check + + - name: Run all linters + working-directory: lexbox/platform.bible-extension + run: npm run lint + + # ---- Escalations: full-tier jobs pulled in by a tier (path rule or `ci:` label). Compile and + # ---- test only: no packaging, signing or store uploads, which stay on develop in fw-lite.yaml. + + maui-windows: + name: MAUI Windows build and tests (tier maui-windows) + needs: detect + if: contains(fromJSON(needs.detect.outputs.tiers), 'maui-windows') + runs-on: windows-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.x' + - name: Cache NuGet packages + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('backend/Directory.Packages.props', 'backend/Harmony*.props') }} + restore-keys: | + nuget-${{ runner.os }}- + - name: Setup Maui + run: dotnet workload install maui-windows --version ${{ env.MAUI_WORKLOAD_VERSION }} + + - name: Build MAUI tests + run: dotnet build backend/FwLite/FwLiteMaui.Tests/FwLiteMaui.Tests.csproj -p:BuildAndroid=false + + - name: Test MAUI + run: dotnet test backend/FwLite/FwLiteMaui.Tests/FwLiteMaui.Tests.csproj --logger GitHubActions --logger trx --results-directory "${{ github.workspace }}/TestResults" --no-build -p:BuildAndroid=false + + - name: Publish MAUI test results to TestHistory + if: always() + continue-on-error: true # telemetry only + uses: hahn-kev/TestHistory/.github/actions/upload-results@e5fc439cd7644a145c7553bc3b0077aa643aef8d # master + with: + server-url: ${{ vars.TESTHISTORY_SERVER_URL }} + project-id: ${{ vars.TESTHISTORY_FWLITE_PROJECT_ID }} + api-token: ${{ secrets.TESTHISTORY_API_TOKEN }} + files: TestResults/**/*.trx + label: FW Lite MAUI Windows + on-no-files: ignore + + apple: + name: iOS simulator and Mac Catalyst builds (tier apple) + needs: detect + if: contains(fromJSON(needs.detect.outputs.tiers), 'apple') + runs-on: macos-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.x' + - name: Setup Maui + run: dotnet workload install maui-ios maui-maccatalyst --version ${{ env.MAUI_WORKLOAD_VERSION }} + + # Same builds as fw-lite.yaml's build-apple, unsigned, no DMG or notarization. + - name: Build iOS (simulator) + working-directory: backend/FwLite/FwLiteMaui + run: dotnet build -f net10.0-ios -c Release -p:BuildAndroid=false -p:RuntimeIdentifier=iossimulator-arm64 + + - name: Build Mac Catalyst (unsigned) + working-directory: backend/FwLite/FwLiteMaui + run: dotnet build -f net10.0-maccatalyst -c Release -p:BuildAndroid=false -p:RuntimeIdentifier=maccatalyst-arm64 + + android: + name: Android build (tier android) + needs: detect + if: contains(fromJSON(needs.detect.outputs.tiers), 'android') + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.x' + - name: Setup Maui + run: dotnet workload install maui-android --version ${{ env.MAUI_WORKLOAD_VERSION }} + + - name: Publish Android (unsigned) + working-directory: backend/FwLite/FwLiteMaui + run: dotnet publish -f net10.0-android -p:BuildApple=false --artifacts-path ../artifacts -p:AndroidKeyStore=false + + e2e: + name: E2E tests on a kind cluster (tier e2e) + needs: detect + if: contains(fromJSON(needs.detect.outputs.tiers), 'e2e') + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + FW_LITE_BINARY_PATH: ${{ github.workspace }}/backend/FwLite/artifacts/publish/FwLiteWeb/release_linux-x64/FwLiteWeb + # Copied from fw-lite.yaml's e2e-test, except the viewer is built here instead of downloaded. + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: true + - name: Install Task + uses: arduino/setup-task@c0bc642852239c2689f73f4ea6459c29405f3c52 # v3.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + package_json_file: 'frontend/package.json' + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: './frontend/package.json' + cache: 'pnpm' + cache-dependency-path: './frontend/pnpm-lock.yaml' + - name: Install frontend deps + working-directory: frontend + run: pnpm install + - name: Build viewer + working-directory: frontend/viewer + run: pnpm run build + + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.x' + - name: Publish FwLiteWeb (linux-x64) + working-directory: backend/FwLite/FwLiteWeb + run: dotnet publish -r linux-x64 --artifacts-path ../artifacts -p:PublishSingleFile=true + - name: set execute permissions + shell: bash + run: chmod +x ${{ env.FW_LITE_BINARY_PATH }} + + - uses: ./.github/actions/setup-k8s + with: + lexbox-api-tag: develop + ingress-controller-port: '6579' # http; only used by the action's own curl-verify step + repo-token: ${{ secrets.GITHUB_TOKEN }} + + # MSAL refuses any non-https authority. ingress-nginx serves a snake-oil + # cert that FwLite trusts via --environment Development and playwright via + # ignoreHTTPSErrors. + - name: Forward ingress HTTPS port + shell: bash + run: | + kubectl port-forward service/ingress-nginx-controller 6580:443 -n languagedepot >/tmp/https-port-forward.log 2>&1 & + # Wait for the port-forward to actually be listening + for i in $(seq 1 30); do + if curl -sk https://localhost:6580 >/dev/null 2>&1; then + echo "✅ HTTPS port-forward ready on :6580" + exit 0 + fi + sleep 1 + done + echo "❌ HTTPS port-forward did not become ready" + cat /tmp/https-port-forward.log + exit 1 + + - name: Cache Playwright browsers + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('frontend/pnpm-lock.yaml') }} + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' + working-directory: frontend/viewer + run: pnpm exec playwright install --with-deps chromium + - name: Install Playwright system deps + if: steps.playwright-cache.outputs.cache-hit == 'true' + working-directory: frontend/viewer + run: pnpm exec playwright install-deps chromium + + - name: Run E2E tests + working-directory: frontend/viewer + env: + TEST_SERVER_PROTOCOL: https + TEST_SERVER_PORT: 6580 + run: task test:e2e + - name: Publish E2E test results to TestHistory + if: always() # run even when the E2E tests above failed + continue-on-error: true # telemetry only — a TestHistory outage must not fail the build + uses: hahn-kev/TestHistory/.github/actions/upload-results@e5fc439cd7644a145c7553bc3b0077aa643aef8d # master + with: + server-url: ${{ vars.TESTHISTORY_SERVER_URL }} + project-id: ${{ vars.TESTHISTORY_FWLITE_PROJECT_ID }} + api-token: ${{ secrets.TESTHISTORY_API_TOKEN }} + files: frontend/viewer/tests/e2e/test-results/e2e-results.xml + label: FW Lite E2E + on-no-files: ignore + + - name: Upload Playwright results/traces (on failure) + if: failure() || cancelled() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: pr-e2e-test-results + if-no-files-found: ignore + path: frontend/viewer/tests/e2e/test-results/ + + - name: Capture k8s pod logs (on failure) + if: failure() || cancelled() + shell: bash + run: | + mkdir -p k8s-logs + for app in lexbox ui hg db fw-headless; do + kubectl describe pods -l "app=${app}" -n languagedepot > k8s-logs/describe-${app}.txt 2>&1 || true + kubectl logs -l "app=${app}" -n languagedepot --prefix --all-containers --tail=-1 > k8s-logs/logs-${app}.txt 2>&1 || true + done + kubectl logs -l 'app.kubernetes.io/name=ingress-nginx' -n languagedepot --prefix --all-containers --tail=-1 > k8s-logs/logs-ingress.txt 2>&1 || true + + - name: Upload k8s logs (on failure) + if: failure() || cancelled() + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: pr-e2e-k8s-logs + if-no-files-found: ignore + path: k8s-logs/*.txt + + summary: + # The one check to mark required at cut-over. A skipped job (not affected, tier off) counts as passed. + name: PR CI result + if: always() + needs: [detect, dotnet, slow-tests, viewer-checks, viewer-ui-tests, extension-lint, maui-windows, apple, android, e2e] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check job results + env: + NEEDS: ${{ toJSON(needs) }} + DETECT_PROJECTS: ${{ needs.detect.outputs.projects }} + DETECT_TIERS: ${{ needs.detect.outputs.tiers }} + DETECT_EVERYTHING: ${{ needs.detect.outputs.everything }} + DETECT_FALLBACK: ${{ needs.detect.outputs.fallback }} + run: | + { + echo "### PR CI result" + echo + echo "| job | result |" + echo "|---|---|" + echo "$NEEDS" | jq -r 'to_entries[] | "| \(.key) | \(.value.result) |"' + echo + echo "Affected .NET projects: \`$DETECT_PROJECTS\`; tiers: \`$DETECT_TIERS\`; everything: \`$DETECT_EVERYTHING\`; fallback: \`${DETECT_FALLBACK:-none}\`" + } >> "$GITHUB_STEP_SUMMARY" + failed=$(echo "$NEEDS" | jq -r 'to_entries[] | select(.value.result == "failure" or .value.result == "cancelled") | .key' | paste -sd ' ') + if [ -n "$failed" ]; then + echo "::error::Failed or cancelled jobs: $failed" + exit 1 + fi + echo "All jobs passed or were skipped as not affected." diff --git a/.gitignore b/.gitignore index f56638f16c..6e8ca694cd 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ screenshots/ # nx (affected detection only): cache/workspace-data and the root node_modules .nx/ /node_modules/ + +# Generated by pr.yaml (affected FW Lite test projects) +/FwLiteAffectedTests.slnf diff --git a/frontend/viewer/vitest.config.ts b/frontend/viewer/vitest.config.ts index 7c1568530b..9cb2cb692e 100644 --- a/frontend/viewer/vitest.config.ts +++ b/frontend/viewer/vitest.config.ts @@ -12,6 +12,14 @@ const launcherTestPattern = './tests/launcher/**/*.{test,spec}.?(c|m)[jt]s?(x)'; const manualTestPattern = '**/*.manual.{test,spec}.?(c|m)[jt]s?(x)'; const e2eTestPatterns = ['./tests/**']; +// The PR workflow (pr.yaml) sets VITEST_CHROMIUM_ONLY=true so the browser-based projects run on +// Chromium alone; locally and on develop (fw-lite.yaml) Firefox runs too. +// A function, not a shared array: vitest rejects instance objects reused across projects as duplicates. +const browserInstances = () => + process.env.VITEST_CHROMIUM_ONLY === 'true' + ? [{browser: 'chromium'}] + : [{browser: 'chromium'}, {browser: 'firefox'}]; + const sharedAlias = [ {find: '$lib', replacement: '/src/lib'}, {find: '$project', replacement: '/src/project'}, @@ -64,10 +72,7 @@ export default defineConfig({ enabled: true, headless: true, provider: playwright(), - instances: [ - {browser: 'chromium'}, - {browser: 'firefox'}, - ], + instances: browserInstances(), }, include: [browserTestPattern], exclude: [...e2eTestPatterns, ...configDefaults.exclude], @@ -97,10 +102,7 @@ export default defineConfig({ enabled: true, headless: true, provider: playwright(), - instances: [ - {browser: 'chromium'}, - {browser: 'firefox'}, - ], + instances: browserInstances(), }, setupFiles: ['./.storybook/vitest.setup.ts'], }, From 78fe5a03dc5cbc7aef8a7135ab6f7e727b214389 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 22 Oct 2026 13:39:07 +0700 Subject: [PATCH 04/12] Tag the slow FW Lite tests and fix task fw-lite:test-verified Category=Slow now covers Sena3SyncTests (class level, replacing its two method tags), both CanSyncRandomEntries theories, BulkCreateEntriesPerformance and GetEntryIndex_LargeList_ReturnsCorrectIndex: about 800 of the 1430 summed seconds in the two slowest assemblies. The PR tier filters them out and the slow-tests job runs them in parallel. task fw-lite:test-verified used FwLiteOnly.slnf with --framework net10.0, which the MAUI test project rejects, so it always exited 1; it now runs FwLiteCore.slnf without the framework switch. Co-Authored-By: Claude Fable 5.1 --- backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs | 1 + backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs | 5 +++-- backend/FwLite/LcmCrdt.Tests/BulkCreateEntriesTest.cs | 1 + backend/FwLite/MiniLcm.Tests/EntryIndexTestsBase.cs | 1 + backend/FwLite/Taskfile.yml | 5 +++-- 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs index 671b8cc465..d674e8e7d4 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs @@ -253,6 +253,7 @@ public enum ApiType // Api == FwDataApi and RoundTripApi == CrdtApi // (though, as noted above, this case doesn't perfectly reflect real usage) [Theory] + [Trait("Category", "Slow")] // 19-35 s per row in both derived classes; PR tier skips Category=Slow [InlineData(ApiType.Crdt)] [InlineData(ApiType.FwData)] [InlineData(null)] diff --git a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs index 80a20b731a..dc98c31b03 100644 --- a/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs +++ b/backend/FwLite/FwLiteProjectSync.Tests/Sena3SyncTests.cs @@ -13,6 +13,9 @@ namespace FwLiteProjectSync.Tests; [Trait("Category", "Integration")] +// Slow: every test here syncs the sample project (65-123 s each); the PR tier skips Category=Slow +// and the slow-tests job or develop runs them. +[Trait("Category", "Slow")] [Collection(Sena3Collection.Name)] public class Sena3SyncTests : IAsyncLifetime { @@ -136,7 +139,6 @@ public async Task DryRunSync_MakesNoChanges() } [Fact] - [Trait("Category", "Slow")] [Trait("Category", "Integration")] public async Task DryRunSync_MakesTheSameChangesAsSync() { @@ -173,7 +175,6 @@ await _crdtApi.TakeProjectSnapshot() } [Fact] - [Trait("Category", "Slow")] [Trait("Category", "Integration")] public async Task SyncWithoutImport_CrdtShouldMatchFwdata() { diff --git a/backend/FwLite/LcmCrdt.Tests/BulkCreateEntriesTest.cs b/backend/FwLite/LcmCrdt.Tests/BulkCreateEntriesTest.cs index 58c2fe9a68..e99277e540 100644 --- a/backend/FwLite/LcmCrdt.Tests/BulkCreateEntriesTest.cs +++ b/backend/FwLite/LcmCrdt.Tests/BulkCreateEntriesTest.cs @@ -98,6 +98,7 @@ private async IAsyncEnumerable SeedData(int entryCount, IMiniLcmApi api) } [Fact] + [Trait("Category", "Slow")] // ~150 s; PR tier skips Category=Slow public async Task BulkCreateEntriesPerformance() { var entryCount = 20_000; diff --git a/backend/FwLite/MiniLcm.Tests/EntryIndexTestsBase.cs b/backend/FwLite/MiniLcm.Tests/EntryIndexTestsBase.cs index e994947c65..64b2719bbc 100644 --- a/backend/FwLite/MiniLcm.Tests/EntryIndexTestsBase.cs +++ b/backend/FwLite/MiniLcm.Tests/EntryIndexTestsBase.cs @@ -79,6 +79,7 @@ public async Task GetEntryIndex_WithCustomSort_ReturnsCorrectIndex() } [Fact] + [Trait("Category", "Slow")] // ~90 s on the CRDT side; PR tier skips Category=Slow public async Task GetEntryIndex_LargeList_ReturnsCorrectIndex() { var entryIds = new List(); diff --git a/backend/FwLite/Taskfile.yml b/backend/FwLite/Taskfile.yml index 9ac9a14703..70111a784c 100644 --- a/backend/FwLite/Taskfile.yml +++ b/backend/FwLite/Taskfile.yml @@ -206,8 +206,9 @@ tasks: cmd: dotnet test ../../FwLiteOnly.slnf test-verified: desc: Run only tests that generate verified snapshot files - # always produces exit status 1, because the framework isn't valid for all test projects - cmd: dotnet test ../../FwLiteOnly.slnf --filter "Category=Verified" --framework net10.0 --logger "console;verbosity=minimal" + # FwLiteCore.slnf, not FwLiteOnly.slnf: the MAUI test project has no net10.0 target and no Verified + # tests, and `--framework net10.0` made the whole run exit 1 for it. + cmd: dotnet test ../../FwLiteCore.slnf --filter "Category=Verified" --logger "console;verbosity=minimal" reset-verified: desc: Reset all verified snapshot files to their state on origin/develop cmds: From 7cc261eb102a65ac037edd5e4410a7722ab5d375 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 22 Oct 2026 13:39:10 +0700 Subject: [PATCH 05/12] Document the PR tier in .github/AGENTS.md Workflow tables gain pr.yaml, develop-ui.yaml's narrowed trigger is noted, and a PR tier section explains affected detection, the .NET test selection, the viewer jobs, the escalation tiers and labels, and why dotnet-affected lost the bake-off. Co-Authored-By: Claude Fable 5.1 --- .github/AGENTS.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 7fdee07130..2dbf8bde5c 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -16,7 +16,8 @@ GitHub Actions workflows and deployment. Full runs take 30-60+ min across Linux, | Workflow | Triggers | What it does | Time | |----------|----------|--------------|------| -| `fw-lite.yaml` | FwLite code changes | Build .NET, run tests, build viewer, publish apps | ~40 min | +| `pr.yaml` (`PR CI`) | Every PR to develop (also `labeled`/`unlabeled`) | The **PR tier**: detects affected projects with nx, then runs only the affected Linux jobs (.NET build + affected tests, viewer checks, viewer Playwright on Chromium, extension lint); escalation tiers add full-tier jobs. See [PR tier](#pr-tier-pryaml) | target < 10 min | +| `fw-lite.yaml` | FwLite code changes | Build .NET, run tests, build viewer, publish apps (the **full tier**; still on PRs during the shadow phase) | ~40 min | | `lexbox-api.yaml` | Called by others | Build API, run unit tests, build Docker image | ~15 min | | `lexbox-ui.yaml` | Called by others | Build SvelteKit UI, build Docker image | ~10 min | | `lexbox-fw-headless.yaml` | Called by others | Build FwHeadless Docker image | ~10 min | @@ -37,7 +38,7 @@ GitHub Actions workflows and deployment. Full runs take 30-60+ min across Linux, | Workflow | Purpose | |----------|---------| | `develop-api.yaml` | Quick API build for PRs | -| `develop-ui.yaml` | Quick UI build for PRs | +| `develop-ui.yaml` | Quick UI build for PRs (`frontend/**` except the viewer, which `pr.yaml` covers) | | `develop-fw-headless.yaml` | Quick FwHeadless build for PRs | --- @@ -107,6 +108,17 @@ flowchart TD - Publishes standalone apps, not Docker images - Does NOT deploy to K8s +### PR tier (`pr.yaml`) + +`PR CI` is the trimmed, Linux-only job set every pull request runs; `fw-lite.yaml` is the full tier on develop (and, during the shadow phase, still on PRs too). Its summary job `PR CI result` is the check to require at cut-over: a job skipped as "not affected" counts as passed. + +- **Affected detection** is the nx graph (`nx` + `@nx/dotnet` at the repo root, graph-only: nothing builds or caches through nx). `.github/scripts/affected.sh` prints one JSON object (`projects`, `viewer`, `extension`, `everything`, `tiers`, `fallback`); `task ci:affected` runs the same script locally against `origin/develop` (`AFFECTED_BASE=` to override) and must give the same answer as the `detect` job. Any detector failure falls back to everything affected with a warning annotation. Rules live in the graph: `nx.json` `sharedGlobals` lists the shared files (props, `global.json`, solution files, lockfiles, nx config) that mark everything affected; `frontend/viewer/project.json` and `platform.bible-extension/project.json` register the two npm projects. +- **.NET**: `dotnet` builds `FwLiteCore.slnf` whole (EF-model and generated-TS drift checks stay unconditional) and tests only the affected test projects through a generated `FwLiteAffectedTests.slnf` (`.github/scripts/write-test-slnf.mjs`) with `--filter "Category!=Slow&Category!=Benchmark"`. `Slow` marks tests over ~20 s (`Sena3SyncTests`, `CanSyncRandomEntries`, `BulkCreateEntriesPerformance`, `GetEntryIndex_LargeList_ReturnsCorrectIndex`); the `slow-tests` tier runs them in a parallel job. +- **Viewer**: two jobs so wall clock is max, not sum: `viewer-checks` (i18n extract drift, viewer-only svelte-check and eslint, prettier, vitest on Chromium only via `VITEST_CHROMIUM_ONLY`, vite build) and `viewer-ui-tests` (Playwright, Chromium; `ci:webkit` adds WebKit). Firefox vitest stays on develop. +- **Escalation tiers** (`.github/ci-tiers//project.json`, README there): `slow-tests`, `maui-windows`, `e2e` fire by path rule; `apple`, `android`, `webkit` by label only; `full` on `pr.yaml` itself. A `ci:` label does the same (`ci:full` = all) and re-triggers the run; shared-file changes run the whole PR tier but never escalate. A shadow-phase miss is a one-line rule edit. +- **CI calls the script directly**, not `task ci:affected` (Taskfile prefixes output with the task name). +- History: dotnet-affected was prototyped against nx for this role (branch `claude/proto-affected-dotnet-affected-febda6`, commit `978a92bb7`) and lost: it is three files and ~3 s, but it missed a `Directory.Packages.props` bump entirely and cannot see the viewer; nx's MSBuild evaluation inputs attribute shared props to every project and the viewer is a real graph node. Delete that branch once `pr.yaml` is merged. + --- ## Key Concepts From feeb2b832fa9c809e0ea780f00d1809016c97be0 Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Thu, 22 Oct 2026 14:19:53 +0700 Subject: [PATCH 06/12] Keep the full tier when a shared-file change marks everything affected affected.sh dropped every tier whenever `everything` was true, which also swallowed `full` (the PR workflow file changed), so the PR that adds pr.yaml escalated to nothing. `full` is now exempt: shared files still never pull in the other tiers. Co-Authored-By: Claude Fable 5.1 --- .github/scripts/affected.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/scripts/affected.sh b/.github/scripts/affected.sh index 7c315e5704..f4d5654354 100755 --- a/.github/scripts/affected.sh +++ b/.github/scripts/affected.sh @@ -70,9 +70,9 @@ ALL="$all" AFFECTED="$affected" node -e ' const everything = real(all).every((n) => affected.includes(n)); // A shared-file change runs the whole PR tier, Linux-only: it never escalates, // even though nx also marks tiers for files it treats as global (nx.json, lockfile). - const tiers = everything - ? [] - : affected.filter(isTier).map((n) => n.slice("tier-".length)).sort(); + // The one exception is `full` (the PR workflow file changed): that always escalates. + const tiers = affected.filter(isTier).map((n) => n.slice("tier-".length)) + .filter((t) => !everything || t === "full").sort(); process.stdout.write(JSON.stringify({ projects, viewer: affected.includes("viewer"), From f1be970fd88d5fcb54bdb52d0a053fe96243c399 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 23 Oct 2026 12:59:55 +0200 Subject: [PATCH 07/12] Move viewer svelte-check and eslint to fw-lite.yaml develop-ui.yaml no longer runs on viewer-only changes, and its check-and-lint job was the only existing CI running svelte-check and eslint on the viewer. Keep the one viewer file the lexbox UI imports (icon-class.ts) in its paths. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/develop-ui.yaml | 3 +++ .github/workflows/fw-lite.yaml | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/develop-ui.yaml b/.github/workflows/develop-ui.yaml index b4bcf45325..a00a7e816b 100644 --- a/.github/workflows/develop-ui.yaml +++ b/.github/workflows/develop-ui.yaml @@ -5,6 +5,8 @@ on: - 'frontend/**' # The viewer (FW Lite) is covered by pr.yaml / fw-lite.yaml and ships via FwLiteShared/wwwroot, not the UI image. - '!frontend/viewer/**' + # frontend/src/lib/icons/index.ts imports this one viewer file + - 'frontend/viewer/src/lib/icon-class.ts' - '.github/workflows/develop-ui.yaml' - '.github/workflows/lexbox-ui.yaml' - '.github/workflows/deploy.yaml' @@ -14,6 +16,7 @@ on: paths: - 'frontend/**' - '!frontend/viewer/**' + - 'frontend/viewer/src/lib/icon-class.ts' - '.github/workflows/develop-ui.yaml' - '.github/workflows/lexbox-ui.yaml' - '.github/workflows/deploy.yaml' diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index f68ce2f44f..d819073699 100644 --- a/.github/workflows/fw-lite.yaml +++ b/.github/workflows/fw-lite.yaml @@ -202,6 +202,12 @@ jobs: working-directory: frontend run: | pnpm install + - name: svelte-check (viewer) + working-directory: frontend/viewer + run: pnpm run check + - name: eslint (viewer) + working-directory: frontend/viewer + run: pnpm run lint - name: Ensure all i18n strings have been extracted working-directory: frontend/viewer shell: bash From 3cced156ef1b81f1910a4ef113639abca93abd12 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 23 Oct 2026 14:03:59 +0200 Subject: [PATCH 08/12] Drop the icon-class.ts re-include from develop-ui.yaml paths Co-Authored-By: Claude Opus 5.5 --- .github/workflows/develop-ui.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/develop-ui.yaml b/.github/workflows/develop-ui.yaml index a00a7e816b..b4bcf45325 100644 --- a/.github/workflows/develop-ui.yaml +++ b/.github/workflows/develop-ui.yaml @@ -5,8 +5,6 @@ on: - 'frontend/**' # The viewer (FW Lite) is covered by pr.yaml / fw-lite.yaml and ships via FwLiteShared/wwwroot, not the UI image. - '!frontend/viewer/**' - # frontend/src/lib/icons/index.ts imports this one viewer file - - 'frontend/viewer/src/lib/icon-class.ts' - '.github/workflows/develop-ui.yaml' - '.github/workflows/lexbox-ui.yaml' - '.github/workflows/deploy.yaml' @@ -16,7 +14,6 @@ on: paths: - 'frontend/**' - '!frontend/viewer/**' - - 'frontend/viewer/src/lib/icon-class.ts' - '.github/workflows/develop-ui.yaml' - '.github/workflows/lexbox-ui.yaml' - '.github/workflows/deploy.yaml' From 2e22df2b32916480228c0280c747c59745c90ef8 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 23 Oct 2026 14:30:37 +0200 Subject: [PATCH 09/12] Keep nx stderr and fix slow-tests with no .NET change affected.sh no longer discards nx's stderr, so a detection fallback can be diagnosed from the log. pr.yaml always falls back to all FW Lite test projects when none are affected, so a ci:slow-tests label on a PR with no .NET change no longer fails in write-test-slnf.mjs. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/affected.sh | 5 +++-- .github/workflows/pr.yaml | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/scripts/affected.sh b/.github/scripts/affected.sh index f4d5654354..6038448cf1 100755 --- a/.github/scripts/affected.sh +++ b/.github/scripts/affected.sh @@ -39,10 +39,11 @@ fallback() { exit 0 } -# nx prints its JSON array as the last stdout line; banners go to stderr. +# nx prints its JSON array as the last stdout line; banners and errors go to stderr, +# which is left visible so a fallback can be diagnosed from the log. nx_json() { local out - out=$("$nx" "$@" --json 2>/dev/null) || return 1 + out=$("$nx" "$@" --json) || return 1 out=$(printf '%s\n' "$out" | grep '^\[' | tail -n 1) [ -n "$out" ] || return 1 printf '%s' "$out" diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index f42fe61f85..609c613830 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -93,7 +93,8 @@ jobs: const coreTests = core.filter((p) => p.endsWith('.Tests')); const dotnet = everything || r.projects.some((p) => core.includes(p)); let testProjects = everything ? coreTests : r.projects.filter((p) => coreTests.includes(p)); - if (dotnet && testProjects.length === 0) testProjects = coreTests; // e.g. only a leaf library changed + // e.g. only a leaf library changed, or a ci:slow-tests label on a PR with no .NET change + if (testProjects.length === 0) testProjects = coreTests; const out = { projects: JSON.stringify(r.projects), viewer: String(r.viewer || everything), From b3a0ac2abfa9541aef62152c01219c3ad9560ce9 Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 23 Oct 2026 16:20:57 +0200 Subject: [PATCH 10/12] Decide CI tiers from their own rules and close affected-detection gaps - affected.sh asks nx for tiers on the diff without nx.json, the root package.json and lockfiles. nx marks every tier for those, so an nx bump escalated to all tiers, while the old `!everything` filter dropped tiers a path rule fired alongside a shared-file change. - slow-tests also fires for the test projects holding Slow tests and for MiniLcm/SyncHelpers; e2e for the viewer's e2e tests and setup-k8s. - sharedGlobals cover the CI helpers the jobs run (write-test-slnf.mjs, backend/FwLite/Taskfile.yml and scripts). - The viewer and extension nx inputs include the files they import from outside their roots. - A ci:webkit label runs the viewer UI tests even when the viewer is not affected. Co-Authored-By: Claude Opus 5.5 --- .github/ci-tiers/README.md | 9 +++++---- .github/ci-tiers/e2e/project.json | 4 +++- .github/ci-tiers/slow-tests/project.json | 10 ++++++---- .github/scripts/affected.sh | 21 ++++++++++++++------- .github/workflows/pr.yaml | 2 +- frontend/viewer/project.json | 8 ++++++-- nx.json | 5 ++++- platform.bible-extension/project.json | 7 +++++-- 8 files changed, 44 insertions(+), 22 deletions(-) diff --git a/.github/ci-tiers/README.md b/.github/ci-tiers/README.md index 0e7e0fa5aa..b3ce3c3bf7 100644 --- a/.github/ci-tiers/README.md +++ b/.github/ci-tiers/README.md @@ -20,11 +20,12 @@ A tier's rule is in its `project.json`, in one of two forms: Label-only tiers (`apple`, `android`, `webkit`) have an empty rule and fire only by label. -Two rules the script adds on top of the graph: +Two rules on top of the graph: -- When a shared file marks everything affected (`nx.json`'s `sharedGlobals`), - the script reports no tiers: the whole PR tier runs, still Linux-only. Only the - PR workflow file itself escalates, to `full`. +- A shared file (`nx.json`'s `sharedGlobals`) never reaches a tier, so it runs + the whole PR tier, still Linux-only. nx itself marks every tier when `nx.json`, + the root `package.json` or the root lockfile changes, so the script leaves + those files out when it asks nx for tiers. - Editing a tier's `project.json` fires that tier (its own folder changed), so a rule change proves itself on the PR that makes it. diff --git a/.github/ci-tiers/e2e/project.json b/.github/ci-tiers/e2e/project.json index e525edec65..c7a550912d 100644 --- a/.github/ci-tiers/e2e/project.json +++ b/.github/ci-tiers/e2e/project.json @@ -10,7 +10,9 @@ "{workspaceRoot}/backend/FwLite/FwLiteProjectSync/**/*", "{workspaceRoot}/backend/FwLite/LcmCrdt/**/*", "{workspaceRoot}/backend/FwLite/FwLiteShared/Auth/**/*", - "{workspaceRoot}/backend/FwLite/FwLiteShared/Sync/**/*" + "{workspaceRoot}/backend/FwLite/FwLiteShared/Sync/**/*", + "{workspaceRoot}/frontend/viewer/tests/e2e/**/*", + "{workspaceRoot}/.github/actions/setup-k8s/**/*" ] } } diff --git a/.github/ci-tiers/slow-tests/project.json b/.github/ci-tiers/slow-tests/project.json index b8b5913862..bca4d98e42 100644 --- a/.github/ci-tiers/slow-tests/project.json +++ b/.github/ci-tiers/slow-tests/project.json @@ -2,14 +2,16 @@ "$schema": "../../../node_modules/nx/schemas/project-schema.json", "name": "tier-slow-tests", "tags": ["ci-tier"], - "_comment": "Escalation: the Slow-trait .NET tests. Path rule: a direct edit in a sync project. (Label: ci:slow-tests.)", + "_comment": "Escalation: the Slow-trait .NET tests. Path rule: a direct edit in a sync project, the sync helpers, or a test project holding Slow tests. (Label: ci:slow-tests.)", "targets": { "ci-affected": { "executor": "nx:noop", "inputs": [ - "{workspaceRoot}/backend/FwLite/FwLiteProjectSync/**/*", - "{workspaceRoot}/backend/FwLite/LcmCrdt/**/*", - "{workspaceRoot}/backend/FwLite/FwDataMiniLcmBridge/**/*" + "{workspaceRoot}/backend/FwLite/FwLiteProjectSync*/**/*", + "{workspaceRoot}/backend/FwLite/LcmCrdt*/**/*", + "{workspaceRoot}/backend/FwLite/FwDataMiniLcmBridge*/**/*", + "{workspaceRoot}/backend/FwLite/MiniLcm/SyncHelpers/**/*", + "{workspaceRoot}/backend/FwLite/MiniLcm.Tests/**/*" ] } } diff --git a/.github/scripts/affected.sh b/.github/scripts/affected.sh index 6038448cf1..68e3c68d71 100755 --- a/.github/scripts/affected.sh +++ b/.github/scripts/affected.sh @@ -22,7 +22,8 @@ # `nx show projects --affected` and shapes its answer. No globs here. # # Any failure still prints valid JSON with "everything": true and a "fallback" -# reason, and exits 0, so a broken detector can only over-run CI, never skip it. +# reason, and exits 0, so a broken detector can only over-run the PR tier, never +# skip it. Escalation tiers then come from labels only. set -u @@ -60,8 +61,17 @@ all=$(nx_json show projects) \ affected=$(nx_json show projects --affected --base="$merge_base" --head="$head_ref") \ || fallback "nx show projects --affected failed" +# nx marks every project, tiers included, when nx.json, the root package.json or the root +# lockfile changes, so tiers are decided on the diff without those files: each tier fires by +# its own rule only. sharedGlobals never reach a tier (no `default` input), so they don't escalate. +tier_files=$(git diff --name-only --no-renames "$merge_base" "$head_ref" -- \ + ':!nx.json' ':!package.json' ':!pnpm-lock.yaml' ':!pnpm-workspace.yaml') \ + || fallback "git diff $merge_base $head_ref failed" +tier_affected=$(nx_json show projects --affected --files="$(printf '%s' "$tier_files" | paste -sd, -)") \ + || fallback "nx show projects --affected --files failed" + # Assemble the result with node (nx already requires it), so names are escaped properly. -ALL="$all" AFFECTED="$affected" node -e ' +ALL="$all" AFFECTED="$affected" TIER_AFFECTED="$tier_affected" node -e ' const isTier = (n) => n.startsWith("tier-"); const isSpecial = (n) => n === "viewer" || n === "extension"; const all = JSON.parse(process.env.ALL); @@ -69,11 +79,8 @@ ALL="$all" AFFECTED="$affected" node -e ' const real = (names) => names.filter((n) => !isTier(n)); const projects = affected.filter((n) => !isTier(n) && !isSpecial(n)).sort(); const everything = real(all).every((n) => affected.includes(n)); - // A shared-file change runs the whole PR tier, Linux-only: it never escalates, - // even though nx also marks tiers for files it treats as global (nx.json, lockfile). - // The one exception is `full` (the PR workflow file changed): that always escalates. - const tiers = affected.filter(isTier).map((n) => n.slice("tier-".length)) - .filter((t) => !everything || t === "full").sort(); + const tiers = JSON.parse(process.env.TIER_AFFECTED).filter(isTier) + .map((n) => n.slice("tier-".length)).sort(); process.stdout.write(JSON.stringify({ projects, viewer: affected.includes("viewer"), diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 609c613830..e0d580aefc 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -319,7 +319,7 @@ jobs: viewer-ui-tests: name: Viewer Playwright UI tests needs: detect - if: needs.detect.outputs.viewer == 'true' + if: needs.detect.outputs.viewer == 'true' || contains(fromJSON(needs.detect.outputs.tiers), 'webkit') runs-on: ubuntu-latest timeout-minutes: 25 env: diff --git a/frontend/viewer/project.json b/frontend/viewer/project.json index decb3fe6f1..afcd2ac02e 100644 --- a/frontend/viewer/project.json +++ b/frontend/viewer/project.json @@ -8,9 +8,13 @@ "targets": { "ci-affected": { "executor": "nx:noop", - "inputs": ["default"], + "inputs": [ + "default", + "{workspaceRoot}/frontend/tests/envVars.ts", + "{workspaceRoot}/frontend/tests/pages/**/*" + ], "metadata": { - "description": "Never run. Its `default` input (which includes nx.json's sharedGlobals) makes a shared-file change mark this project affected." + "description": "Never run. Its `default` input (which includes nx.json's sharedGlobals) makes a shared-file change mark this project affected; the frontend/tests files are imported by the viewer's Playwright tests." } } } diff --git a/nx.json b/nx.json index b96a8a4327..e655b99cc6 100644 --- a/nx.json +++ b/nx.json @@ -25,7 +25,10 @@ "{workspaceRoot}/package.json", "{workspaceRoot}/pnpm-lock.yaml", "{workspaceRoot}/pnpm-workspace.yaml", - "{workspaceRoot}/.github/scripts/affected.sh" + "{workspaceRoot}/.github/scripts/affected.sh", + "{workspaceRoot}/.github/scripts/write-test-slnf.mjs", + "{workspaceRoot}/backend/FwLite/Taskfile.yml", + "{workspaceRoot}/backend/FwLite/scripts/**/*" ] } } diff --git a/platform.bible-extension/project.json b/platform.bible-extension/project.json index 46bb41652e..c7bc10148c 100644 --- a/platform.bible-extension/project.json +++ b/platform.bible-extension/project.json @@ -8,9 +8,12 @@ "targets": { "ci-affected": { "executor": "nx:noop", - "inputs": ["default"], + "inputs": [ + "default", + "{workspaceRoot}/frontend/viewer/src/lib/dotnet-types/**/*" + ], "metadata": { - "description": "Never run. Its `default` input (which includes nx.json's sharedGlobals) makes a shared-file change mark this project affected." + "description": "Never run. Its `default` input (which includes nx.json's sharedGlobals) makes a shared-file change mark this project affected; the dotnet-types are imported through tsconfig's @dotnet-types path." } } } From e077865698572e00c66ae5c7746202f2b1bcebfe Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Mon, 26 Oct 2026 13:46:35 +0700 Subject: [PATCH 11/12] Format the extension project.json and document the dropped launcher step The extension's prettier (100-column limit from its .editorconfig) wants the nx `inputs` array on one line, and the viewer-side pre-commit hook ignores that directory, so this was failing the extension lint in both workflows. The PR tier's e2e job also leaves out fw-lite.yaml's "Test FwLite launcher" step on purpose; the job comment now says so. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/pr.yaml | 4 +++- platform.bible-extension/project.json | 5 +---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index e0d580aefc..6ef808c32f 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -534,7 +534,9 @@ jobs: timeout-minutes: 30 env: FW_LITE_BINARY_PATH: ${{ github.workspace }}/backend/FwLite/artifacts/publish/FwLiteWeb/release_linux-x64/FwLiteWeb - # Copied from fw-lite.yaml's e2e-test, except the viewer is built here instead of downloaded. + # Copied from fw-lite.yaml's e2e-test, with two differences: the viewer is built here + # instead of downloaded, and the "Test FwLite launcher" vitest step is left out. The + # smoke test below launches the same binary; the launcher's own cases stay on develop. steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/platform.bible-extension/project.json b/platform.bible-extension/project.json index c7bc10148c..7c6b7fffc0 100644 --- a/platform.bible-extension/project.json +++ b/platform.bible-extension/project.json @@ -8,10 +8,7 @@ "targets": { "ci-affected": { "executor": "nx:noop", - "inputs": [ - "default", - "{workspaceRoot}/frontend/viewer/src/lib/dotnet-types/**/*" - ], + "inputs": ["default", "{workspaceRoot}/frontend/viewer/src/lib/dotnet-types/**/*"], "metadata": { "description": "Never run. Its `default` input (which includes nx.json's sharedGlobals) makes a shared-file change mark this project affected; the dotnet-types are imported through tsconfig's @dotnet-types path." } From b23ae25b88684c5409263686efdde2d8e265d93e Mon Sep 17 00:00:00 2001 From: Kevin Hahn Date: Tue, 27 Oct 2026 11:53:51 +0700 Subject: [PATCH 12/12] Stop running the full tier on pull requests fw-lite.yaml and platform.bible-extension.yaml now trigger only on develop pushes (plus dispatch and workflow_call for fw-lite); pull requests run pr.yaml alone. Drops the dead feat/sync-morph-types branch entry, and the concurrency rule no longer needs the pull_request case. Co-Authored-By: Claude Fable 5.1 --- .github/AGENTS.md | 8 ++++---- .github/workflows/fw-lite.yaml | 19 ++----------------- .../workflows/platform.bible-extension.yaml | 6 ------ 3 files changed, 6 insertions(+), 27 deletions(-) diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 2dbf8bde5c..0835543c22 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -17,7 +17,7 @@ GitHub Actions workflows and deployment. Full runs take 30-60+ min across Linux, | Workflow | Triggers | What it does | Time | |----------|----------|--------------|------| | `pr.yaml` (`PR CI`) | Every PR to develop (also `labeled`/`unlabeled`) | The **PR tier**: detects affected projects with nx, then runs only the affected Linux jobs (.NET build + affected tests, viewer checks, viewer Playwright on Chromium, extension lint); escalation tiers add full-tier jobs. See [PR tier](#pr-tier-pryaml) | target < 10 min | -| `fw-lite.yaml` | FwLite code changes | Build .NET, run tests, build viewer, publish apps (the **full tier**; still on PRs during the shadow phase) | ~40 min | +| `fw-lite.yaml` | FwLite code changes | Build .NET, run tests, build viewer, publish apps (the **full tier**, develop only; pull requests run `pr.yaml`) | ~40 min | | `lexbox-api.yaml` | Called by others | Build API, run unit tests, build Docker image | ~15 min | | `lexbox-ui.yaml` | Called by others | Build SvelteKit UI, build Docker image | ~10 min | | `lexbox-fw-headless.yaml` | Called by others | Build FwHeadless Docker image | ~10 min | @@ -101,7 +101,7 @@ flowchart TD ### FwLite -`fw-lite.yaml` runs on its own for CI (develop pushes, PRs, manual dispatch). Releases call it from `release-pipeline.yaml` with `release: true` and the shared `version`/`semver-version`; that's the only way to publish the GitHub/Play Store release. Its own manual dispatch can't release (it declares no inputs). The LexBox and FW Lite releases run in parallel; neither blocks the other. +`fw-lite.yaml` runs on its own for CI (develop pushes and manual dispatch; pull requests run `pr.yaml` instead). Releases call it from `release-pipeline.yaml` with `release: true` and the shared `version`/`semver-version`; that's the only way to publish the GitHub/Play Store release. Its own manual dispatch can't release (it declares no inputs). The LexBox and FW Lite releases run in parallel; neither blocks the other. - Core .NET build/tests run on Linux (`FwLiteCore.slnf`); MAUI build/tests on Windows only - Has its own test suite @@ -110,12 +110,12 @@ flowchart TD ### PR tier (`pr.yaml`) -`PR CI` is the trimmed, Linux-only job set every pull request runs; `fw-lite.yaml` is the full tier on develop (and, during the shadow phase, still on PRs too). Its summary job `PR CI result` is the check to require at cut-over: a job skipped as "not affected" counts as passed. +`PR CI` is the trimmed, Linux-only job set every pull request runs; `fw-lite.yaml` and `platform.bible-extension.yaml` are the full tier, on develop pushes only. Its summary job `PR CI result` is the check to require on develop: a job skipped as "not affected" counts as passed. - **Affected detection** is the nx graph (`nx` + `@nx/dotnet` at the repo root, graph-only: nothing builds or caches through nx). `.github/scripts/affected.sh` prints one JSON object (`projects`, `viewer`, `extension`, `everything`, `tiers`, `fallback`); `task ci:affected` runs the same script locally against `origin/develop` (`AFFECTED_BASE=` to override) and must give the same answer as the `detect` job. Any detector failure falls back to everything affected with a warning annotation. Rules live in the graph: `nx.json` `sharedGlobals` lists the shared files (props, `global.json`, solution files, lockfiles, nx config) that mark everything affected; `frontend/viewer/project.json` and `platform.bible-extension/project.json` register the two npm projects. - **.NET**: `dotnet` builds `FwLiteCore.slnf` whole (EF-model and generated-TS drift checks stay unconditional) and tests only the affected test projects through a generated `FwLiteAffectedTests.slnf` (`.github/scripts/write-test-slnf.mjs`) with `--filter "Category!=Slow&Category!=Benchmark"`. `Slow` marks tests over ~20 s (`Sena3SyncTests`, `CanSyncRandomEntries`, `BulkCreateEntriesPerformance`, `GetEntryIndex_LargeList_ReturnsCorrectIndex`); the `slow-tests` tier runs them in a parallel job. - **Viewer**: two jobs so wall clock is max, not sum: `viewer-checks` (i18n extract drift, viewer-only svelte-check and eslint, prettier, vitest on Chromium only via `VITEST_CHROMIUM_ONLY`, vite build) and `viewer-ui-tests` (Playwright, Chromium; `ci:webkit` adds WebKit). Firefox vitest stays on develop. -- **Escalation tiers** (`.github/ci-tiers//project.json`, README there): `slow-tests`, `maui-windows`, `e2e` fire by path rule; `apple`, `android`, `webkit` by label only; `full` on `pr.yaml` itself. A `ci:` label does the same (`ci:full` = all) and re-triggers the run; shared-file changes run the whole PR tier but never escalate. A shadow-phase miss is a one-line rule edit. +- **Escalation tiers** (`.github/ci-tiers//project.json`, README there): `slow-tests`, `maui-windows`, `e2e` fire by path rule; `apple`, `android`, `webkit` by label only; `full` on `pr.yaml` itself. A `ci:` label does the same (`ci:full` = all) and re-triggers the run; shared-file changes run the whole PR tier but never escalate. When develop goes red on something the PR tier skipped, the fix is a one-line rule edit. - **CI calls the script directly**, not `task ci:affected` (Taskfile prefixes output with the task name). - History: dotnet-affected was prototyped against nx for this role (branch `claude/proto-affected-dotnet-affected-febda6`, commit `978a92bb7`) and lost: it is three files and ~3 s, but it missed a `Directory.Packages.props` bump entirely and cannot see the viewer; nx's MSBuild evaluation inputs attribute shared props to every project and the viewer is a real graph node. Delete that branch once `pr.yaml` is merged. diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index d819073699..7da1755841 100644 --- a/.github/workflows/fw-lite.yaml +++ b/.github/workflows/fw-lite.yaml @@ -31,26 +31,11 @@ on: - 'global.json' branches: - develop - pull_request: - paths: - - 'backend/FwLite/**' - - 'backend/Directory.Packages.props' - - 'backend/Harmony*.props' - - 'frontend/viewer/**' - - 'platform.bible-extension/**' - - '.github/workflows/fw-lite.yaml' - - '.github/actions/setup-k8s/**' - - 'FwLiteCore.slnf' - - 'FwLiteOnly.slnf' - - 'global.json' - branches: - - develop - - feat/sync-morph-types #just for now ensure PRs to this branch have checks run -# Only cancel superseded PR runs; never cancel develop or release builds. +# Never cancel develop or release builds. Pull requests run pr.yaml (the PR tier), not this workflow. concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} + cancel-in-progress: false permissions: contents: read diff --git a/.github/workflows/platform.bible-extension.yaml b/.github/workflows/platform.bible-extension.yaml index 3978b29f57..a2a89edcc5 100644 --- a/.github/workflows/platform.bible-extension.yaml +++ b/.github/workflows/platform.bible-extension.yaml @@ -6,12 +6,6 @@ on: - ".github/workflows/platform.bible-extension.yaml" branches: - develop - pull_request: - paths: - - "platform.bible-extension/**" - - ".github/workflows/platform.bible-extension.yaml" - branches: - - develop # Modified from https://github.com/paranext/paranext-extension-template/blob/main/.github/workflows/lint.yml