CI affected scope
How `scripts/affected.ts` computes which packages a PR actually touches and how that scopes typecheck / test / lint / visual-regression on CI.
CI scales by running only what changed. The scoping happens in
scripts/affected.ts,
called from the Detect Changes job in
ci.yml.
What affected.ts does
It reads:
- The list of changed file paths (from
gh api .../pulls/<n>/files, or from a SHA comparison forpush/merge_groupevents) pnpm-workspace.yamlto discover every workspace package dynamically- Each package's
package.jsonto map files → internal@todayai-labs/*deps
It outputs a JSON payload:
interface AffectedResult {
fullRun: boolean
changedPackages: string[]
affectedPackages: string[]
affectedApps: Record<string, boolean> // { web: true, admin: false, ... }
pnpmFilter: string // "--filter @todayai-labs/web... --filter @todayai-labs/admin"
lintPaths: string // "apps/web/ design-system/"
runVisual: boolean
}pnpmFilter becomes the CI input for pnpm <script> invocations.
lintPaths constrains oxlint to changed directories. runVisual gates
the Visual Regression job.
Dynamic discovery — no hardcoded lists
The script does not maintain a hand-written list of packages. It reads
pnpm-workspace.yaml's packages: globs and expands them by stat-ing each
candidate directory's package.json. A new package under apps/,
packages/, or design-system/ is picked up automatically on the next CI
run — no changes to affected.ts required.
This is the reason PR-CA1 didn't need to register apps/dev-docs anywhere
in CI config (Codex's review caught a draft that proposed adding a
hardcoded path map; the actual codebase doesn't need one).
What triggers a fullRun
fullRun: true means "treat every package as affected" — typecheck, test,
lint, and visual regression all run across the whole workspace. Triggers:
| Trigger | Why |
|---|---|
| A changed file doesn't belong to any package | Root config (tsconfig.json, oxfmt.config.ts, .moon/, .github/) potentially affects all builds |
Lockfile-only changes (pnpm-lock.yaml) | Dependency updates can affect any consumer |
Empty stdin (workflow_dispatch / fresh branch) | No diff context; safest default is everything |
For a typical PR that touches apps/web/src/... only, fullRun is false
and CI shaves off all the design-system, admin, onboarding work.
Reading affectedPackages vs changedPackages
| Field | Means |
|---|---|
changedPackages | Packages with at least one changed file |
affectedPackages | Changed packages + every downstream package that depends on them |
affectedPackages ⊇ changedPackages. A change in design-system/ui is in
changedPackages: ['@todayai-labs/tdx-ui'], and affectedPackages adds
@todayai-labs/web, @todayai-labs/admin, @todayai-labs/opal (everything that imports tdx-ui).
Visual regression gating
runVisual is true if apps/web is in affectedApps. Visual regression is
heavy (Playwright + Storybook build + snapshot diff) and only apps/web has
the visual specs; skipping it on design-system-only or admin-only changes
is significant CI time saved.
Triggers and their event shapes
| GitHub event | Diff source |
|---|---|
pull_request | gh api repos/<repo>/pulls/<n>/files — listed files |
merge_group | repos/<repo>/compare/<base_sha>...<head_sha> from the speculative branch |
push (non-first) | compare/<event.before>...<sha> |
push (first push) | empty diff → fullRun |
workflow_dispatch | empty diff → fullRun |
The merge-queue case uses the speculative branch's diff so affected scope only covers PRs in this batch, not the whole queue.
CI fan-out
Downstream of Detect Changes, the CI workflow uses the outputs as inputs:
# excerpt — ci.yml downstream jobs
jobs:
typecheck:
needs: changes
if: ${{ needs.changes.outputs['pnpm-filter'] != '' }}
runs-on: ubuntu-latest
steps:
- run: pnpm ${{ needs.changes.outputs['pnpm-filter'] }} typecheckSo pnpm --filter @todayai-labs/web... --filter @todayai-labs/admin typecheck runs on a
PR that touches both, scoped to their dependency closures via the ...
specifier.
Common confusions
- "My change typechecks locally but the PR is BLOCKED on
Typecheck." You probably ranpnpm --filter @todayai-labs/web typecheck(scoped) but the change also affects@todayai-labs/adminor a sibling. Runpnpm typecheck(unscoped) — that's what CI does. - "Why did CI re-run on the merge queue?" Speculative branch — see Merge queue for the double-run explanation.
- "My docs-only change ran full visual regression." Did you touch a
root config (
.gitignore,package.json, etc.) by accident? Those triggerfullRun.
Local equivalent
To preview what CI will run before pushing:
echo "apps/web/src/lib/foo.ts" | node --no-warnings \
--experimental-strip-types scripts/affected.tsOutputs the same JSON CI consumes. Useful when investigating why a PR is running more (or less) than expected.
dev → main sync
How `sync-main.yml` fast-forwards `main` to `dev` on manual dispatch — the gate that turns a `dev`-merged PR into a production deploy.
Deployment
Five Vercel projects, four owned by GitHub Actions prebuilt deploys and one on Vercel's Git Integration. CI gates every Actions-owned deploy before it ships.