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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions .github/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**, 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 |
Expand All @@ -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 |

---
Expand Down Expand Up @@ -100,13 +101,24 @@ 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
- 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` 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=<ref>` 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/<name>/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:<tier>` 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.

---

## Key Concepts
Expand Down
33 changes: 33 additions & 0 deletions .github/ci-tiers/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# CI escalation tiers

Each folder here is an nx pseudo-project named `tier-<name>`. It has no code and
runs nothing: it exists so that `nx show projects --affected` (via
`.github/scripts/affected.sh`) lists `tier-<name>` when a pull request should
pull that tier's full-tier jobs into its PR run. The `ci:<name>` 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 on top of the graph:

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

A missed escalation during the shadow phase becomes a one-line change to the
tier's `project.json`, reviewed like any other PR.
7 changes: 7 additions & 0 deletions .github/ci-tiers/android/project.json
Original file line number Diff line number Diff line change
@@ -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": []
}
7 changes: 7 additions & 0 deletions .github/ci-tiers/apple/project.json
Original file line number Diff line number Diff line change
@@ -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": []
}
19 changes: 19 additions & 0 deletions .github/ci-tiers/e2e/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"$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/**/*",
"{workspaceRoot}/frontend/viewer/tests/e2e/**/*",
"{workspaceRoot}/.github/actions/setup-k8s/**/*"
]
}
}
}
12 changes: 12 additions & 0 deletions .github/ci-tiers/full/project.json
Original file line number Diff line number Diff line change
@@ -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"]
}
}
}
12 changes: 12 additions & 0 deletions .github/ci-tiers/maui-windows/project.json
Original file line number Diff line number Diff line change
@@ -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*/**/*"]
}
}
}
18 changes: 18 additions & 0 deletions .github/ci-tiers/slow-tests/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"$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, 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/MiniLcm/SyncHelpers/**/*",
"{workspaceRoot}/backend/FwLite/MiniLcm.Tests/**/*"
]
}
}
}
7 changes: 7 additions & 0 deletions .github/ci-tiers/webkit/project.json
Original file line number Diff line number Diff line change
@@ -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": []
}
17 changes: 17 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
92 changes: 92 additions & 0 deletions .github/scripts/affected.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#!/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, // 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-<name> pseudo-project
# // is affected (.github/ci-tiers/<name>/project.json)
# "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.
#
# 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 the PR tier, never
# skip it. Escalation tiers then come from labels only.

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

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
}

# 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) || 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"

nx=./node_modules/.bin/nx
[ -x "$nx" ] || fallback "nx is not installed: run 'pnpm install' at the repo root"

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"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# 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" 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);
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));
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"),
extension: affected.includes("extension"),
everything,
tiers,
fallback: null,
}) + "\n");
' || fallback "could not assemble JSON from nx output"
16 changes: 16 additions & 0 deletions .github/scripts/write-test-slnf.mjs
Original file line number Diff line number Diff line change
@@ -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(', ')}`);
3 changes: 3 additions & 0 deletions .github/workflows/develop-ui.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down
25 changes: 8 additions & 17 deletions .github/workflows/fw-lite.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -202,6 +187,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
Expand Down
6 changes: 0 additions & 6 deletions .github/workflows/platform.bible-extension.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading