diff --git a/.changeset/12276-lint-turbo-cache.md b/.changeset/12276-lint-turbo-cache.md new file mode 100644 index 0000000000..880eecf533 --- /dev/null +++ b/.changeset/12276-lint-turbo-cache.md @@ -0,0 +1,4 @@ +--- +--- + +No package released: CI configuration, one docs page and tests. `turbo.json` gives the root `//#lint:root` task the inputs it lints, so every package's `lint` hash stops moving on unrelated files and the `Lint` job's restored cache can replay it. The four turbo caches that restored a bare `turbo--` key (`Lint`, `Build Docs`, `Changeset Release`, `Bundle Analysis`) each get their own key prefix, are written only by runs on `main`, are pruned to what the run used, and report what they replayed (objectui#12276). No published package's files change. diff --git a/.github/workflows/changeset-release.yml b/.github/workflows/changeset-release.yml index 66faaa45b2..8b0e6ca83c 100644 --- a/.github/workflows/changeset-release.yml +++ b/.github/workflows/changeset-release.yml @@ -709,14 +709,23 @@ jobs: # # The restore half is deliberately left UNBOUNDED: a restore stall fails # BEFORE anything is published, which is the honest failure. + # + # ONE restore key, this job's own prefix (objectui#12276, the shape + # `ci.yml`'s `type-check` job took in objectui#12222). Until then this key + # was `turbo--` with a bare `turbo--` fallback, and `Lint` + # and `Bundle Analysis` saved under that same key, so this job restored + # whichever of their caches reached the sha first -- and its own save + # below found an exact hit and skipped. Every run of this job is on + # `main` (push, `schedule`, dispatch), so its own previous entry is what + # the prefix finds. - name: Restore Turbo Cache id: turbo-cache uses: actions/cache/restore@v6 with: path: .turbo/cache - key: turbo-${{ runner.os }}-${{ github.sha }} + key: turbo-${{ runner.os }}-release-${{ github.sha }} restore-keys: | - turbo-${{ runner.os }}- + turbo-${{ runner.os }}-release- - name: Install dependencies run: pnpm install --frozen-lockfile @@ -738,6 +747,8 @@ jobs: # commit; this one only ever duplicated it, ~34 minutes later. - name: Build packages + env: + TURBO_RUN_SUMMARY: 'true' run: pnpm build # ══════════════════════════════════════════════════════════════════════ @@ -1408,6 +1419,37 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # ── What the cache replayed, and only what it used is kept (objectui#12276) ── + # The `type-check` job's two bookkeeping steps (objectui#12222), for this + # job's cache. `Build packages` above sets `TURBO_RUN_SUMMARY`, so turbo + # wrote its run summary to `.turbo/runs/`; the report reads it and puts + # what the restored cache replayed, what re-ran and the restored key on + # ONE `::notice` annotation, readable through the check-runs API by + # anyone who cannot open this log. The prune then deletes every entry + # that build did not use, so the entry saved below is exactly the + # current build outputs. Every run of this job is a `main` run, so both + # run on every run, after every release step: `!cancelled()` on the + # report so a failed release still reports, the prune on success like + # the save it prepares. + # + # Bounded and `continue-on-error`, like the save: neither can redden a + # job that has already published. `timeout-minutes: 1` each is DERIVED + # FOR THIS SITE: the script reads one run summary and lists one + # directory, and measured 87 ms on a lint run's summary + # (objectui#12276); a minute is several hundred times that. + - name: Report what the turbo cache replayed + if: ${{ !cancelled() }} + timeout-minutes: 1 + continue-on-error: true + env: + RESTORED_CACHE_KEY: ${{ steps.turbo-cache.outputs.cache-matched-key }} + run: node scripts/report-turbo-cache.mjs + + - name: Prune the turbo cache to what this run used + timeout-minutes: 1 + continue-on-error: true + run: node scripts/report-turbo-cache.mjs --prune + # The bookkeeping half of the split documented at the restore step above, # placed HERE — last in the job, after publish, after the post-version # tree validation and after the refresh — because that is exactly where @@ -1417,14 +1459,16 @@ jobs: # `timeout-minutes: 5` is DERIVED, and the derivation is weaker here than # at the other six sites — said plainly rather than dressed up. # objectui#7048 fences inheriting PR #7047's 5, so: this cache is - # `.turbo/cache` under the same `turbo-${{ runner.os }}-${{ github.sha }}` - # key shape as `lint.yml` and `performance-budget.yml`, and the saves of - # THAT cache measured 1s, 2s, 5s and 6s across four jobs on 2026-09-02 + # `.turbo/cache`, under the same `turbo-${{ runner.os }}-${{ github.sha }}` + # key as `lint.yml` and `performance-budget.yml` when this was derived + # (each job has its own key prefix since objectui#12276), and the saves + # of that cache measured 1s, 2s, 5s and 6s across four jobs on 2026-09-02 # (`Bundle Analysis` 100096569559, `Type Check` 100096569872, # `Build Docs` 100094597253, `Lint` 100097466846). A same-SITE save was # not observable in the sampled runs: this job runs on `push` to `main` # only when the version is not yet on npm, and on the 6-hourly `schedule` - # the primary key is an exact hit, so `Post Turbo Cache` measured 0s on + # the primary key was an exact hit -- another job's save under the shared + # key -- so `Post Turbo Cache` measured 0s on # the sampled scheduled run (job 99021319583, 2026-08-29) with its # restore at 4s. 5 minutes is 50x the slowest of the four same-cache # saves; if a directly measured save of this site ever lands and lies @@ -1449,4 +1493,4 @@ jobs: continue-on-error: true with: path: .turbo/cache - key: turbo-${{ runner.os }}-${{ github.sha }} + key: turbo-${{ runner.os }}-release-${{ github.sha }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 782e93e9f6..150bde7768 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2484,6 +2484,14 @@ jobs: # The restore half is deliberately left UNBOUNDED: a restore stall fails # BEFORE any verdict exists — a gate that did not run, which is honest — # rather than a recorded verdict discarded. + # + # ONE restore key, this job's own prefix (objectui#12276, the shape the + # `type-check` job above took in objectui#12222). Until then a bare + # `turbo--` key followed this one, and when the job's own prefix + # found nothing it matched any job's cache -- `Lint`'s, the bundle and + # release builds' -- which this job then saved again under its own key. + # Pull-request and queue runs save nothing now (see the prune step + # below), so on those refs this key falls through to `main`'s entry. - name: Restore Turbo Cache id: turbo-cache if: steps.docs-changes.outputs.should_run == 'true' @@ -2493,14 +2501,18 @@ jobs: key: turbo-${{ runner.os }}-docs-${{ github.sha }} restore-keys: | turbo-${{ runner.os }}-docs- - turbo-${{ runner.os }}- - name: Install dependencies if: steps.docs-changes.outputs.should_run == 'true' run: pnpm install --frozen-lockfile + # `TURBO_RUN_SUMMARY` makes turbo write its run summary to `.turbo/runs/`; + # it changes nothing turbo runs or caches, and `Report what the turbo + # cache replayed` below reads it (objectui#12276). - name: Build Site if: steps.docs-changes.outputs.should_run == 'true' + env: + TURBO_RUN_SUMMARY: 'true' run: pnpm turbo run build --filter='@object-ui/site' # REPORT-ONLY, by the 2026-09-06 ruling on objectui#7851, and it must stay @@ -2533,6 +2545,40 @@ jobs: if: steps.docs-changes.outputs.should_run == 'true' run: node scripts/check-doc-expression-carriage.mjs + # ── What the cache replayed, and only `main` writes it (objectui#12276) ── + # The `type-check` job's two bookkeeping steps (objectui#12222), for this + # job's cache. The report reads the summary `Build Site` asked turbo for + # and puts what the restored cache replayed, what re-ran and the restored + # key on ONE `::notice` annotation, readable through the check-runs API + # by anyone who cannot open this log. The prune deletes every entry this + # run did not use, on `push` and `schedule` only -- the runs that save -- + # so `main`'s entry is exactly the current build outputs instead of + # every build output any earlier run left behind; pull-request and queue + # runs restore and never save (see the save step below). + # + # Both are bookkeeping after the last checking step, bounded and + # `continue-on-error`. `timeout-minutes: 1` each is DERIVED FOR THIS + # SITE: the script reads one run summary and lists one directory, and + # measured 87 ms on a lint run's summary (objectui#12276); a minute is + # several hundred times that, and with the save's 5 this job's + # bookkeeping stays at 7 of its 15 -- inside half the ceiling, which is + # the rule the save's own derivation below applies. + - name: Report what the turbo cache replayed + if: steps.docs-changes.outputs.should_run == 'true' && !cancelled() + timeout-minutes: 1 + continue-on-error: true + env: + RESTORED_CACHE_KEY: ${{ steps.turbo-cache.outputs.cache-matched-key }} + run: node scripts/report-turbo-cache.mjs + + - name: Prune the turbo cache to what this run used + if: >- + steps.docs-changes.outputs.should_run == 'true' + && (github.event_name == 'push' || github.event_name == 'schedule') + timeout-minutes: 1 + continue-on-error: true + run: node scripts/report-turbo-cache.mjs --prune + # The bookkeeping half of the split documented at the restore step above, # placed HERE — after `Build Site`, the step that records this job's # verdict — because that is exactly where the post phase it replaces @@ -2563,10 +2609,13 @@ jobs: # - the condition names no status function, so the implicit `success()` # still applies — matching the combined action's `post-if: success()`. # - same `path` and same `key` as the restore step above. + # One deliberate change (objectui#12276): only `push` and `schedule` + # save, after the prune step above, as in the `type-check` job. - name: Save Turbo Cache if: >- steps.docs-changes.outputs.should_run == 'true' && steps.turbo-cache.outputs.cache-hit != 'true' + && (github.event_name == 'push' || github.event_name == 'schedule') uses: actions/cache/save@v6 timeout-minutes: 5 continue-on-error: true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 69edb76646..ad10cbd403 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -322,22 +322,44 @@ jobs: # The restore half is deliberately left UNBOUNDED: a restore stall fails # BEFORE any verdict exists — a gate that did not run, which is honest — # rather than a recorded verdict discarded. + # + # ONE restore key, this job's own prefix (objectui#12276, the shape + # `ci.yml`'s `type-check` job took in objectui#12222). Until then this key + # was `turbo--` with a bare `turbo--` fallback: two other + # workflows' build jobs (`changeset-release.yml`, `performance-budget.yml`) + # saved under that same key, and the fallback also matched the `docs` and + # `type-check` prefixes, so this job restored and re-saved whichever + # job's cache came first on the ref -- build outputs, for a task whose + # own entries hold only logs. `actions/cache` tries the run's + # OWN ref before `main`; pull-request and queue runs save nothing now (see + # the save step below), so on those refs this key finds nothing and the + # lookup falls through to `main`'s lint entry. - name: Restore Turbo Cache id: turbo-cache if: steps.relevant.outputs.should_run == 'true' uses: actions/cache/restore@v6 with: path: .turbo/cache - key: turbo-${{ runner.os }}-${{ github.sha }} + key: turbo-${{ runner.os }}-lint-${{ github.sha }} restore-keys: | - turbo-${{ runner.os }}- + turbo-${{ runner.os }}-lint- - name: Install dependencies if: steps.relevant.outputs.should_run == 'true' run: pnpm install --frozen-lockfile + # `TURBO_RUN_SUMMARY` makes turbo write its run summary to `.turbo/runs/` + # -- per task, the hash, the cache status and the tasks it depends on. It + # changes nothing turbo runs or caches; `Report what the turbo cache + # replayed` below reads it (objectui#12276). Every package's `lint` + # dependsOn `//#lint:root`, and turbo folds that task's hash into theirs, + # so `turbo.json` gives the root task the inputs it actually lints: with + # none it hashed every file in the repository, and no commit could replay + # a single package's lint. - name: Run linter if: steps.relevant.outputs.should_run == 'true' + env: + TURBO_RUN_SUMMARY: 'true' run: pnpm lint # ── vi.mock override VALUE shapes (objectui#8903) ───────────────────── @@ -490,6 +512,57 @@ jobs: if: steps.relevant.outputs.should_run == 'true' run: pnpm check + # ── What the cache replayed (objectui#12276) ───────────────────────── + # The `type-check` job's report step (objectui#12222), here for this job's + # cache. Bookkeeping, like the save below: it runs after the last checking + # step, so it can neither delay nor change the verdict. It reads the run + # summary `Run linter` asked turbo for and prints, per task, what the + # restored cache replayed and what re-ran -- each re-run task with the + # tasks it depends on, so a dependency whose hash moved every package's + # is named in the log -- and puts the counts and the restored key on ONE + # `::notice` annotation, which the check-runs API serves to readers who + # cannot open this log (AGENTS.md #9: the instrument is the claim, its + # answer is not written down). `!cancelled()` so a red lint still + # reports what it replayed. + # + # `timeout-minutes: 1` is DERIVED FOR THIS SITE: the script reads one + # summary and lists one directory, and measured 87 ms on a lint run's + # summary (objectui#12276); a minute is several hundred times that, and + # with the save's 5 below this job's bookkeeping stays at 7 of its 25. + # `continue-on-error: true` because a broken report says nothing about + # the code under lint. + - name: Report what the turbo cache replayed + if: steps.relevant.outputs.should_run == 'true' && !cancelled() + timeout-minutes: 1 + continue-on-error: true + env: + RESTORED_CACHE_KEY: ${{ steps.turbo-cache.outputs.cache-matched-key }} + run: node scripts/report-turbo-cache.mjs + + # ── Only `push` writes this cache, and only what it used ───────────── + # The `type-check` job's rule (objectui#12222): pull-request and queue + # runs restore and never save, because what they would save is scoped to + # a ref no later run of another pull request can read, and every entry + # saved is one more a size-limited cache store can evict `main`'s for. + # A `push` run saves, after this step deletes every entry the run did + # not use, so `main`'s entry is exactly the current lint verdicts. + # + # `push` alone, not `push` or `schedule`: this workflow has no + # `schedule`. Measured on objectui#12276 before relying on it: the push + # concurrency group here is the ref, so a merge can cancel the previous + # merge's run, and 14 of the 20 newest `main` push runs succeeded (6 + # cancelled) -- the save lands on most merges, and a pull request + # restores the newest one that did. Bookkeeping like the report step: + # bounded, `continue-on-error`; `scripts/report-turbo-cache.mjs` refuses + # to prune on a summary that lists no task. + - name: Prune the turbo cache to what this run used + if: >- + steps.relevant.outputs.should_run == 'true' + && github.event_name == 'push' + timeout-minutes: 1 + continue-on-error: true + run: node scripts/report-turbo-cache.mjs --prune + # The bookkeeping half of the split documented at the restore step above, # placed HERE — after the last checking step — because that is exactly # where the post phase it replaces already ran. Nothing about which @@ -517,13 +590,16 @@ jobs: # - the condition names no status function, so the implicit `success()` # still applies — matching the combined action's `post-if: success()`. # - same `path` and same `key` as the restore step above. + # One deliberate change (objectui#12276): only `push` saves, after the + # prune step above; see there for why. - name: Save Turbo Cache if: >- steps.relevant.outputs.should_run == 'true' && steps.turbo-cache.outputs.cache-hit != 'true' + && github.event_name == 'push' uses: actions/cache/save@v6 timeout-minutes: 5 continue-on-error: true with: path: .turbo/cache - key: turbo-${{ runner.os }}-${{ github.sha }} + key: turbo-${{ runner.os }}-lint-${{ github.sha }} diff --git a/.github/workflows/performance-budget.yml b/.github/workflows/performance-budget.yml index 6dffefe8e6..915fb71525 100644 --- a/.github/workflows/performance-budget.yml +++ b/.github/workflows/performance-budget.yml @@ -57,6 +57,10 @@ on: - 'scripts/check-side-effects-array.mjs' - 'scripts/component-registrations.mjs' - 'scripts/js-comment-mask.mjs' + # The turbo cache report and prune (objectui#12276). Same rule again: + # this job runs it, so an edit to it must run this gate on its own pull + # request. + - 'scripts/report-turbo-cache.mjs' pull_request: branches: [main, develop] paths: @@ -113,6 +117,10 @@ on: - 'scripts/check-side-effects-array.mjs' - 'scripts/component-registrations.mjs' - 'scripts/js-comment-mask.mjs' + # The turbo cache report and prune (objectui#12276). Same rule again: + # this job runs it, so an edit to it must run this gate on its own pull + # request. + - 'scripts/report-turbo-cache.mjs' concurrency: group: bundle-analysis-${{ github.event.pull_request.number || github.ref }} @@ -226,20 +234,34 @@ jobs: # The restore half is deliberately left UNBOUNDED: a restore stall fails # BEFORE any verdict exists — a gate that did not run, which is honest — # rather than a recorded verdict discarded. + # + # ONE restore key, this job's own prefix (objectui#12276, the shape + # `ci.yml`'s `type-check` job took in objectui#12222). Until then this key + # was `turbo--` with a bare `turbo--` fallback, and `Lint` + # and the release job saved under that same key, so on a pull request + # this job restored whichever cache reached the ref first -- `Lint`'s + # logs-only entry as readily as a build -- and saved it again. + # Pull-request runs save nothing now (see the prune step below), so on a + # pull request's ref this key finds nothing and falls through to `main`. - name: Restore Turbo Cache id: turbo-cache uses: actions/cache/restore@v6 with: path: .turbo/cache - key: turbo-${{ runner.os }}-${{ github.sha }} + key: turbo-${{ runner.os }}-perf-${{ github.sha }} restore-keys: | - turbo-${{ runner.os }}- + turbo-${{ runner.os }}-perf- - name: Install dependencies run: pnpm install --frozen-lockfile + # `TURBO_RUN_SUMMARY` makes turbo write its run summary to `.turbo/runs/`; + # it changes nothing turbo runs or caches, and `Report what the turbo + # cache replayed` below reads it (objectui#12276). - name: Build packages id: build_packages + env: + TURBO_RUN_SUMMARY: 'true' run: pnpm turbo run build --filter='./packages/*' - name: Build Console @@ -610,6 +632,45 @@ jobs: await core.summary.addRaw(body).write(); } + # ── What the cache replayed, and only `main` writes it (objectui#12276) ── + # The `type-check` job's two bookkeeping steps (objectui#12222), for this + # job's cache. The report reads the summary `Build packages` asked turbo + # for and puts what the restored cache replayed, what re-ran and the + # restored key on ONE `::notice` annotation, readable through the + # check-runs API by anyone who cannot open this log. The prune deletes + # every entry this run did not use, on `push` only -- the runs that save + # -- so `main`'s entry is exactly the current build outputs; pull-request + # runs restore and never save (see the save step below), because what + # they would save is scoped to a ref no other pull request can read, and + # every build-sized entry saved is one more a size-limited cache store + # can evict `main`'s for. + # + # `push` alone: this workflow has no `schedule`. Measured on + # objectui#12276 before relying on it: the push concurrency group here + # is the ref, so a merge can cancel the previous merge's run, and 16 of + # the 20 newest `main` push runs succeeded (3 cancelled, 1 still running) + # -- the save lands on most merges that reach this workflow's paths. + # + # Bookkeeping after the last checking step, bounded and + # `continue-on-error`. `timeout-minutes: 1` each is DERIVED FOR THIS + # SITE: the script reads one run summary and lists one directory, and + # measured 87 ms on a lint run's summary (objectui#12276); a minute is + # several hundred times that, and with the save's 5 this job's + # bookkeeping stays at 7 of its 25. + - name: Report what the turbo cache replayed + if: ${{ !cancelled() }} + timeout-minutes: 1 + continue-on-error: true + env: + RESTORED_CACHE_KEY: ${{ steps.turbo-cache.outputs.cache-matched-key }} + run: node scripts/report-turbo-cache.mjs + + - name: Prune the turbo cache to what this run used + if: github.event_name == 'push' + timeout-minutes: 1 + continue-on-error: true + run: node scripts/report-turbo-cache.mjs --prune + # The bookkeeping half of the split documented at the restore step above, # placed HERE — last in the job, after the budget verdict AND after the # comment that reports it — because that is exactly where the post phase @@ -637,11 +698,15 @@ jobs: # - the condition names no status function, so the implicit `success()` # still applies — matching the combined action's `post-if: success()`. # - same `path` and same `key` as the restore step above. + # One deliberate change (objectui#12276): only `push` saves, after the + # prune step above; see there for why. - name: Save Turbo Cache - if: steps.turbo-cache.outputs.cache-hit != 'true' + if: >- + steps.turbo-cache.outputs.cache-hit != 'true' + && github.event_name == 'push' uses: actions/cache/save@v6 timeout-minutes: 5 continue-on-error: true with: path: .turbo/cache - key: turbo-${{ runner.os }}-${{ github.sha }} + key: turbo-${{ runner.os }}-perf-${{ github.sha }} diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index f992b7ae1c..dcf8078b95 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -257,7 +257,7 @@ it green — which is how two of `type-check`'s gates came to be missing from th | `test-coverage` | Test (coverage shard N/8) | `pnpm test:coverage --reporter=blob --reporter=default --reporter=github-actions --shard=N/8` across an 8-runner matrix with `fail-fast: false`. Each shard writes `.vitest-reports/blob-N-8.json` — raw coverage and test results in one file — and uploads it as an artifact even when the shard is red (vitest deletes `coverage/` on a red run unless `coverage.reportOnFailure` is set, [#5402](https://github.com/objectstack-ai/objectui/issues/5402)). The two reporters after the blob are what keep a red shard *readable*: a CLI `--reporter` replaces the default reporter set rather than adding to it, so `--reporter=blob` on its own ended a failing shard's log at `blob report written to …` with no test name and no assertion text, leaving the download-only artifact as the only copy of the failure ([#9177](https://github.com/objectstack-ai/objectui/issues/9177)); `--reporter=default` restores the log and `--reporter=github-actions` restores the per-test `::error` annotation. The flags are pinned by `scripts/__tests__/coverage-shard-reporter-readability.test.ts`. The configured coverage thresholds are neutralised on the shard legs, because a slice of the suite judged against a whole-suite threshold is not a defect signal; they are enforced once, on the merged report, by the job below ([#5403](https://github.com/objectstack-ai/objectui/issues/5403)). **4 → 8 shards in [#12256](https://github.com/objectstack-ai/objectui/issues/12256)** (folded into [#12208](https://github.com/objectstack-ai/objectui/issues/12208)): at four, a shard was cancelled at this job's 40-minute ceiling on 7 of 25 `push` runs measured on that card, and every shard of the runs that finished sat within one to three minutes of it. The width is derived from those walls at a 20-minute target per shard; the ceiling is not raised, no test is skipped, and the thresholds are unchanged. The width is spelled in this job's matrix, name and `--shard` flag and in `coverage-report`'s `COVERAGE_SHARDS`, and `scripts/__tests__/coverage-shard-width.test.ts` holds every spelling to the matrix. Its legs are split by the same duration-balanced sequencer as `test`. | **Push and the hourly schedule only** | | `coverage-report` | Test (coverage) | Downloads every shard's blob report, refuses to continue unless all of them arrived (the count it expects is the job's `COVERAGE_SHARDS`), merges them with `pnpm test:coverage --merge-reports` into one complete report — which is where the configured coverage thresholds are enforced, over the whole merged map, the shard legs having overridden them to zero — and publishes that report as the `coverage-report` artifact (kept 7 days, the same as the blobs it is derived from). Its last step runs on every path and states the outcome: the job is **red, with an error annotation**, whenever the gate did not run for the commit — before [#5403](https://github.com/objectstack-ai/objectui/issues/5403) the final step carried the implicit `success()` and was silently skipped by 311 of 373 coverage jobs, which is how four days of a 100%-failing coverage job went unnoticed. A breach of the thresholds is reported *separately* from a lane that never delivered, because the two call for opposite actions. ⛔ It never merges a report from fewer than every shard: a wrong coverage number is worse than a missing one. The Codecov upload this job used to carry was retired by [#5436](https://github.com/objectstack-ai/objectui/issues/5436) — `CODECOV_TOKEN` was never set, so it failed on every push; the trend dashboard and PR coverage comments are gone with it, the gate is not. Before the verdict, `scripts/shard-headroom.mjs` prints the longest coverage shard's wall against the 40-minute cap and the width the run's measured total implies at a 20-minute target per shard. It is report-only in the same way as in `test-aggregate`. On a red lane it reads `scripts/` through a sparse checkout of its own, because the main checkout is skipped there. | **Push and the hourly schedule only** | | `e2e` | Build & E2E | Builds the console with `vite build` (`VITE_BASE_PATH=/console/`), verifies the artifact, then `pnpm test:e2e --project=chromium`. On failure it uploads `test-results/` — the screenshots, traces and `error-context.md` Playwright writes for failing specs — as the `e2e-failure-artifacts` upload; the `github` reporter this lane runs on CI writes annotations, so there is no HTML report in it. | Every run; on a PR the steps short-circuit when only ignored paths changed | -| `docs` | Build Docs | `turbo run build --filter='@object-ui/site'`. On a PR it first diffs against the base and skips the build only when nothing that build consumes changed. ⛔ The path set is **not enumerated here**, deliberately: it is derived rather than curated — from the workspace packages turbo builds on the way to `@object-ui/site`, the root-level inputs `turbo.json` declares for the `build` task, and the workspace manifests, plus the workflow file itself so a change to this gate is validated by the gate — and a path list copied into prose is a stale list the moment the closure moves, which is the class [#8629](https://github.com/objectstack-ai/objectui/issues/8629) and [#7448](https://github.com/objectstack-ai/objectui/issues/7448) each record. `scripts/__tests__/docs-build-trigger.test.ts` re-derives all three populations on every PR and executes the step's own shell against them, so the live answer is the pathspec in the step and a red test is what happens when it stops covering them. Until [#8647](https://github.com/objectstack-ai/objectui/issues/8647) the filter named the site's **output** surface only — the docs content and the site app — while its **input** surface is everything turbo builds before `next build` runs, so a pull request touching only `packages/**` skipped the build and still reported `success`, and a skipped build and a passed build are the same green to every reader downstream. The merge-queue leg always built, so what the filter cost was early detection rather than the guarantee at merge time. Then `scripts/check-doc-expression-carriage.mjs`, which is **report-only**: it censuses every `json` fence on the surface `check:doc-types` walks (`content/docs/**`, every `apps//docs/**` tree, and the root `README.md` — widened from `content/docs/**` alone in `59a3a233d`) for a `${…}` authored on a key `SchemaRenderer` never evaluates — the class that reached `main` four times under green gates, because `check:doc-types` judges the `type` literal only and `check:doc-snippets` compiles the ts/tsx blocks only ([#7851](https://github.com/objectstack-ai/objectui/issues/7851)). It prints its findings and **exits 0 regardless**, so it can block no merge; it exits 1 only when the instrument itself is broken — a derivation that matched nothing, a missing `@objectstack/spec` artifact, or a failed built-in control — because a check that runs, goes green and looked at nothing is worse than none. Report-only is a ruling, not an oversight: three cards of the class it reports ([#7440](https://github.com/objectstack-ai/objectui/issues/7440), [#7444](https://github.com/objectstack-ai/objectui/issues/7444), [#7838](https://github.com/objectstack-ai/objectui/issues/7838)) are open and each fixes its own sites. It does **not** check docs links any more — that moved to a workflow of its own (#3448), because this workflow's `paths-ignore` then hid exactly the docs-only PRs a link check needs to see — today the Internal Docs Link Check step of `repo-gates.yml` (#12214). #3523 has since removed that filter from the `pull_request` trigger, but the check stays out of this job: `repo-gates.yml` still runs where this workflow does not (a docs-only push to `main`), and one gate with one home was the point of #3448. | Every run (build itself conditional) | +| `docs` | Build Docs | `turbo run build --filter='@object-ui/site'`. On a PR it first diffs against the base and skips the build only when nothing that build consumes changed. ⛔ The path set is **not enumerated here**, deliberately: it is derived rather than curated — from the workspace packages turbo builds on the way to `@object-ui/site`, the root-level inputs `turbo.json` declares for the `build` task, and the workspace manifests, plus the workflow file itself so a change to this gate is validated by the gate — and a path list copied into prose is a stale list the moment the closure moves, which is the class [#8629](https://github.com/objectstack-ai/objectui/issues/8629) and [#7448](https://github.com/objectstack-ai/objectui/issues/7448) each record. `scripts/__tests__/docs-build-trigger.test.ts` re-derives all three populations on every PR and executes the step's own shell against them, so the live answer is the pathspec in the step and a red test is what happens when it stops covering them. Until [#8647](https://github.com/objectstack-ai/objectui/issues/8647) the filter named the site's **output** surface only — the docs content and the site app — while its **input** surface is everything turbo builds before `next build` runs, so a pull request touching only `packages/**` skipped the build and still reported `success`, and a skipped build and a passed build are the same green to every reader downstream. The merge-queue leg always built, so what the filter cost was early detection rather than the guarantee at merge time. Then `scripts/check-doc-expression-carriage.mjs`, which is **report-only**: it censuses every `json` fence on the surface `check:doc-types` walks (`content/docs/**`, every `apps//docs/**` tree, and the root `README.md` — widened from `content/docs/**` alone in `59a3a233d`) for a `${…}` authored on a key `SchemaRenderer` never evaluates — the class that reached `main` four times under green gates, because `check:doc-types` judges the `type` literal only and `check:doc-snippets` compiles the ts/tsx blocks only ([#7851](https://github.com/objectstack-ai/objectui/issues/7851)). It prints its findings and **exits 0 regardless**, so it can block no merge; it exits 1 only when the instrument itself is broken — a derivation that matched nothing, a missing `@objectstack/spec` artifact, or a failed built-in control — because a check that runs, goes green and looked at nothing is worse than none. Report-only is a ruling, not an oversight: three cards of the class it reports ([#7440](https://github.com/objectstack-ai/objectui/issues/7440), [#7444](https://github.com/objectstack-ai/objectui/issues/7444), [#7838](https://github.com/objectstack-ai/objectui/issues/7838)) are open and each fixes its own sites. Then — bookkeeping only, after the verdict — `scripts/report-turbo-cache.mjs`, which posts the restored cache key and what the build replayed as one notice, and on `push` and `schedule` prunes the cache to what the build used before it is saved; a pull request or queue build restores `main`'s entry under this job's own key prefix and saves nothing ([#12276](https://github.com/objectstack-ai/objectui/issues/12276)). It does **not** check docs links any more — that moved to a workflow of its own (#3448), because this workflow's `paths-ignore` then hid exactly the docs-only PRs a link check needs to see — today the Internal Docs Link Check step of `repo-gates.yml` (#12214). #3523 has since removed that filter from the `pull_request` trigger, but the check stays out of this job: `repo-gates.yml` still runs where this workflow does not (a docs-only push to `main`), and one gate with one home was the point of #3448. | Every run (build itself conditional) | Uses: Node 22.x, pnpm via `corepack`, `actions/cache` over `.turbo/cache`. @@ -390,6 +390,13 @@ first, which is what stops a scanner that recognises nothing from reporting a cl [#5246](https://github.com/objectstack-ai/objectui/issues/5246) — it had been exiting 1 on `main` for as long as any `tsconfig.json` carried a comment ([#5237](https://github.com/objectstack-ai/objectui/issues/5237)), and no gate ever asked. +- Then — bookkeeping only, after the verdict — `scripts/report-turbo-cache.mjs`: one notice naming + the restored cache key and how many package `lint` tasks the cache replayed, and on `push` a prune + to what the run used before the cache is saved. A pull request or queue build restores `main`'s + entry under this job's own key prefix and saves nothing. The replay depends on `turbo.json` giving + the root `//#lint:root` task the inputs it lints: every package's `lint` depends on it, so with no + inputs it hashed the whole repository and no package's lint could replay + ([#12276](https://github.com/objectstack-ai/objectui/issues/12276)). **It gates errors, not warnings.** `--max-warnings` is deliberately unset: the repository carries thousands of warnings (overwhelmingly `no-explicit-any`, plus React Compiler rules the config @@ -445,6 +452,7 @@ the entry-chunk line cannot see them. - Posts a PR comment with the budget report and pass/fail status — but **only when the bundle was actually measured**. A run that was cancelled (a second push supersedes the first via `cancel-in-progress`) posts nothing, and a run whose build never produced a bundle posts a neutral "not measured" note instead of a verdict. A `FAIL` verdict therefore always carries the measured size that exceeded the budget. - The comment is rendered by `scripts/render-budget-comment.mjs` (unit-tested), not by logic inlined in YAML. - Runs `pnpm check:sdui-registration-pins` in a step of its own, after the budget step. This is the **artifact** half of the `sideEffects` contract: the static gate in `ci.yml` proves the array agrees with the module bodies, but only a real bundler run proves the SDUI widget registrations still reach a chunk. The step declares no `continue-on-error`, so a dropped registration fails this workflow — a size win bought by silently deleting a feature must not read as a size win. +- Then — bookkeeping only, after the verdict — `scripts/report-turbo-cache.mjs`: one notice naming the restored cache key and what the package build replayed, and on `push` a prune to what the build used before the cache is saved. A pull request restores `main`'s entry under this job's own key prefix and saves nothing ([#12276](https://github.com/objectstack-ai/objectui/issues/12276)). **If it fails:** the step prints `BUDGET EXCEEDED: Main entry is KB gzip (limit: 350 KB)` and the PR comment carries the same two numbers, so the log already tells you the size and the @@ -2309,6 +2317,11 @@ Before either lane's changesets step, the release job runs `pnpm build` — the what that build produced, and the gates below judge the built `dist/`, so there is nothing for them to read until it has run. +Last in the job, after every release step, `scripts/report-turbo-cache.mjs` posts the restored +cache key and what that build replayed as one notice, then prunes the cache to what the build used +before it is saved under this job's own key prefix +([#12276](https://github.com/objectstack-ai/objectui/issues/12276)). + The publish lane then runs `pnpm changeset:publish`, and that script is `node scripts/check-published-dist-tooling.mjs && node scripts/check-spec-range-floors.mjs && changeset publish` — the **blocking** copies of the Published Dist Gate and the Spec Range Floors diff --git a/scripts/__tests__/render-budget-comment.test.ts b/scripts/__tests__/render-budget-comment.test.ts index 652f027ef8..a5f048b70e 100644 --- a/scripts/__tests__/render-budget-comment.test.ts +++ b/scripts/__tests__/render-budget-comment.test.ts @@ -456,7 +456,9 @@ describe('performance-budget.yml contract', () => { }); it('gates the size report, the renderer and the comment on !cancelled()', () => { - expect(conditions.filter((c) => c.includes('!cancelled()'))).toHaveLength(3); + // Four: those three, plus the turbo cache report (objectui#12276), which is + // bookkeeping after the verdict and reports on a red run for the same reason. + expect(conditions.filter((c) => c.includes('!cancelled()'))).toHaveLength(4); }); it('generates the size report only from a complete package build', () => { @@ -473,7 +475,12 @@ describe('performance-budget.yml contract', () => { }); it('passes every env var the renderer reads, and reads every one it passes', () => { - const declared = [...workflow.matchAll(/^\s{10}([A-Z_]+): \$\{\{ steps\./gm)].map((m) => m[1]); + // The render step's own block: another step's `env:` (the turbo cache + // report's RESTORED_CACHE_KEY, objectui#12276) is not the renderer's contract. + const renderStep = workflow.slice(workflow.indexOf('- name: Render performance budget comment')); + const block = renderStep.slice(0, renderStep.indexOf('- name:', 1)); + expect(block, 'the render step is gone').toContain('scripts/render-budget-comment.mjs'); + const declared = [...block.matchAll(/^\s{10}([A-Z_]+): \$\{\{ steps\./gm)].map((m) => m[1]); expect(declared.length).toBeGreaterThan(0); for (const name of declared) { @@ -569,6 +576,8 @@ describe('performance-budget.yml contract', () => { // `isEntrypoint` decides whether `main()` runs at all — a regression here // makes the checker exit 0 having measured nothing. 'scripts/invoked-as.mjs', + // The turbo cache report and prune the job runs after its verdict (objectui#12276). + 'scripts/report-turbo-cache.mjs', ]; it('triggers on its whole runtime closure, so no part of this gate ships unexercised', () => { diff --git a/scripts/__tests__/report-turbo-cache.test.ts b/scripts/__tests__/report-turbo-cache.test.ts index 652c5d676d..8d543dacef 100644 --- a/scripts/__tests__/report-turbo-cache.test.ts +++ b/scripts/__tests__/report-turbo-cache.test.ts @@ -215,3 +215,134 @@ describe('ci.yml — only the full battery writes the type-check cache, pruned f expect(steps[pruneAt].if ?? '').toContain(FULL_BATTERY); }); }); + +/** + * objectui#12276 — every turbo cache in `.github/workflows/` is its job's own. + * + * Until then four of the five jobs that restore `.turbo/cache` ended their + * restore keys with a bare `turbo--`, and three of them (`Lint`, `Bundle + * Analysis`, the release job) saved under one and the same `turbo--` + * key. `actions/cache` takes the newest match on the run's own ref first, so a + * job restored whichever job's cache had reached that ref -- the measured + * instance is objectui#12222's, where `Type Check` restored the `docs` job's + * multi-GB build cache on a pull request -- and saved it again under its own + * key. The `type-check` job's shape (objectui#12222, the block above) is the + * rule here, applied to the population rather than to one job: its own key + * prefix and no other restore key; pull-request and queue runs restore and + * never save; the saving runs prune to what they used first; and every run + * reports what it replayed and which key it restored, which is how "no job + * restores another job's cache" is read after the fact (the notice names the + * restored key) rather than written down here. + */ +describe('every workflow turbo cache: its own key, written by main only, pruned and reported (objectui#12276)', () => { + type Step = { + name?: string; + id?: string; + uses?: string; + run?: string; + if?: string; + env?: Record; + with?: Record; + }; + const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const workflowDir = path.join(repoRoot, '.github/workflows'); + const KEY = /^turbo-\$\{\{ runner\.os \}\}-([a-z][a-z0-9-]*)-\$\{\{ github\.sha \}\}$/; + + const caches = fs + .readdirSync(workflowDir) + .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')) + .sort() + .flatMap((file) => { + const wf = parseYaml(fs.readFileSync(path.join(workflowDir, file), 'utf8')) as { + on?: Record; + jobs?: Record; + }; + const triggers = Object.keys(wf.on ?? {}); + return Object.entries(wf.jobs ?? {}).flatMap(([job, def]) => { + const steps = def.steps ?? []; + const restoreAt = steps.findIndex( + (s) => (s.uses ?? '').startsWith('actions/cache/restore@') && s.with?.path === '.turbo/cache', + ); + if (restoreAt === -1) return []; + const saveAt = steps.findIndex( + (s) => (s.uses ?? '').startsWith('actions/cache/save@') && s.with?.path === '.turbo/cache', + ); + const restore = steps[restoreAt]; + const tag = KEY.exec(restore.with?.key ?? '')?.[1] ?? null; + return [{ where: `${file} \`${job}\``, triggers, steps, restoreAt, saveAt, restore, tag }]; + }); + }); + + /** Whether an `if:` expression names this event. */ + const names = (expr: string | undefined, event: string) => (expr ?? '').includes(`github.event_name == '${event}'`); + + it('reads the population, and the type-check job the rule is taken from is in it', () => { + expect(caches.length, 'the workflow scan found implausibly few turbo caches').toBeGreaterThan(2); + expect(caches.map((c) => c.where)).toContain('ci.yml `type-check`'); + }); + + it('keys each cache with its own prefix, and restores by that prefix alone', () => { + const off = caches.flatMap((c) => { + if (c.tag === null) return [`${c.where}: key \`${c.restore.with?.key}\` is not turbo---`]; + const restoreKeys = (c.restore.with?.['restore-keys'] ?? '').split('\n').map((l) => l.trim()).filter(Boolean); + const own = `turbo-\${{ runner.os }}-${c.tag}-`; + return restoreKeys.length === 1 && restoreKeys[0] === own + ? [] + : [`${c.where}: restore-keys ${JSON.stringify(restoreKeys)}, expected exactly ["${own}"]`]; + }); + expect(off, 'a turbo cache restore can reach another job’s cache:\n' + off.map((o) => ` - ${o}`).join('\n')).toEqual([]); + }); + + it('gives no two jobs a prefix that matches the other’s key', () => { + const tags = caches.map((c) => c.tag).filter((t): t is string => t !== null); + const clashes = tags.flatMap((a, i) => + tags.filter((b, j) => i !== j && `${b}-`.startsWith(`${a}-`)).map((b) => `${a} restores ${b}`), + ); + expect(clashes, 'one job’s restore prefix is a prefix of another job’s key').toEqual([]); + }); + + it('saves only on runs that are not a pull request or a queue build, after pruning on the same runs', () => { + const off = caches.flatMap((c) => { + if (c.saveAt === -1) return [`${c.where}: restores \`.turbo/cache\` and never saves it`]; + const save = c.steps[c.saveAt]; + const pruneAt = c.steps.findIndex((s) => s.run === 'node scripts/report-turbo-cache.mjs --prune'); + const out: string[] = []; + if (pruneAt === -1 || pruneAt > c.saveAt) out.push(`${c.where}: no prune step before the save`); + const reviewed = c.triggers.includes('pull_request') || c.triggers.includes('merge_group'); + if (reviewed) { + if (!names(save.if, 'push')) out.push(`${c.where}: the save runs on pull requests or queue builds`); + for (const event of ['push', 'schedule', 'pull_request', 'merge_group']) { + if (pruneAt !== -1 && names(save.if, event) !== names(c.steps[pruneAt].if, event)) { + out.push(`${c.where}: the prune and the save disagree on \`${event}\``); + } + } + } + for (const event of ['pull_request', 'merge_group']) { + if (names(save.if, event)) out.push(`${c.where}: the save names \`${event}\``); + } + return out; + }); + expect(off, off.map((o) => ` - ${o}`).join('\n')).toEqual([]); + }); + + it('reports what it replayed and the key it restored, from a summary its turbo step wrote', () => { + const off = caches.flatMap((c) => { + const reportAt = c.steps.findIndex((s) => s.run === 'node scripts/report-turbo-cache.mjs'); + if (reportAt === -1) return [`${c.where}: no \`node scripts/report-turbo-cache.mjs\` step`]; + const report = c.steps[reportAt]; + const out: string[] = []; + if (c.saveAt !== -1 && reportAt > c.saveAt) out.push(`${c.where}: the report runs after the save`); + if (!(report.if ?? '').includes('!cancelled()')) { + out.push(`${c.where}: the report must run on a red run too (\`!cancelled()\`)`); + } + const expected = `\${{ steps.${c.restore.id}.outputs.cache-matched-key }}`; + if (report.env?.RESTORED_CACHE_KEY !== expected) { + out.push(`${c.where}: the report must read RESTORED_CACHE_KEY from ${expected}`); + } + const summarised = c.steps.slice(c.restoreAt + 1, reportAt).some((s) => s.env?.TURBO_RUN_SUMMARY === 'true'); + if (!summarised) out.push(`${c.where}: no step between the restore and the report sets TURBO_RUN_SUMMARY`); + return out; + }); + expect(off, off.map((o) => ` - ${o}`).join('\n')).toEqual([]); + }); +}); diff --git a/scripts/__tests__/turbo-lint-inputs.test.ts b/scripts/__tests__/turbo-lint-inputs.test.ts index a9fcdd0531..f93b598245 100644 --- a/scripts/__tests__/turbo-lint-inputs.test.ts +++ b/scripts/__tests__/turbo-lint-inputs.test.ts @@ -2,14 +2,17 @@ import { describe, expect, it } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import ts from 'typescript'; +import { ESLint } from 'eslint'; import { globToRegExp, packagesWithScript, rel, repoFilesOnDisk, + repoRoot, rootAnchoredInputs, taskInputs, } from './helpers/turbo-inputs'; +import { walkedFiles } from '../check-lint-rule-coverage.mjs'; import { FLAT_CONFIG_FILENAMES, configFilesFor, @@ -243,3 +246,108 @@ describe('turbo `lint` inputs cover every out-of-package file (objectui#4184)', ).toEqual([]); }); }); + +/** + * objectui#12276 — the root task `lint` depends on hashes what it LINTS, not the + * repository. + * + * Every package's `lint` dependsOn `//#lint:root` (objectui#4456), and turbo + * folds a dependency's hash into its dependents'. The root task stays uncached + * (`turbo-task-guard-coverage.test.ts` says why), so its `inputs` decide nothing + * but that inherited hash — and with none, turbo hashed every file in the + * repository for it. Measured on that card, before the fix: one untracked file + * under `docs/` moved 46 of 46 package `lint` hashes, so the cache `Lint` + * restores could never replay a single package. After it, on the same probe: 0 + * of 46; an untracked `.changeset/` or `content/` file, 0; a file in one + * package, that package's alone; a root-level linted file (`scripts/`, `e2e/`) + * or `eslint.config.js`, all 46 — readings of that day, not live counts. + * + * The inputs are the extensions ESLint lints at that scope, minus exactly the + * trees the `lint:root` script hands ESLint as `--ignore-pattern`, so they + * follow the script rather than a list of root directories that a new one would + * outdate. Plus one exclusion that is turbo's, not ESLint's: under explicit + * `inputs` turbo hashes git-ignored files as well (measured on that card: 64008 + * inputs, 63598 of them under the root `node_modules`), which `$TURBO_DEFAULT$` + * never does. + * + * ⚠️ Bound, said so it is not read as coverage: turbo adds the root + * `package.json` and `turbo.json` to a root task's inputs on its own, so an + * edit to either still moves every package's `lint` hash. And a git-ignored + * output directory with linted extensions outside the four trees (a local + * `coverage/`, say) is hashed too; none exists when `Lint` runs `pnpm lint`. + */ +describe('`//#lint:root` hashes the files it lints, never the whole repository (objectui#12276)', () => { + const ROOT_TASK = '//#lint:root'; + const turbo = JSON.parse(fs.readFileSync(path.join(repoRoot, 'turbo.json'), 'utf8')) as { + tasks: Record; + }; + const inputs = turbo.tasks[ROOT_TASK]?.inputs ?? []; + const include = inputs.filter((glob) => !glob.startsWith('!')).map((glob) => globToRegExp(glob)); + const exclude = inputs.filter((glob) => glob.startsWith('!')).map((glob) => globToRegExp(glob.slice(1))); + /** Turbo's rule: a file is an input when some glob names it and no `!` glob removes it. */ + const hashed = (file: string): boolean => include.some((re) => re.test(file)) && !exclude.some((re) => re.test(file)); + + const script = ( + JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { scripts: Record } + ).scripts['lint:root']; + const ignorePatterns = [...(script ?? '').matchAll(/--ignore-pattern\s+'([^']+)'/g)].map((m) => m[1]); + + it('is what every package `lint` depends on, so its hash is in all of theirs', () => { + expect(turbo.tasks.lint?.dependsOn ?? [], 'the premise of this block').toContain(ROOT_TASK); + expect(script, 'the root package.json must still define `lint:root`').toMatch(/^eslint \. /); + expect(ignorePatterns, 'the `lint:root` ignore-pattern parse came back empty').toContain('docs/**'); + }); + + it('declares inputs, and `$TURBO_DEFAULT$` is not one of them', () => { + expect( + inputs, + `\`${ROOT_TASK}\` declares no \`inputs\`, so turbo hashes every file in the repository for it — ` + + 'and every package’s `lint` inherits that hash, so no commit can replay a cached lint.', + ).not.toHaveLength(0); + expect(inputs, '`$TURBO_DEFAULT$` on a root task is the whole repository again').not.toContain( + '$TURBO_DEFAULT$', + ); + }); + + it('excludes exactly the trees the `lint:root` script ignores, and node_modules', () => { + const negations = inputs.filter((glob) => glob.startsWith('!')).map((glob) => glob.slice(1)); + expect( + negations.sort(), + 'the `!` inputs must be the `lint:root` script’s `--ignore-pattern` values, so a file in a tree ' + + 'that script never lints cannot move every package’s lint hash, plus `**/node_modules/**`, ' + + 'which turbo would otherwise hash', + ).toEqual([...ignorePatterns, '**/node_modules/**'].sort()); + for (const probe of ['node_modules/x/index.js', 'scripts/node_modules/y/index.mjs', 'docs/z.ts']) { + expect(hashed(probe), `${probe} must not be a \`${ROOT_TASK}\` input`).toBe(false); + } + }); + + it('hashes every file ESLint lints at the `lint:root` scope', async () => { + const eslint = new ESLint({ cwd: repoRoot, ignorePatterns }); + const walked = await walkedFiles(repoRoot, eslint); + expect(walked.length, 'the `lint:root` walk found implausibly few files').toBeGreaterThan(100); + expect(walked, 'the walk must reach the root config and a root script').toEqual( + expect.arrayContaining(['eslint.config.js', 'scripts/report-turbo-cache.mjs']), + ); + const unhashed = walked.filter((file) => !hashed(file)); + expect( + unhashed, + `files \`lint:root\` lints that \`${ROOT_TASK}\`'s inputs do not name — an edit to them would ` + + 'not move the hash its dependents inherit:\n' + + unhashed.map((f) => ` - ${f}`).join('\n'), + ).toEqual([]); + + const linted = new Set(walked.map((file) => path.extname(file))); + const beyond = [...repoFilesOnDisk()].filter((file) => hashed(file) && !linted.has(path.extname(file))); + expect( + beyond, + `\`${ROOT_TASK}\`'s inputs reach files of an extension \`lint:root\` never lints, and each one ` + + 'moves every package’s lint hash when it changes:\n' + + beyond.map((f) => ` - ${f}`).join('\n'), + ).toEqual([]); + }); + + it('stays uncached, so its inputs scope a hash and never a replayed verdict', () => { + expect(turbo.tasks[ROOT_TASK]?.cache).toBe(false); + }); +}); diff --git a/scripts/__tests__/turbo-task-guard-coverage.test.ts b/scripts/__tests__/turbo-task-guard-coverage.test.ts index c243ba9ac2..d8e14d2537 100644 --- a/scripts/__tests__/turbo-task-guard-coverage.test.ts +++ b/scripts/__tests__/turbo-task-guard-coverage.test.ts @@ -44,6 +44,13 @@ import { repoRoot } from './helpers/turbo-inputs'; * stale the moment a new root-level directory is added, and a stale inputs list * on a CACHED task replays a green verdict over a directory nothing linted — * which is objectui#4456 itself, one level up. It runs in about four seconds. + * It has declared `inputs` since objectui#12276, and that does not move it + * across the partition: uncached, its inputs decide no verdict of its own, + * only the hash every package's `lint` inherits through `dependsOn` — with + * none, that hash covered every file in the repository and no package's lint + * could replay. They are derived from the `lint:root` script's ignore + * patterns rather than listed by directory, and + * `turbo-lint-inputs.test.ts` pins them against ESLint's own walk. * * `//#type-check:e2e` (objectui#4471) is the same move for the TYPE gate: `e2e/` * is not a workspace package, so `turbo run type-check` could not reach it and diff --git a/turbo.json b/turbo.json index fdf03d9afa..fda8870ea3 100644 --- a/turbo.json +++ b/turbo.json @@ -78,7 +78,19 @@ }, "//#lint:root": { "outputs": [], - "cache": false + "cache": false, + "inputs": [ + "**/*.js", + "**/*.cjs", + "**/*.mjs", + "**/*.ts", + "**/*.tsx", + "!packages/*/**", + "!examples/*/**", + "!apps/*/**", + "!docs/**", + "!**/node_modules/**" + ] }, "type-check": { "dependsOn": ["^build", "//#type-check:e2e"],