Skip to content

Commit 8210894

Browse files
ci: check out Type Check UI at depth 1 and give every history fetch a budget sized to its job (#31644)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb2CZ7ohopLMiBhAucfDgqvA)_ domain:devx#2 · zhuangjianguo · session_01QsR2jRSntnsNKYKPXp9kFG Refs #22034 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:根 `ci.yml` 的 `Type Check UI`(`type-check-ui`)也不再用 `fetch-depth: 0`,改为深度 1 检出,加上与 Test Core / Test UI 逐字相同的一步"取当前提交的完整祖先 + main(及 PR 目标分支)",不取 tag 和其它分支;四个 job 的这一步各自加超时,按各自 job 墙钟留出的余量定:test-plan 8 分钟、test-ui 14、test 20、type-check-ui 20;新增一条测试钉住"四个 job 都是深度 1 + 紧跟取历史步骤 + 预算不低于 8 分钟且小于 job 墙钟",两条原本钉 `fetch-depth: 0` 的测试改钉取历史步骤。 - 为什么改:#22034 第二步的第二个 PR,收尾第一个 PR(#31429)留下的四件事。注意实测:健康日新路径并不更快,三个 job 合并看中位数约慢 5 秒(新 62 秒 vs 旧 57 秒),其中 test-plan 慢 14 秒;收益在故障尾部——去掉的 1.5k 分支和 1.7 万 tag 正是 11-03 事故里慢 5.6–6.5 倍、10-13 三次传输失败的那部分负载。两组分布及 n 见下方 Acceptance notes。 - 风险与回滚:风险是 `Type Check UI` 的读者拿不到历史;已逐个核对四个读者(lockfile 告警、i18n 漂移、受影响 type-check 范围、spec 构建)都由这一步满足。取历史三次失败或超过该 job 的预算会让该步变红,而不是静默。回滚即还原本 PR。 - 先看:`.github/workflows/ci.yml` 的 `type-check-ui` 检出与新步骤;`test` job 注释里新加的 FETCH BUDGET 段;`ui/scripts/__tests__/select-affected-tests.test.ts` 末尾的新 describe。 - 两分钟验证:打开本 PR 的 CI,看 `Type Check UI` 的 "Fetch this commit's history and the base branch" 日志打印 `shallow=false, no tags`,且该 job 整体为绿。 - 需要你做的:合并本 PR(受管 `.github/workflows/**`,Tier H,须维护者手合)。 ## What changes Five files; only `.github/workflows/ci.yml` is a workflow. 1. **`type-check-ui` checks out at depth 1** and runs the fetch step right after the checkout. The `with:` / `fetch-depth: 0` block and its comment are gone. The step is identical, but for its budget, to the three copies #31429 added in `test-plan`, `test` and `test-ui`: `git fetch --no-tags --unshallow origin HEAD_SHA +refs/heads/main:refs/remotes/origin/main`, plus the PR's base branch when it is not `main`, three attempts, then a red step. Its comment names this job's readers. 2. **Each fetch step takes its own `timeout-minutes`, sized inside its job's wall:** `test-plan` 8, `test-ui` 14, `test` 20, `type-check-ui` 20. Besides the budget, the four copies change in one line: the shared `::error::` now reads "This job's history readers need it, so this job stops here rather than reading from a shallow clone". The old text named the package selection, which is not the reader in `test-ui` or `type-check-ui`. `test`'s comment gains a FETCH BUDGET paragraph with the sizing (below). The other copies' comments say "verbatim but for its budget". 3. **Pins.** - New `describe` in `ui/scripts/__tests__/select-affected-tests.test.ts`. It runs one case per job (`test-plan`, `test`, `test-ui`, `type-check-ui`), reading the parsed YAML. Each case checks that the `actions/checkout` step sets no `fetch-depth`, that the next step is the fetch step, that its budget is a number of at least 8 and under the job's own `timeout-minutes`, and that the job's first history reader comes after it. A fifth case asserts the four fetch steps are deep-equal once `timeout-minutes` is removed. - `check-i18n-en-drift.test.ts` and `check-lockfile-integrity.test.ts` asserted `fetch-depth: 0` on `type-check-ui`. They now assert the fetch step sits before their gate's step. 4. **Prose.** - `ui/scripts/check-i18n-en-drift.mjs`: the shallow-clone refusal said "In CI, give the checkout `fetch-depth: 0`". It now says to fetch HEAD's history and the base branch after the checkout. - In `ci.yml`, two comments that cited `fetch-depth: 0` as the source of `refs/remotes/origin/*` and of the merge base now name the fetch step. ⛔ Not in this PR: any other line of the fetch step, `select-shard-packages.sh`, any other job or workflow, and `ui/.github/**`. ## Why Card #22034, step 2, second PR, released by the maintainer's 「以上都同意」 (recorded 6718334937). It closes what the first PR's hand-off (6723904729) and contract review 6718987786 ③ left: the same fetch step for `type-check-ui`, a depth-1 pin, a sized fetch budget, and the stale `fetch-depth: 0` prose. The step-1 reading is 6724659023. The seat's rulings are on the amended claim. ## Tests - `pnpm exec vitest run` (from `ui/`) on `select-affected-tests.test.ts`, `check-i18n-en-drift.test.ts`, `check-lockfile-integrity.test.ts` and `select-affected-tests-wiring.test.ts`: 4 files, 148 tests passed on `d69b4e47` (`@objectstack/spec` built first; the drift test imports it). - **Base red:** the same three pin files against `main`'s `ci.yml` (`dced781f`, restored into the tree, then `git restore --source=HEAD`): 7 failed. They are the four per-job cases, the deep-equal case, and the two moved pins. - **Ablations** (`node scripts/ablation-replace.mjs`; each restore left the blob equal to HEAD and `git diff HEAD` empty): - On `6d8684a5`, re-adding `with: fetch-depth: 0` to `type-check-ui`'s checkout landed (anchor 1 → 0) and failed the `type-check-ui` case: "type-check-ui's checkout sets fetch-depth: expected +0 to be undefined" (1 failed, 55 passed). - On `d69b4e47`, raising `test-plan`'s fetch budget to its 10-minute wall landed and failed the `test-plan` case: "expected 10 to be less than 10" (1 failed, 55 passed). - On `d69b4e47`, adding one `env` key to the `test-ui` copy only landed and failed "the fetch step is one step, the same in all four jobs but for its budget" (1 failed, 55 passed). ## Acceptance notes **The cost, plainly.** On healthy runs the depth-1 path is not faster: pooled over the three jobs it is about 5 s slower at the median, and `test-plan` is 14 s slower. The gain is the incident tail: the dropped 1.5k heads and 17k tags. Those slowed 5.6-6.5x in the 2026-11-03 incident, against about 3x for shallow fetches, and failed three times on transport on 2026-10-13. No incident fell in either window, so this reading does not measure that gain. Both distributions come from the jobs API, `CI` on `main` (push) and `merge_group`. Times are in seconds, at 1 s grain. | Window | Jobs (`test-plan` + `test` + `test-ui`) | n | p50 | p90 | p99 | max | |---|---|---|---|---|---|---| | Before #31429: "Checkout repository" with `fetch-depth: 0`, 178 runs, 2026-11-08 01:27Z–16:00Z | all three | 2504 | 57 | 64 | 87 | 143 | | After: checkout + fetch step, 22 runs (8 push, 14 queue), 16:00Z–17:22Z | all three | 277 | 62 | 79 | 99 | 107 | | After, fetch step alone | all three | 277 | 50 | 57 | 72 | 85 | - **Per lane, p50 before → after:** - Plan Test Core shards: 57 → 71 (n 164 / 22). - Test Core (N/8): 57 → 62 (917 / 111). - Test UI (N/16): 58 → 60 (1423 / 144). - **Control:** `Type Check UI`, still on full history in the same runs, read p50 53 → 51 (n 89 / 9). The two windows saw the same platform. - **Fetch outcome:** 277 of 277 fetches succeeded. The slowest, 85 s (job 128790425643), ran one attempt. - **Why it is not faster:** the fetch pulls HEAD's whole ancestry, about 20.7k commits. On CI that costs about what the old single full-ref clone did. - **What `Type Check UI` should expect:** about the same shift. Its checkout read p50 53 s before; the new path reads about 60 s. - **Full reading:** 6724659023, with method and per-event splits. The seat re-reads n at ACCEPT. **The budgets: 8 / 14 / 20 / 20 minutes, each inside its job's wall.** - **The rule:** each job gives the fetch what its wall leaves after its measured checkout and body (the max over 2026-11-08's runs), capped at 20 minutes. lint.yml's 25-minute CHECKOUT BUDGET is the same reading for its full-history checkouts. | Job | Wall | Checkout max | Body max | Room | Budget | |---|---|---|---|---|---| | `test-plan` | 10:00 | 0:24 | 0:37 | 8:59 | **8** | | `test-ui` | 30:00 | 0:28 | 15:29 | 14:03 | **14** | | `test` | 60:00 | 0:31 | 24:00 | 35:29 | **20** | | `type-check-ui` | 40:00 | about 0:30 | 14:23 | about 25 | **20** | - **Healthy floor:** the smallest budget, 480 s, still holds the fetch's max 85 s x 5.6 and its p99 72 s x 6.5. Those factors are the 2026-11-03 incident's slowdown of the full-ref fetch at the median. Three healthy attempts plus the 15 s and 30 s sleeps take 300 s. - **The incident tail:** on 2026-11-03, full-history checkouts of 1063-1799 s cancelled 19 Test UI shards at their wall. This step pulls the whole ancestry, so I assume its tail is no shorter; that is unmeasured. 20 minutes holds the tail's low end (1063 s), not its top. - **Per job:** - In `test` and `type-check-ui`, a fetch as slow as the tail's low end now finishes, where a uniform 8 would have gone red. - In `test-ui`, neither end of the tail fits beside the body inside the 30-minute wall, so the budget only ends the red sooner and names the step. - In `test-plan`, a red plan falls back to all eight shards, and each shard fetches for itself. **`type-check-ui`'s readers (inventory rows 5-8 of #31429), re-read on `main` `467a01f7`, all answered by the same step:** | Row | Reader | Answered because | |---|---|---| | 5 | `check:lockfile-integrity` (PR only, `continue-on-error`): `git merge-base origin/BASE HEAD`, `git show REV:pnpm-lock.yaml` | `origin/main` is always fetched, plus `origin/BASE` when the base is another branch; no partial-clone filter, so the blob is local. The step precedes it. | | 6 | `check:i18n-drift`: `merge-base HEAD origin/$GITHUB_BASE_REF`, then `origin/main` | On PR, `origin/BASE`. On queue and push, the merge base with `origin/main`, the same commit as under `fetch-depth: 0`. | | 7 | the affected type-check scope, `turbo --filter=...[BASE]` with the payload `base.sha` or the queue `base_sha` | Both are ancestors of HEAD, so both are in the unshallowed ancestry. | | 8 | spec's build (`build-schemas.ts`) | `merge-base HEAD origin/main`, as row 2. | None of the job's 16 other `pnpm --dir ui run` checks, the local modules they import, or its `tsc` steps runs a history-reading git command. **Left as found:** - The `fetch-depth: 0` prescriptions in `packages/spec/scripts/build-schemas.ts`, `ui/scripts/check-new-cross-file-line-citations.mjs` and `ui/scripts/check-changeset-claims.mjs` are the seat's follow-up card, not this PR. - `test`'s "NOT `fetch-depth: 0`" comment and the old-refspec description are history, and stay. **Serial:** `git merge-tree --write-tree` of `d69b4e47` is clean against PR #31611's head `95c48c6f` and against `main` `c5361e2c`. `main`'s #31639 also edits `select-affected-tests.test.ts`; I ran that file and `select-affected-tests-wiring.test.ts` with the merged contents in the tree: 78 passed. ## Verification I derived the gate union with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 62 families for this diff, on head `d69b4e47`; the same 62 as on `6d8684a5`. I ran every one and captured each exit code before any pipe. `--ran` reconciliation: "62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of them is 3)". - **All 62 exit 0.** Among them: - `pnpm check:required-contexts`, `check:workflow-status-functions`, `check:workflow-step-name-quoting`, `check:shard-attestation`, `check:stall-guard-budget`, `check:stall-guard-headroom`, `check:aggregator-roster`, `check:ci-filter-parity`, `check:pm-expected-skips`; - `node scripts/check-step-collectors.mjs`, `node scripts/check-self-test-workflow-commands.mjs`, `node scripts/ci/plan-test-core-shards.mjs --self-test`; - `pnpm check:dts-closure` (256/256 declaration files across 80 packages) and `pnpm check:dual-build-cjs-loads` (122 entry points; both read the full build `check:type-check-debt` made); - `pnpm check:type-check-debt` under `os-verify-lock.sh` (VERDICT command-exit 0; on `6d8684a5` it also ran the full build the two lines above read); - `pnpm check:pm-dispatch-gates` ("2205 cases pass", fast tier); - `pnpm --dir ui exec vitest run scripts/` under the lock (170 files passed, 2 skipped; 5014 tests passed); - `node scripts/check-closing-keyword-parity.mjs` against this body. - **Outside the derivation:** - `node scripts/pm/check-governed-merges.mjs --branch HEAD`: exit 3, "GOVERNED — a human merge is the review record", landing tier H, `.github/workflows/ci.yml` the one register path, 125 changed lines (under 3000). - `node scripts/pm/dispatch-gates.mjs --tier`: exit 0, "no path-derived mandate". - The PR-body gates on this body (`check-changeset-no-major --base origin/main`, `check-adr-0087-registration`, `check-partof-closing-keyword`): each exit 0. - `pnpm lint`: the diff touches no package, so there is no scoped package lint. - `main` moved 16 commits after the branch point (to `c5361e2c`). Only #31639 touches a file this PR changes, and it merges cleanly and passes (Serial above). Following the seat's ask, no new head was pushed for it. `skip-changeset`: `.github/**` and `ui/scripts/**` ship in no package. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. (test case names only) - [x] 3. No formatter ran; `pnpm lint` passes. (no package touched) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. (publishes nothing: `skip-changeset`) - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (the 维护者速读 block is the lane's required Chinese summary) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jRSntnsNKYKPXp9kFG --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jRSntnsNKYKPXp9kFG)_ <!-- signed: domain:devx#2 · zhuangjianguo · session_01QsR2jRSntnsNKYKPXp9kFG --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 907fdca commit 8210894

5 files changed

Lines changed: 136 additions & 24 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -800,8 +800,10 @@ jobs:
800800
- name: Checkout repository
801801
uses: actions/checkout@v7
802802

803-
# The `test` job's step of the same name, verbatim (its comment says why).
803+
# The `test` job's step of the same name, verbatim but for its budget
804+
# (its comment says why and sizes the budget).
804805
- name: Fetch this commit's history and the base branch
806+
timeout-minutes: 8
805807
env:
806808
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
807809
run: |
@@ -815,7 +817,7 @@ jobs:
815817
if [ "$(git rev-parse --is-shallow-repository)" = true ]; then deepen=(--unshallow); fi
816818
if git fetch --no-tags "${deepen[@]}" origin "$head" "${refspecs[@]}"; then break; fi
817819
if [ "$attempt" = 3 ]; then
818-
echo "::error::Could not fetch $head's history and ${refspecs[*]} after 3 attempts. The package selection reads that history, so this job stops here rather than selecting from a shallow clone."
820+
echo "::error::Could not fetch $head's history and ${refspecs[*]} after 3 attempts. This job's history readers need it, so this job stops here rather than reading from a shallow clone."
819821
exit 1
820822
fi
821823
sleep $((attempt * 15))
@@ -1078,7 +1080,29 @@ jobs:
10781080
# commit, not a branch tip. Three attempts, like the checkout's own fetch,
10791081
# then a red step: a selection read from a shallow clone is the silent
10801082
# all-packages answer above, never a fallback this job may take quietly.
1083+
#
1084+
# FETCH BUDGET: the one line that differs between the four copies. Each
1085+
# job gives the fetch what its wall leaves after its measured checkout
1086+
# and body (the max over 2026-11-08's runs on `main` and the queue),
1087+
# capped at 20 minutes: `test-plan` 8 (wall 10, body 0:37),
1088+
# `test-ui` 14 (wall 30, body 15:29), `test` 20 (wall 60, body 24:00),
1089+
# `type-check-ui` 20 (wall 40, body 14:23). lint.yml's CHECKOUT BUDGET
1090+
# (25) is the same reading for its full-history checkouts.
1091+
# - Healthy, over the 22 runs after this step landed: 277 fetches, all
1092+
# green, p50 50 s, p99 72 s, max 85 s.
1093+
# The smallest budget, 480 s, still holds max x 5.6 and p99 x 6.5
1094+
# (the 2026-11-03 incident's slowdown of the full-ref fetch at the
1095+
# median) and three attempts plus the two sleeps (300 s).
1096+
# - The 2026-11-03 tail: full-history checkouts of 1063-1799 s
1097+
# cancelled 19 Test UI shards at their wall. This step pulls the
1098+
# whole ancestry, so its tail is assumed no shorter. 20 minutes holds
1099+
# the tail's low end (1063 s), not its top. In `test-ui` neither end
1100+
# fits beside the body inside the 30-minute wall, so its budget only
1101+
# ends the red sooner and names the step. A red `test-plan` falls
1102+
# back to all eight shards, and each fetches for itself.
1103+
# A fetch that hangs ends red here, named, instead of eating the wall.
10811104
- name: Fetch this commit's history and the base branch
1105+
timeout-minutes: 20
10821106
env:
10831107
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
10841108
run: |
@@ -1092,7 +1116,7 @@ jobs:
10921116
if [ "$(git rev-parse --is-shallow-repository)" = true ]; then deepen=(--unshallow); fi
10931117
if git fetch --no-tags "${deepen[@]}" origin "$head" "${refspecs[@]}"; then break; fi
10941118
if [ "$attempt" = 3 ]; then
1095-
echo "::error::Could not fetch $head's history and ${refspecs[*]} after 3 attempts. The package selection reads that history, so this job stops here rather than selecting from a shallow clone."
1119+
echo "::error::Could not fetch $head's history and ${refspecs[*]} after 3 attempts. This job's history readers need it, so this job stops here rather than reading from a shallow clone."
10961120
exit 1
10971121
fi
10981122
sleep $((attempt * 15))
@@ -1178,7 +1202,7 @@ jobs:
11781202
# gives this checkout a `ref:`, where parent^1 becomes the PR's
11791203
# previous commit. `merge-base` is right under BOTH checkouts.
11801204
# - Dropping the variable and letting turbo default to `main`. Measured
1181-
# in a CI-shaped clone: `fetch-depth: 0` populates
1205+
# in a CI-shaped clone: the fetch step above populates
11821206
# `refs/remotes/origin/*`, NOT local heads, so there is no local `main`
11831207
# — and turbo does not error, it silently returns the ENTIRE
11841208
# workspace, untouched packages included. Safe, and the whole
@@ -3803,12 +3827,39 @@ jobs:
38033827
TURBO_RUN_SUMMARY: 'true'
38043828

38053829
steps:
3830+
# Depth 1, then the step below: this job reads HEAD's ancestry and the
3831+
# base branch, and no tag or other head (#22034).
38063832
- name: Checkout repository
38073833
uses: actions/checkout@v7
3808-
with:
3809-
# The affected scope below asks turbo for the packages changed since
3810-
# the base commit, which needs the base's history in the clone.
3811-
fetch-depth: 0
3834+
3835+
# The `test` job's step of the same name, verbatim but for its budget
3836+
# (its comment says why and sizes the budget). Here the readers are check:lockfile-integrity
3837+
# (`git merge-base origin/<base> HEAD`), check:i18n-drift (the merge base
3838+
# with origin/<base> or origin/main), the affected type-check scope
3839+
# (`turbo --filter=...[BASE]`) and spec's build. On a shallow clone the
3840+
# lockfile alarm finds no merge base and stays green, and the drift check
3841+
# exits red.
3842+
- name: Fetch this commit's history and the base branch
3843+
timeout-minutes: 20
3844+
env:
3845+
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
3846+
run: |
3847+
refspecs=('+refs/heads/main:refs/remotes/origin/main')
3848+
if [ -n "$PR_BASE_REF" ] && [ "$PR_BASE_REF" != main ]; then
3849+
refspecs+=("+refs/heads/$PR_BASE_REF:refs/remotes/origin/$PR_BASE_REF")
3850+
fi
3851+
head=$(git rev-parse HEAD)
3852+
for attempt in 1 2 3; do
3853+
deepen=()
3854+
if [ "$(git rev-parse --is-shallow-repository)" = true ]; then deepen=(--unshallow); fi
3855+
if git fetch --no-tags "${deepen[@]}" origin "$head" "${refspecs[@]}"; then break; fi
3856+
if [ "$attempt" = 3 ]; then
3857+
echo "::error::Could not fetch $head's history and ${refspecs[*]} after 3 attempts. This job's history readers need it, so this job stops here rather than reading from a shallow clone."
3858+
exit 1
3859+
fi
3860+
sleep $((attempt * 15))
3861+
done
3862+
echo "History: $(git rev-list --count HEAD) commit(s) behind HEAD, shallow=$(git rev-parse --is-shallow-repository), no tags, refs: $(git for-each-ref --format='%(refname)' refs/remotes | tr '\n' ' ')"
38123863
38133864
- name: Setup Node.js
38143865
uses: actions/setup-node@v7
@@ -3865,9 +3916,9 @@ jobs:
38653916
# request's `pnpm-lock.yaml` may not give any package a second physical
38663917
# copy. The script's header has the mechanism and its boundary.
38673918
#
3868-
# It lives here because this job already checks out with
3869-
# `fetch-depth: 0`, so `origin/<base>` and the merge base are in the
3870-
# clone, and because it runs on every change to `pnpm-lock.yaml` (the
3919+
# It lives here because this job's fetch step already brings HEAD's
3920+
# ancestry and `origin/<base>`, so the merge base is in the clone, and
3921+
# because it runs on every change to `pnpm-lock.yaml` (the
38713922
# `ui` filter lists it). Before the install on purpose: the gate reads
38723923
# two text files with Node builtins only, which Lint UI's pre-install
38733924
# import graph check holds. `pull_request` only, because the gate's
@@ -4108,12 +4159,14 @@ jobs:
41084159
- name: Checkout repository
41094160
uses: actions/checkout@v7
41104161

4111-
# The `test` job's step of the same name, verbatim (its comment says why).
4162+
# The `test` job's step of the same name, verbatim but for its budget
4163+
# (its comment says why and sizes the budget).
41124164
# Here the readers are the select step below (`git merge-base` against
41134165
# origin/<base ref>, then select-affected-tests.mjs's `git diff
41144166
# base...HEAD`) and spec's build. On a shallow clone the select step
41154167
# would not fail: it would warn and run the full suite.
41164168
- name: Fetch this commit's history and the base branch
4169+
timeout-minutes: 14
41174170
env:
41184171
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
41194172
run: |
@@ -4127,7 +4180,7 @@ jobs:
41274180
if [ "$(git rev-parse --is-shallow-repository)" = true ]; then deepen=(--unshallow); fi
41284181
if git fetch --no-tags "${deepen[@]}" origin "$head" "${refspecs[@]}"; then break; fi
41294182
if [ "$attempt" = 3 ]; then
4130-
echo "::error::Could not fetch $head's history and ${refspecs[*]} after 3 attempts. The package selection reads that history, so this job stops here rather than selecting from a shallow clone."
4183+
echo "::error::Could not fetch $head's history and ${refspecs[*]} after 3 attempts. This job's history readers need it, so this job stops here rather than reading from a shallow clone."
41314184
exit 1
41324185
fi
41334186
sleep $((attempt * 15))

‎ui/scripts/__tests__/check-i18n-en-drift.test.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1170,13 +1170,17 @@ describe('the gate is wired to run', () => {
11701170
expect(step, 'the check runs before dependencies are installed').toBeGreaterThan(install);
11711171
});
11721172

1173-
it('gives that job a full-history checkout, or the merge base does not exist', () => {
1174-
// actions/checkout defaults to depth 1. Without this the gate cannot resolve
1175-
// a base — and by design that is a red build, not a silent pass, so losing
1176-
// this line breaks CI loudly. The pin is here so the reason survives.
1173+
it("fetches HEAD's history and the base branch before it runs, or the merge base does not exist", () => {
1174+
// actions/checkout defaults to depth 1, and the job keeps that default: the
1175+
// fetch step after it brings HEAD's ancestry and origin/<base> (objectstack#22034).
1176+
// Without it the gate cannot resolve a base — and by design that is a red
1177+
// build, not a silent pass, so losing the step breaks CI loudly. The pin is
1178+
// here so the reason survives.
1179+
const fetch = job.indexOf("- name: Fetch this commit's history and the base branch");
11771180
const step = job.indexOf(`run: ${uiRun('check:i18n-drift')}`);
11781181
expect(step, '`type-check-ui` does not run `pnpm --dir ui run check:i18n-drift`').toBeGreaterThan(-1);
1179-
expect(job.slice(0, step)).toContain('fetch-depth: 0');
1182+
expect(fetch, '`type-check-ui` has no step fetching HEAD\'s history and the base branch').toBeGreaterThan(-1);
1183+
expect(fetch, 'the history is fetched after the check runs').toBeLessThan(step);
11801184
});
11811185

11821186
it('is not hidden from the locale packs by ci.yml path filters', () => {

‎ui/scripts/__tests__/check-lockfile-integrity.test.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -218,14 +218,17 @@ describe('the gate is wired into the root CI (objectstack#26717)', () => {
218218
expect(step()).toContain("if: github.event_name == 'pull_request'");
219219
});
220220

221-
it('checks out deep enough to have a merge base', () => {
222-
// A shallow clone has no merge base, and the script exits 2 rather than
223-
// guess one: a reading that never measured the change.
221+
it('fetches deep enough to have a merge base', () => {
222+
// A shallow clone has no merge base, and the script refuses rather than
223+
// guess one: a reading that never measured the change. The checkout stays
224+
// at depth 1; the fetch step after it brings HEAD's ancestry and
225+
// origin/<base> (objectstack#22034).
224226
const block = withoutComments(jobBlock(rootCi(), JOB));
225227
const checkout = block.indexOf('uses: actions/checkout@');
228+
const fetch = block.indexOf("- name: Fetch this commit's history and the base branch");
226229
expect(checkout).toBeGreaterThan(-1);
227-
expect(block.indexOf('fetch-depth: 0', checkout)).toBeGreaterThan(checkout);
228-
expect(block.indexOf('fetch-depth: 0', checkout)).toBeLessThan(block.indexOf(`- name: ${STEP_NAME}`));
230+
expect(fetch).toBeGreaterThan(checkout);
231+
expect(fetch).toBeLessThan(block.indexOf(`- name: ${STEP_NAME}`));
229232
});
230233

231234
it('⛔ stays an alarm in a required job: continue-on-error, before the install', () => {

‎ui/scripts/__tests__/select-affected-tests.test.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import {
2626
trackedFiles,
2727
} from '../select-affected-tests.mjs';
2828
import { shardTotal } from '../../../scripts/ci-shard-matrix.mjs';
29+
import { parse as parseYaml } from 'yaml';
30+
2931
import { jobBlock, rootCi, triggerBlock, UI_JOBS, uiRun, withoutComments } from './helpers/root-ci';
3032

3133
/**
@@ -727,3 +729,53 @@ describe("the root ci.yml's Test UI job wires the selector fail-open (objectui#1
727729
expect(run).toContain(`pnpm --dir ui exec vitest run --shard=\${{ matrix.shard }}/${shards} --reporter=default --reporter=github-actions "\${filters[@]}"`);
728730
});
729731
});
732+
733+
// objectstack#22034: the four root ci.yml jobs that read git history check out
734+
// at depth 1 and fetch only HEAD's ancestry and the base branch, in one step
735+
// that is the same in all four. `fetch-depth: 0` fetched every head and every
736+
// tag; a checkout that grows it back passes every other gate and silently
737+
// undoes the measurement the fetch step's budget was sized from.
738+
describe("the root ci.yml's history readers check out at depth 1, then fetch (objectstack#22034)", () => {
739+
type Step = { name?: string; uses?: string; with?: Record<string, unknown>; 'timeout-minutes'?: number; run?: string; env?: Record<string, string> };
740+
const jobs = (parseYaml(rootCi()) as { jobs: Record<string, { 'timeout-minutes'?: number; steps: Step[] }> }).jobs;
741+
const FETCH = "Fetch this commit's history and the base branch";
742+
// Each job's first step that reads history: the fetch must come before it.
743+
const FIRST_READER: Record<string, string> = {
744+
'test-plan': "Plan this run's Test Core shards",
745+
test: "Compute this shard's package set",
746+
[UI_JOBS.test]: 'Select the tests this change affects',
747+
[UI_JOBS.typeCheck]: 'check:lockfile-integrity — compare this pull request\'s lockfile with its merge base',
748+
};
749+
750+
for (const [job, reader] of Object.entries(FIRST_READER)) {
751+
it(`${job}: depth-1 checkout, the fetch step next, then the first reader`, () => {
752+
const steps = jobs[job]?.steps ?? [];
753+
expect(steps.length, `ci.yml has no job ${job}`).toBeGreaterThan(0);
754+
const checkout = steps.findIndex((s) => s.uses?.startsWith('actions/checkout@'));
755+
expect(checkout, `${job} has no actions/checkout step`).toBeGreaterThan(-1);
756+
expect(steps[checkout].with?.['fetch-depth'], `${job}'s checkout sets fetch-depth`).toBeUndefined();
757+
expect(steps[checkout + 1]?.name, `${job}: the step after the checkout is not the fetch`).toBe(FETCH);
758+
const first = steps.findIndex((s) => s.name === reader);
759+
expect(first, `${job} has no step named ${reader}`).toBeGreaterThan(checkout + 1);
760+
// Each job sizes its own budget inside its wall (the `test` job's FETCH
761+
// BUDGET comment): never under the healthy floor, never the whole wall.
762+
const budget = steps[checkout + 1]['timeout-minutes'];
763+
const wall = jobs[job]['timeout-minutes'];
764+
expect(typeof budget, `${job}'s fetch step has no timeout-minutes`).toBe('number');
765+
expect(budget).toBeGreaterThanOrEqual(8);
766+
expect(typeof wall, `${job} has no job wall`).toBe('number');
767+
expect(budget!).toBeLessThan(wall!);
768+
});
769+
}
770+
771+
it('the fetch step is one step, the same in all four jobs but for its budget', () => {
772+
const copies = Object.keys(FIRST_READER).map((job) => {
773+
const copy: Step = { ...jobs[job].steps.find((s) => s.name === FETCH) };
774+
delete copy['timeout-minutes'];
775+
return copy;
776+
});
777+
expect(copies[0]).toHaveProperty('run');
778+
for (const copy of copies) expect(copy).toEqual(copies[0]);
779+
expect(copies[0]?.run).toContain('git fetch --no-tags "${deepen[@]}" origin "$head" "${refspecs[@]}"');
780+
});
781+
});

‎ui/scripts/check-i18n-en-drift.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -839,7 +839,7 @@ if (invokedDirectly) {
839839
' answer it confidently. Name a commit that exists in this clone, or pass none and\n' +
840840
' let the merge base with the target branch be found.\n'
841841
: base.shallow
842-
? ' This clone is SHALLOW. In CI, give the checkout `fetch-depth: 0`; locally,\n' +
842+
? ' This clone is SHALLOW. In CI, fetch HEAD\'s history and the base branch after the checkout; locally,\n' +
843843
' run `git fetch --no-tags origin main` (or `git fetch --unshallow`).\n'
844844
: ' Fetch the base branch (`git fetch --no-tags origin main`) and re-run.\n') +
845845
' This is a failure, not a skip: a diff gate with no diff would pass while\n' +

0 commit comments

Comments
 (0)