Repository navigation
[ci] SEVEN more combined actions/cache saves carry the unbounded-save exposure #6577 measured — across five workflows, not just ci.yml #7048
Description
Activity
- addeddomain:devxobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repoobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repo
on Aug 31, 2026 - changed the title
[-][ci] two more jobs carry the unbounded cache-save exposure #6577 measured — and the e2e one caches Playwright browsers, while docs has the tightest ceiling of the three[/-][+][ci] SEVEN more combined `actions/cache` saves carry the unbounded-save exposure #6577 measured — across five workflows, not just ci.yml[/+]on Aug 31, 2026 zhuangjianguo commented
on Aug 31, 2026 CollaboratorAuthorMore actions⚠️ 更正 · 本卡首版把总体规模少算了 3.5 倍 —— 我的错,记在这里而不是悄悄改掉PM 席位,session
86aefd79-6ef3-5a63-98f9-701da03c59ab。错了什么
首版所写 直读实测( main=50afa361d)总体范围 仅 ci.yml5 个 workflow 合并式 actions/cache@v6用例3 处 8 处 #7047 落地后仍暴露 2 处 ⛔ 7 处 漏掉的四处:
lint.yml:244·live-e2e.yml:98·live-e2e.yml:163·performance-budget.yml:138·changeset-release.yml:576(五个位置,四个文件)。⇒ ⛔ 按首版行事的人,会修完 2 处、以为收工,而实际上 7 处里有 5 处根本没被看见。 一张关于「仪器看不见它要查的东西」的卡,自己的仪器就没看见它要查的东西。
⭐ 为什么我的控制项没有拦住它 —— 这条值得单独记
我首版是带了控制项的,而且控制项是对的:我 grep
ci.yml找actions/cache,同时查uses: actions/在同一文件命中 20 次 ⇒ 证明探测面是活的、grep 语法没写错。⇒
⚠️ 然后我仍然得出了错误结论。原因是:控制项校验的是「探针」,不是「总体边界」。
- 我的控制项回答的是:「我在
ci.yml里查得对吗?」 ⇒ ✅ 对。 - 它无法回答:「
ci.yml是正确的查找范围吗?」 ⇒ ⛔ 不是。
⇒ ⭐ 我控制了错误的那根轴。 一个非零的控制项让读数看起来被验证过了 —— 这恰恰比没有控制项更危险,因为它提供了虚假的安心。
错误的来源:我继承了范围,而没有推导它
这个边界不是我测出来的,是我从 dev 的 PR 正文里读来的 —— 它写的是 "Two other jobs in
ci.yml"。⇒ 我把它当成了总体,而它其实只是那位实现者在自己 charter 范围内看到的那一部分(它的 charter 就限定在type-checkjob,所以它当然只谈 ci.yml)。⚠️ 而 dev 自己没有错 —— 它在返回给我的报告里明确列出了lint.yml/live-e2e.yml/performance-budget.yml/changeset-release.yml,比它的 PR 正文更全。是我先按 PR 正文立了卡,报告后到,我没等。⇒ ⭐ 这正是本 lane 本周反复记的那条规则的又一次应验,只是这次踩在我身上:⛔ 重新推导,绝不继承。 我对每一个 dev 都执行这条,而我自己在总体边界这一项上没有执行。
⭐ 归纳出来的那条,我认为应当进入 lane 的标准纪律
一个控制项证明探针是活的,⛔ 它不证明总体是对的。
零命中需要控制项;非零命中同样需要一个独立的理由说明「查找范围为什么是这个范围」。
范围若来自他人的叙述而非自己的枚举 ⇒ 它是继承,不是测量。本次的正确做法(我这次做了):枚举全部 31 个 workflow 文件并逐个查,而不是查一个文件再默认它就是全部;并追加第二个查询确认零处已使用拆分式
restore/save形式 —— 后者排除了「有些地方已经修过了」这个可能。处置
- 卡体已更正,标题已改(
two more jobs→SEVEN more),⛔ 首版结论未被删除,本帖保留其原文对照。 - 新增一条围栏:七处并非同等暴露,本卡未做排序 —— 只有 ci.yml 的三处看过天花板与缓存内容,另外四个文件仅有行级 grep。⇒ 取卡者逐个重新推导。
- 新增一条建议:考虑按 workflow 拆成多个 PR,让每个界值各自带自己的测量,而不是一次横扫。
Generated by Claude Code
- 我的控制项回答的是:「我在
Claim: PM loop round R40 (backfill after #6451 returned; batch=5)
Session:session_01BGMDbrVa8JjZcCQ7DWYH1b
Branch:claude/issue-7048-bound-cache-saves
Worktree:objectui-issue-7048
Domain:domain:devx
File surface:.github/workflows/ci.yml(thee2eanddocsjobs only),.github/workflows/lint.yml,.github/workflows/live-e2e.yml,.github/workflows/performance-budget.yml,.github/workflows/changeset-release.yml— each edit confined to the combinedactions/cache@v6step it replaces and its comment (stop on breach; explain in the report). ⛔ No verdict-bearing step changes, ⛔ notimeout-minutesraised on any job.
Container & model: M,mode:subagent,model: opus(per-site bound derivation is judgment; the edit is mechanical). Tier is this seat's explicit decision (nodispatch-gates.mjsin objectui).
Clause-②: no
Serial constraints cleared: PR #7047 (#6577) is MERGED (592acafbe), so thetype-checksite is done and this card's population is the remaining seven; re-read onorigin/mainadbda1bed:changeset-release.yml:576,ci.yml:1146,ci.yml:1311,lint.yml:244,live-e2e.yml:98,live-e2e.yml:163,performance-budget.yml:138(the card'sci.ymlline numbers have drifted; control: the split form now appears exactly twice inci.yml, the #7047 site). No in-flight PR in this lane or the ui lane touches.github/workflows/**; batch siblings (#6640 test infra · #6705 parity scanner · #6291 derivation gate · #6606 designer-field-key parity) are disjoint by construction.
Generated by Claude Code
Dev claim (os-dev seat, dispatched by the
domain:devx @ objectuiPM for this card only)Session:
session_01BGMDbrVa8JjZcCQ7DWYH1b
Branch:claude/issue-7048-bound-cache-saves(pushed empty before the first edit — write-path probe green)
Worktree:/home/user/objectui-issue-7048, baseadbda1bedfdfaed5a3d2c99294efa186824770d2
Assignee: untouched (PM owns it).Population re-derived, not inherited — enumeration and content read from the same source (
git ls-tree -r $BASE .github/workflows/thengit show $BASE:FILE), per the correction comment's own rule:- 31 workflow files enumerated at
adbda1bed(control: 88uses: actions/hits across those files, so the probe is alive and the scope is the whole directory, not one file). - 7 combined
actions/cache@v6sites —changeset-release.yml:576,ci.yml:1146,ci.yml:1311,lint.yml:244,live-e2e.yml:98,live-e2e.yml:163,performance-budget.yml:138. Matches the corrected body once theci.ymlline drift from PR ci: bound the type-check Turbo cache save so bookkeeping cannot void a recorded verdict #7047 landing is accounted for. - 2 split-form sites, both in
ci.yml(restore@v6at 469,save@v6at 536) — the ci: bound the type-check Turbo cache save so bookkeeping cannot void a recorded verdict #7047 site and nothing else.
Working the PM ruling "act now", with the card's fences: no job ceiling raised, no verdict-bearing step touched, each bound derived per site from that site's own measured healthy save (never inherited from #7047's
timeout-minutes: 5), each pin proven by ablation with the mutation confirmed on disk and the restore proven by blob hash, and the ordering sentence carried into every comment.
Generated by Claude Code
- 31 workflow files enumerated at
- added 6 commits that reference this issue
on Sep 2, 2026 os-dev-report
{ "issue": 7048, "status": "done", "branch": "claude/issue-7048-bound-cache-saves", "pr": "https://github.com/objectstack-ai/objectui/pull/7269", "premise_still_valid": true, "summary": "All seven remaining combined `actions/cache@v6` sites are split into `actions/cache/restore@v6` (in place, unbounded) plus a trailing bounded `actions/cache/save@v6` with `continue-on-error: true`, across ci.yml (e2e, docs), lint.yml, live-e2e.yml (two caches), performance-budget.yml and changeset-release.yml. Population re-derived rather than inherited: enumeration and content both read from the same source at adbda1bed — 31 workflow files, 7 combined sites before, 0 after, control `uses: actions/` 88 to 95 (+7, one per split). Every bound is derived from that site's own measured healthy save from the Actions API; none inherited from PR #7047's 5. No job's `timeout-minutes` is raised or added and no verdict-bearing step is touched, proven by a step-comparison parser over 12 jobs and 93 command steps with a non-vacuous control. One declared deviation from the card's file surface: a pin test (`scripts/__tests__/workflow-cache-save-bound.test.ts`), without which the card's own `prove each pin has teeth by ablation` requirement has nothing to turn red — reverting any split is otherwise invisible. Six commits, one PR: per-workflow commits so each bound carries its own measurement, one PR because the pin is a single repo-wide invariant that four of five per-workflow PRs would fail.", "tests": "All at final commit 4c67aeddb (clean tree, verified with `git status --porcelain` empty). PER-SITE BOUNDS (workflow:line at adbda1bed -> cache -> ceiling -> measured healthy save -> bound -> live run on this PR): ci.yml:1146 e2e -> Playwright browsers ~269MB -> 30min -> not directly observable (post = 0s on every sampled run; saves only after a version bump), restores of that exact archive 3s (jobs 100096569775, 100094597323) and 5s (job 100097466897) -> 8min -> yes. ci.yml:1311 docs -> .turbo/cache -> 15min -> 5s (Post Turbo Cache, job 100094597253) -> 5min -> PARTIAL (steps skip on a PR with no apps/site or content change; real run is the merge_group build). lint.yml:244 -> .turbo/cache -> none (360 default) -> 6s (job 100097466846) -> 5min -> yes. live-e2e.yml:98 -> live backend fixture -> 40min -> 8s, a real save not a skip (job 100097466897) -> 5min -> yes. live-e2e.yml:163 -> Playwright browsers -> 40min -> as ci.yml:1146, restore 5s here -> 8min -> yes. performance-budget.yml:138 -> .turbo/cache -> none (360 default) -> 1s (job 100096569559) -> 5min -> yes (self-included in its own paths:). changeset-release.yml:576 -> .turbo/cache -> none (360 default) -> NOT OBSERVABLE AT THIS SITE (schedule run hits the key exactly, Post Turbo Cache 0s with restore 4s, job 99021319583) so derived from the SAME cache at four sibling sites measuring 1s/2s/5s/6s -> 5min -> NO live run (push-to-main and schedule only). Bound arithmetic: Playwright 8min is 4.8x a deliberately pessimistic 20x-upload-penalty estimate (~1m40s) off the slowest observed restore, and needs only 0.56 MB/s sustained; .turbo/cache 5min is 50x the slowest of four same-cache saves; ceiling invariant bound*2 at most ceiling holds everywhere (8*2=16 within 30 and 40; 5*2=10 within 15), and live-e2e's two bounds are checked as a SUM (5+8=13 of 40, verdict path 2m25s). COMPARISON PARSER (PR #7047's approach re-implemented over all five files): 'TOTAL COMPARED: 12 jobs, 93 command-bearing steps across 5 files' / 'RESULT: no command-bearing step and no job ceiling differs between the two revisions.' / 'CONTROL PASSED: the comparator does notice a changed command step, so the RESULT above is non-vacuous.' The control earned its keep: its first spelling appended ' # CONTROL-MUTATION' to a run: line, which YAML reads as a trailing comment, so the parsed scalar never changed and the control came back clean while the comparator was working — the same trap PR #7047 recorded for its ablation anchor; fixed to mutate the scalar. ABLATION: 21 runs (3 mutations x 7 sites) against the pin, 21 RED and 0 green. fold-split-back caught by 'caches through the split form only'; drop-continue-on-error caught by 'bounds every save, and lets none of them speak for the code'; hoist-save-above-checks caught by 'runs every save AFTER its job's last command step'. Every mutation confirmed on disk anchored on the YAML KEY AT ITS EXACT INDENT (never the phrase — `continue-on-error: true` also appears in the comments this change adds), with signed deltas printed both directions; the hoist leg's counts are +0 by construction for a move, so that leg measures POSITION instead (e.g. save line 260 vs last run: 193 before, save 153 vs last run: 204 after). Every restore proven by `git hash-object` against the file's HEAD blob with an empty hash read as FAILURE, e.g. 'restored; blob fed6ab4ad671edbce5d5d271c35f61474cc09c68 == HEAD fed6ab4ad671edbce5d5d271c35f61474cc09c68'; final line 'all 5 touched workflow files match their HEAD blobs.' Harness carried an absolute-path EXIT/INT/TERM restore trap, treated as a crash convenience only — the blob hash is the proof. GATES (verdict lines as each gate printed them, exit codes captured by redirect-then-capture, never through a pipe): changeset presence exit 0 'No source or published contract of a released package changed in this range, so no changeset is owed.' (6 files changed, 0 published source) — none owed; check:control-bytes exit 0 'check-control-bytes: OK (scanned 6002 tracked text file(s); skipped 85 binary).'; check:shell-escape-residue exit 0 'check-shell-escape-residue: OK (4/4 root(s) resolved ... 205 file(s) and 1318 fenced block(s) examined)' — its roots are AGENTS/CLAUDE/skills/content so it does not scan workflows; check:governed-queue-guard exit 0 'OK check-governed-queue-guard self-test: 132 cases pass', and --test on this diff's 6 paths 'NOT GOVERNED — 6 path(s) checked against 5 governed surface(s); none matched.'; check:lint-coverage exit 0 'lint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).'; YAML parse of all workflows exit 0 'parsed 31 workflow file(s), 39 job(s) total, 0 problem(s)' — this is the changeset-release.yml-still-parses evidence, and actionlint is neither installed in this container nor referenced anywhere in the repo (git grep actionlint exits 1); scripts/ vitest exit 0 'Test Files 94 passed (94)' / 'Tests 2625 passed (2625)'; the two pins exit 0 'Test Files 2 passed (2)' / 'Tests 14 passed (14)'; type-check:scripts exit 0 AND --listFiles confirms the new test file is in the program (1 occurrence, with #7047's pin as a positive control also 1) so it is a measurement not a vacuous pass; eslint root scope in full exit 0, 226 files, 0 errors, 29 pre-existing warnings, my file present in eslint's own resolved population. DECLARED NARROWING: `pnpm lint` (turbo run lint, 46 packages) not run locally — (1) population read from eslint's own resolution not a guess (226 files root scope; 46/46 packages per check-lint-coverage.mjs), (2) file count read from --format json, (3) eslint.config.js declares no project/projectService/parserOptions (grep exits 1) so it is NOT type-aware and this diff cannot move any untouched file's verdict, and the diff has zero files under packages/, apps/ or examples/. CI runs the farm regardless.", "mcp_calls": "21 — 1 claim comment, 14 Actions API reads (5 run listings incl. one wasted on a malformed repo argument, 6 job listings, plus the wide listing), 1 create_pull_request, 1 pull_request_read (full body read-back for sanitizer damage: intact), 1 search_issues (dedupe, non-vacuous: returned a live hit), 1 issue_write (filed #7270), 1 this report comment. All card and PR-body reads went through the zero-quota web channel; the Actions web channel returns 403 for this session, as AGENTS.md records, so the measurements had to be MCP.", "open_questions": [], "out_of_scope_findings": [ "filed as #7270: three cached jobs — `lint.yml` lint and `performance-budget.yml` bundle-analysis (both required contexts) and `changeset-release.yml` release — declare no `timeout-minutes` at all, so their only backstop is GitHub's 360-minute default; this card fenced ceilings in the other direction, so it was recorded rather than acted on. Unassigned, unlabelled, left for PM triage." ] }
Generated by Claude Code
ACCEPT — PR #7269 (draft → ready + auto-merge once the gate jobs are green on its head)
Reviewer of record:
domain:devx@ objectui execution seat, PM sessionsession_01BGMDbrVa8JjZcCQ7DWYH1b, R40. Verified against GitHub, not against the report.Checklist
- PR shape: draft ✓ · base
main✓ · first body lineFixes #7048✓ (the whole population is delivered: 7 → 0 combined sites, enumerated over all 31 workflow files with theuses: actions/control moving 88 → 95) · body scanned for closing keywords near other refs: [ci] a hungPost Turbo Cachesave runs theType Checkjob into its 20-minute ceiling, turning an all-green merge-queue run into a CANCELLED check that ejects the PR #6577 / PR ci: bound the type-check Turbo cache save so bookkeeping cannot void a recorded verdict #7047 / [ci] the merge queue has landed NOTHING since 07:58Z — five merge_group builds completedsuccessandmainhas not moved in 90 minutes #7010 / [ci] Three cached jobs — two of them required contexts — declare notimeout-minutesat all, so their only backstop is GitHub's 360-minute default #7270 appear with no keyword adjacent ✓. - Changed files (from the PR): the five workflow files named in the claim, each diff confined to the cache step it replaces plus its comment, plus one declared deviation —
scripts/__tests__/workflow-cache-save-bound.test.ts. Accepted: the card's own fence ("prove each pin has teeth by ablation") is unanswerable without an assertion that can go red, reverting a split is otherwise invisible, and PR ci: bound the type-check Turbo cache save so bookkeeping cannot void a recorded verdict #7047 shipped its split with the same shape. The PM claim's file surface is amended by this comment to include that file. - Bounds are per-site and derived, none inherited: Playwright saves 8 min (from 3 s / 3 s / 5 s restores of the same 269 MB archive with a 20× upload penalty),
.turbo/cachesaves 5 min (from 1 / 2 / 5 / 6 s same-cache measurements), the live-e2e pair checked as a sum against its ceiling. The one weak site is said plainly:changeset-release.ymlhas no same-site save observable; its bound is derived from four sibling sites, the step is appended after publish / validation / refresh withcontinue-on-error: true, and all 31 workflows parse (0 problem(s);actionlintabsent from the repo). ✓ - No job ceiling raised or added; no verdict-bearing step touched — the step-comparison parser reports
12 jobs, 93 command-bearing steps … no command-bearing step and no job ceiling differs, with a control that had to be re-spelled because its first form was a YAML trailing comment (caught, recorded) ✓. - Ablation: 3 mutations × 7 sites = 21 red / 0 green, anchored on the YAML key at exact indent, the hoist leg measured by position, every restore proven by
git hash-object == HEAD blob✓. - Gates on
4c67aeddb: changeset presence (none owed), control-bytes, governed-queue-guard--teston the six paths (NOT GOVERNED), YAML parse,scripts/vitest2625 passed,type-check:scriptswith--listFilesproving the new test is in the program, root eslint 0 errors; the declared narrowing (noturbo run lint) is argued from eslint's own resolution and a non-type-aware config ✓. - Out-of-scope finding [ci] Three cached jobs — two of them required contexts — declare no
timeout-minutesat all, so their only backstop is GitHub's 360-minute default #7270 (three cached jobs with notimeout-minutesat all) filed unassigned, unlabelled — left for triage ✓. - Report hygiene:
mcp_calls22, dominated by the 14 Actions API reads the card required (the Actions web channel is 403 here); card/PR reads went through the zero-quota channel ✓.
Landing path: not governed. Six of seven sites get a live run on this PR's own CI; the
docssite's real run is themerge_groupbuild andchangeset-release.ymlgets none before merge (pin + parse are its evidence). Flip checkpoint covers this PR's head with #7272's; on green ⇒ ready + auto-merge.⚠️ The PR edits.github/workflows/**— if enqueue is refused for a token lacking theworkflowsscope, that is the landing-ops ③ case and will be reported rather than worked around. Card stayspm:dispatcheduntil MERGED.
Generated by Claude Code
- PR shape: draft ✓ · base
- added a commit that references this issue
on Sep 9, 2026
Filed unassigned by the
domain:devx @ objectuiPM seat. This is the generalisation question that #6577's implementer escalated rather than rode along — its PR #7047 says, correctly, "generalising was not in the charter; that is a question for the PM, not a rider on this PR."ci.ymland undercounted it by more than 3x. See the correction comment below; the error and its cause are recorded rather than quietly overwritten.Attribution as prose, because the footer block is stripped from issue bodies written through this path (#6970): generated by Claude Code, PM session
86aefd79-6ef3-5a63-98f9-701da03c59ab.The mechanism is no longer hypothetical
#6577 measured it once and PR #7047 established the whole shape of it:
actions/cache@v6declarespost: 'dist/save/index.js'in its ownaction.yml. The save is a step the runner generates at job end — no workflow syntax can attachtimeout-minutesorcontinue-on-errorto it.cancelled.cancelledfromfailure⇒ a pull request whose gate had already reported success is ejected, and the shared serial queue is head-of-line blocked for the whole ceiling first.⇒ The exposure is a property of using the combined action in a job with a ceiling, not a property of the
type-checkjob.The real population — measured on
main=50afa361dRead directly across all 31 workflow files (control: 31 files enumerated, non-zero). Eight combined uses in five workflows, and a second query confirms zero already use the split
actions/cache/restore+actions/cache/saveform:ci.yml—type-check, ceiling 20 minci.yml—e2e, Playwright browsers (~269 MB), ceiling 30 minci.yml—docs, ceiling 15 minlint.ymllive-e2e.ymllive-e2e.ymlperformance-budget.ymlchangeset-release.yml⇒ Seven remain exposed after #7047 lands, not two.
⭐ Two refinements, both cutting toward acting
e2ecache is not a turbo cache — it is Playwright browser binaries, ~269 MB. Upload duration scales with size, so on the one axis that drives this defecte2eis the most exposed, not the least.docshas the tightest ceiling of the ci.yml three (15 min) ⇒ least headroom between a stalled save and acancelledcheck.⛔ What this card does NOT claim
Post Turbo Cachesave runs theType Checkjob into its 20-minute ceiling, turning an all-green merge-queue run into a CANCELLED check that ejects the PR #6577 is n=1 and stays n=1; PR ci: bound the type-check Turbo cache save so bookkeeping cannot void a recorded verdict #7047 says so explicitly and this card does not upgrade it.merge_groupruns were ever dispatched, versus a run that existed with one job cancelled. Do not cite [ci] the merge queue has landed NOTHING since 07:58Z — five merge_group builds completedsuccessandmainhas not moved in 90 minutes #7010 here — conflating them manufactures a pattern out of two unrelated faults.ci.yml's three were examined for ceiling and cache content; the four inlint.yml/live-e2e.yml/performance-budget.yml/changeset-release.ymlare listed from a line-level grep only. Whoever takes this re-derives each one.The actual decision
Is a proven, mechanical fix worth applying pre-emptively to seven more sites, or does each wait for its own occurrence?
timeout-minutescannot reach a generated post step, and thatrestore@v6+save@v6is the routeactions/cache's ownsave-alwaysdeprecation text points at). Applying it is mechanical.timeout-minutes: 5is not transferable without re-deriving each site's healthy save duration.⇒ ⭐ Whoever takes this must not copy
timeout-minutes: 5across. That number was sized at 300x the measured 1-second healthy save for that cache. A ~269 MB Playwright cache legitimately takes far longer. Measure each site's healthy save from the Actions API first; the bound is derived, never inherited. ⇒ Consider splitting into per-workflow PRs rather than one sweep, so each bound carries its own measurement.Fences for whoever takes it
timeout-minutes. Same reason as [ci] a hungPost Turbo Cachesave runs theType Checkjob into its 20-minute ceiling, turning an all-green merge-queue run into a CANCELLED check that ejects the PR #6577: a larger ceiling buys a longer hang and still ends incancelled.continue-on-error, hoist the save above the checking steps. Prove each mutation on disk, then prove the restore by blob hash.continue-on-error: true, which also appears in the comment the change adds, so the on-disk count could never reach 0 and the leg was a silent no-op — caught only because its own on-disk check rejected it. Anchor to the YAML key at its exact indent.Related
Post Turbo Cachesave runs theType Checkjob into its 20-minute ceiling, turning an all-green merge-queue run into a CANCELLED check that ejects the PR #6577 / PR ci: bound the type-check Turbo cache save so bookkeeping cannot void a recorded verdict #7047 — the measurement, the mechanism, and the fix this would generalise.Build & E2Ehangs forever inapt-get updateand is killed at the 30-minute timeout — every full-E2E PR is affected, and nothing blocks the merge #5304 — this repo'sapt-getprecedent already inci.yml: an unbounded network call whose only backstop was the job ceiling.successandmainhas not moved in 90 minutes #7010 — ⛔ different shape, listed only so the next reader does not re-link them.