Skip to content

Commit daf52bc

Browse files
ci(ui-merge): port the four objectui gates that guard published @object-ui packages into root CI (#26854)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9UWsef2B9ahn2NkfVDtnHnS)_ Part of #26717 (the four "port" rows: 23, 21, 7 `test:dist`, 22. The five port-or-retire rows 19, 18, 7-docs, 17 and 26 are measured below and left for the next card.) Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:把保护已发布 `@object-ui/*` 包的四个 objectui 检查接回根 CI。`Build & E2E UI` 多跑两步:Console 首屏体积预算(`check:eager-closure`)和 components 的构建产物测试(`test:dist`),都复用这个 job 已有的构建。新增一个只在 main 的 push/定时批次跑的 job「UI Published Artifacts」:先跑一次和发布相同的 `pnpm build`,再跑两个检查,一个查发布包里有没有测试文件,一个查每个 ESM 入口能不能被 Node 直接加载。`scripts/release-publish.sh` 在 `changeset publish` 之前先扫一遍,扫出问题就拒绝发布。 - 为什么改:objectui 并入后,这四个检查没有在任何地方跑过。18.2.0 发 `@object-ui/*` 时也没有经过发布前的那道扫描。 - 风险与回滚:`Build & E2E UI` 是必需检查,多出的两步会挡 PR。这两步在 main 上实测是绿的,首屏体积还有 481 KB 余量。新 job 不是必需检查,PR 和合并队列都不跑它。但发布审批前读的全量批次里会有它,它红了算 main-red。回滚就是 revert 本 PR。 - 先看:`.github/workflows/ci.yml` 末尾新增的 `ui-published-artifacts` job;`scripts/release-publish.sh` 新增的拒发段;`ui/scripts/__tests__/check-published-dist-tooling.test.ts` 改写的接线测试(这一处要你拍板,见决策卡)。 - 两分钟验证:`node scripts/release-verify-npm.mjs --self-test`,预期 169 例全过,其中第 16 组有 12 例,包括「扫出测试文件就拒绝发布、changeset publish 不会运行」。 - 需要你做的:批准本 PR(Tier H),并在线程里回答行 23 的位置问题(推荐 B,本 PR 就是按 B 做的)。 ## What changes Rows of the objectstack#25697 workflow roster, ported from the objectui workflows deleted by #26058. Invocations were read at that PR's parent `be789779` (`published-dist-gate.yml`, `node-esm-load-gate.yml`, `performance-budget.yml`, `ci.yml` "Test (dist pins)"). | Row | Gate | Where it runs now | Step name | |---|---|---|---| | 22 | `check:eager-closure` | `Build & E2E UI`, over the Console build the job already makes. On a pull request, a step before it resolves the base branch's ceiling constants (objectui#6245 freshness half), ported from `performance-budget.yml`. | `check:eager-closure — resolve the base-branch ceiling constants`, `check:eager-closure — weigh the Console's eager closure against its budget` | | 7 | `test:dist` on `@object-ui/components` | `Build & E2E UI`. The root turbo task's `dependsOn: ["build"]` replays the registry build the SDUI manifest step already ran. | `test:dist — run the built-artifact pins of @object-ui/components` | | 23 | `check:published-dist` | New full-battery job `UI Published Artifacts`, after `pnpm build`, with `--no-build`. Also `scripts/release-publish.sh`, before npm's record starts, refusing on a non-zero exit. | `check:published-dist — scan every published @object-ui tarball for tooling files` | | 21 | `check:node-esm-load` (load leg) | Same job, same build, `--no-build`. It runs whatever the scan above said. | `check:node-esm-load — import every published ESM entry under plain Node` | - **`ui-published-artifacts`** (`UI Published Artifacts`): `if:` is Build & E2E UI's condition plus `github.event_name != 'pull_request' && github.event_name != 'merge_group'`, the full-battery spelling. So a pull request and a queue entry skip it, `main`'s push and 4-hourly schedule runs carry it, and `scheduled-full-run.mjs --release-gate` reads it before a publish approval. It is not a required context. Its setup is spelled out (Node, pnpm, a restore-only store cache, `pnpm install --frozen-lockfile`) rather than the `setup-workspace` composite, because `check-pre-install-import-graph.mjs` reads a job with no literal `pnpm install` as running entirely before `node_modules` exists, and both gates import `typescript`. It restores the build-core / ui-deps turbo seeds (restore-only). `timeout-minutes: 60`, objectui's bound for each of the two builds it replaces. - **Why `pnpm build` + `--no-build`.** The gate's own build mode is not the release's build. `check:published-dist` builds `turbo run build --filter=!@object-ui/site` from `ui/`, which under the monorepo is 128 tasks including `@objectstack/docs`; the release builds `pnpm build` (no docs). `check:node-esm-load`'s build mode runs `pnpm build` in `ui/`, which has no `build` script (measured: `ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL Command "build" not found`, exit 254). Building once with the release's command and grading with `--no-build` is the order the publish path runs the scan in. `--no-build` cannot pass vacuously: a package with nothing under its build output is `no-build-output`, both gates assert a floor, and the ESM gate's provenance leg still refuses an artifact whose turbo log names another tree. - **`scripts/release-publish.sh`** runs `node ui/scripts/check-published-dist-tooling.mjs --no-build` after the `npm_config_globalconfig` unset and before `--start-npm-record`, so the scan's `npm pack --dry-run` runs never land in the record `release-verify-npm.mjs` reads back. A non-zero exit prints an `::error::` and exits 1 before `changeset publish`. - **THE TWO SPEEDS** header in `ci.yml` now names four full-battery jobs. ### Forced test edits - `scripts/release-verify-npm.mjs` battery 16 (floor 8 → 12). Its run of `release-publish.sh` would otherwise run the real scan over an unbuilt tree. It now stubs `npm` on `PATH` (answering `npm pack --dry-run --json` with one build-output file, plus a `dist/__tests__/*.test.js` when asked; the stub writes a debug log into `npm_config_logs_dir` like real npm). New cases: the scan packs every published package before `changeset`; npm's record holds only the publish's log; a tarball with tooling material exits 1 with `Refusing to publish`; and then `changeset` and `git` are never called. The existing call-order case filters out the `npm pack` lines. - `ui/scripts/__tests__/check-published-dist-tooling.test.ts` (own commit, option B on the thread's decision card). The pin `is NOT wired into any per-PR workflow` read the ruling (objectui#4846 comment 5307574139: no per-PR full-repo build) per workflow FILE, so no job of `ci.yml` (which subscribes `pull_request`) could carry the scan. It now reads per JOB: in any workflow that subscribes `pull_request` or `merge_group`, a job that runs the scan must deny both events in its own `if:`, and the loop must find at least one carrier. The retired `runs nightly` pin comes back as `runs in ci.yml's full battery, after the release's build, with --no-build`. If option A or C is chosen instead, this commit is dropped and the job moves or loses its first step. ## Why The roster kept these four as "kept, follow-up port", and #26058 deleted their only runners. Since then nothing has run them: 18.2.0 published `@object-ui/*` without the published-dist scan, and a Console first-load regression would land unmeasured. Card #26717 has the business case. ## Tests Measured on head `f0665932` (the final head `8090d1fa` differs on this PR's paths only in the new job's setup steps, spelled out instead of the composite; the gate commands, scripts and `release-publish.sh` are unchanged) in this worktree, after a cold `pnpm build` (116 tasks, 13m50s under `os-verify-lock`, shared box): <details><summary>The four gates as the CI steps spell them (all exit 0)</summary> - `pnpm --dir ui run check:published-dist --no-build`: exit 0, 241 s. `Inspected 36 published package(s): 5799 tarball file(s), 5654 of them build output, 0 tooling artifact(s) in build output.` - `pnpm --dir ui run check:node-esm-load --no-build`: exit 0, 81 s. `Provenance leg: 35 of 35 gradable entries were built by this tree.` `Load leg: 33 of 36 published ESM entries imported and evaluated.` (create-plugin is bin-only; plugin-dashboard and plugin-map are the ruled `.css` boundary.) - `VITE_BASE_PATH=/console/ pnpm --dir ui/apps/console exec vite build`, then `pnpm --dir ui run check:eager-closure`: exit 0. `Console eager closure is 1950.2 KB gzipped across 89 of 2392 chunks (budget: 2431.6 KB, headroom: 481.4 KB).` Per-chunk, membership and sensitivity halves all ✅; freshness not applicable off a pull request. - `pnpm exec turbo run test:dist --filter=@object-ui/components`: exit 0, `Test Files 2 passed (2)`, `Tests 8 passed (8)`. - PR freshness step rehearsed: its `run:` extracted from this `ci.yml`, run under `bash -e` with `PR_BASE_SHA=8b276385`, `BASE_REF=main`: exit 0, then the checker with `GITHUB_EVENT_NAME=pull_request` and those five variables: exit 0, `Ceiling freshness: all 3 ceiling constants are unchanged on main since this checkout's base`. - `node scripts/release-verify-npm.mjs --self-test`: `OK … 169 cases pass across 16 batteries`. - `pnpm --dir ui exec vitest run scripts/__tests__/check-published-dist-tooling.test.ts`: 33 passed. </details> <details><summary>Counter-probes (each defect turned its step red, then was restored)</summary> One locked script, every mutation restored by trap and checked by sha256 or `git diff HEAD`: - Row 23: `ui/packages/core/dist/probe-26717.test.d.ts` written into a built dist. `check:published-dist --no-build` exit 1: `@object-ui/core [tooling-in-published-output] packages/core/dist/probe-26717.test.d.ts`. The release-path spelling `node ui/scripts/check-published-dist-tooling.mjs --no-build` exit 1 too. Restored (file absent). Battery 16's tooling case is the same refusal through `release-publish.sh`. - Row 21: `export * from './probe26717extless'` appended to built `ui/packages/react/dist/index.js`. `check:node-esm-load --no-build` exit 1: `✗ @object-ui/components (via @object-ui/react): ERR_MODULE_NOT_FOUND …/react/dist/probe26717extless`, 21 lines naming it. Restored, sha matches. - Row 22: `eager-closure.json` grown by 700,000 gzip bytes. `check:eager-closure` exit 1: `❌ Console eager closure is 2633.8 KB gzipped across 89 chunks — 202.2 KB over the 2431.6 KB budget.` Restored, sha matches; re-run exit 0. - Row 7: through `scripts/ablation-replace.mjs`, `namespace: 'page'` → `namespace: 'probe26717'` on `page:header`'s registration in `ui/packages/components/src/renderers/layout/containers.tsx`. `turbo run test:dist --filter=@object-ui/components` rebuilt the package and exited 1: `× registers page:header from the BUILT bundle`. Restored (`git diff HEAD` empty); re-run exit 0. - Release path, mutation-free: battery 16's `a tarball carrying tooling material fails the script` and `changeset publish never runs` cases. CI run ids per gate go on the card once a `main` push run and this PR's run exist: this PR's run covers the two `Build & E2E UI` steps; `UI Published Artifacts` first runs on the push that lands this. </details> ## Acceptance notes - **The five rows left for the next card**, measured on `origin/main` `8b276385`: no root workflow runs any of them. - Row 19 `lockfile-integrity` (`check:lockfile-integrity`): root `validate-deps.yml` has no duplicate-dependency step (its steps are changeset-fixed, override-consistency, vendor-export-contract-resolve, OSV and outdated), so no overlap to retire it on. One ui test reads the script. - Row 18 `lockfile-dedupe` (`check:lockfile-dedupe`): no root workflow runs `pnpm dedupe --check`; #25920 measured 145 runs / 90 days, 0 failures. One ui test reads it. - Row 7-docs `@object-ui/site`: no root workflow builds it, and root `pnpm build` excludes `./ui*/apps/site`. - Row 17 `live-e2e` (`test:e2e:live:ci`): no root carrier; one ui test reads `playwright.live.config`. - Row 26 `shadcn-check` (`shadcn:check`): no root carrier; three ui tests read `shadcn-sync`. - **The rest of objectui's budget step is not ported.** It also held the entry chunk to 350 KB gzipped, ran `check-eager-locale-catalogues.mjs` (alias `check:eager-locale-catalogues`), `check:sdui-registration-pins`, a package size report and a PR comment. None of them runs in root CI today. The card names `check:eager-closure` only; the rest is next-card material. - **Found, not fixed here:** `pnpm --dir ui run check:node-esm-load` without `--no-build` exits 1 in the monorepo (`ui/package.json` has no `build` script). And `check:published-dist`'s own build mode builds `@objectstack/docs`, unlike the release it claims to match. Both are ui script defects; this PR routes around neither: CI and the release call the gates with `--no-build` after the release's own build. Fixing the scripts' build modes is a ui-lane card (and `ui/package.json` is held by open #26556). - `release.yml`'s comment over the publish step still describes `release-publish.sh` as `changeset publish` followed by one tag push; it now runs the scan first. Not edited here (outside the claimed surface). - The `check-node-esm-load.mjs` docblock says `--no-build` "is wired into nothing"; this PR wires it. Not edited (ui script, outside the surface). - No new required context. `Build & E2E UI` (required) gains three steps, about 1 to 2 minutes on top of its measured 312 s run (schedule run 40537439467) against its 35-minute bound. ## Verification Final head `8090d1fa` (the branch with `origin/main` 0ea90c7 merged in; no overlapping files). After a fresh `pnpm build` (13m22s), the sweep ran every command `node scripts/pm/dispatch-gates.mjs --commands` derives for this change set (4 paths) under `os-verify-lock` (VERDICT command-exit 0, 44m35s). <details><summary>Gate sweep and reconciliation</summary> - 71 derived commands, 71 run, all `exit 0`. Among them: `pnpm --dir ui exec vitest run scripts/` (178 files, 5066 tests passed, 2 skipped), `node scripts/release-verify-npm.mjs --self-test` (169 cases across 16 batteries), `pnpm check:published-files`, and `check:pm-dispatch-gates` (fast tier, 1935 cases). - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran <ledger>`: `✓ 71 derived famil(ies) accounted for — 71 run, 0 NOT-MEASURED`, 0 UNRUN. - Not run locally: `pnpm lint` and the full test suites, which are CI's run. </details> ## 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. - [ ] 3. No formatter ran; `pnpm lint` passes. (no formatter ran; `pnpm lint` is CI's run) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset. (nothing publishes: workflow, root scripts and a ui test; `skip-changeset`) - [x] 6. `packages/spec` touched: n/a. - [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 repo's Chinese brief). - [ ] The *Before Submitting* list in CONTRIBUTING.md is done. (the local subset per the dispatch contract; the full suite is CI's) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01ADHkmNiJfZmoXAbfcJSAfx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d7dd47a commit daf52bc

4 files changed

Lines changed: 264 additions & 19 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 170 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,13 @@ concurrency:
117117
# A `pull_request` or `merge_group` run carries the FAST SET: `Build Core`,
118118
# `Test Core` (affected-only) and `Build Docs`. `main`'s 4-hourly `schedule` run,
119119
# its `push` runs and an on-demand `workflow_dispatch` carry the FULL BATTERY:
120-
# the fast set plus the three jobs below that spell
120+
# the fast set plus the four jobs below that spell
121121
#
122122
# github.event_name != 'pull_request' && github.event_name != 'merge_group'
123123
#
124-
# in their `if:` — `temporal-conformance`, `dogfood` (three shards) and
125-
# `dogfood-verify`. Nothing is deleted: the jobs moved off the
126-
# per-PR path. A red in one of them is a main-red for the owning lane, and the
124+
# in their `if:` — `temporal-conformance`, `dogfood` (three shards),
125+
# `dogfood-verify` and `ui-published-artifacts`. Nothing is deleted: the jobs
126+
# moved off the per-PR path, the last one never had one (#26717). A red in one of them is a main-red for the owning lane, and the
127127
# publish lane reads the full battery before its approval (release.yml's
128128
# `full-battery` job, `scripts/ci/scheduled-full-run.mjs --release-gate`).
129129
#
@@ -4237,6 +4237,74 @@ jobs:
42374237
fi
42384238
echo "Console build artifact is ready."
42394239
4240+
# ── Two objectui gates that read this job's build (#26717) ──────────
4241+
# Rows 7 and 22 of the objectstack#25697 workflow roster, "kept,
4242+
# follow-up port": objectui ran them in its own workflows, which have run
4243+
# nowhere since #26058 deleted `ui/.github/workflows/`. Folded in here
4244+
# because this job already builds what they read, so neither pays a
4245+
# build of its own (row 22's note: no second Console build).
4246+
#
4247+
# The Console's eager-closure budget (objectui's performance-budget.yml).
4248+
# The Vite build above writes `ui/apps/console/dist/eager-closure.json`
4249+
# and `chunk-membership.json` in every build; the checker weighs them
4250+
# against the ceilings it carries, next to the reasoning behind each, so
4251+
# no size is copied into this file. Exit 1 is a size verdict, exit 2 a
4252+
# gauge that produced no trustworthy reading; both fail the step.
4253+
#
4254+
# On a pull request the checker also asks whether the ceilings it was
4255+
# checked out with are still the ones on the base branch (objectui#6245:
4256+
# GitHub does not re-run a PR's checks when the base moves). This step
4257+
# hands it the base branch's copy of the checker at two commits. It is
4258+
# `continue-on-error` and that is NOT a fail-open: when it cannot write
4259+
# both files the checker finds the variables unset and exits 2.
4260+
- name: check:eager-closure — resolve the base-branch ceiling constants
4261+
if: ${{ github.event_name == 'pull_request' }}
4262+
continue-on-error: true
4263+
env:
4264+
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
4265+
BASE_REF: ${{ github.base_ref }}
4266+
run: |
4267+
set -euo pipefail
4268+
CHECKER='ui/scripts/check-eager-closure-budget.mjs'
4269+
OUT="${RUNNER_TEMP}/eager-closure-ceilings"
4270+
mkdir -p "$OUT"
4271+
4272+
# The commit the merge ref was computed against.
4273+
if ! git cat-file -e "${PR_BASE_SHA}^{commit}" 2>/dev/null; then
4274+
git fetch --no-tags --depth=1 origin "$PR_BASE_SHA"
4275+
fi
4276+
git cat-file -p "${PR_BASE_SHA}:${CHECKER}" > "$OUT/pr-base.partial"
4277+
mv "$OUT/pr-base.partial" "$OUT/pr-base.mjs"
4278+
4279+
# The base branch as it stands now, which the merge would land on.
4280+
git fetch --no-tags --depth=1 origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
4281+
BASE_SHA=$(git rev-parse "refs/remotes/origin/${BASE_REF}")
4282+
git cat-file -p "${BASE_SHA}:${CHECKER}" > "$OUT/base.partial"
4283+
mv "$OUT/base.partial" "$OUT/base.mjs"
4284+
4285+
# Written only after both moves: a half-written pair would let the
4286+
# checker compare against a file it takes for the base branch.
4287+
{
4288+
echo "EAGER_CLOSURE_PR_BASE_SOURCE=$OUT/pr-base.mjs"
4289+
echo "EAGER_CLOSURE_BASE_SOURCE=$OUT/base.mjs"
4290+
echo "EAGER_CLOSURE_PR_BASE_SHA=$PR_BASE_SHA"
4291+
echo "EAGER_CLOSURE_BASE_SHA=$BASE_SHA"
4292+
echo "EAGER_CLOSURE_BASE_REF=$BASE_REF"
4293+
} >> "$GITHUB_ENV"
4294+
4295+
echo "Ceilings resolved: ${PR_BASE_SHA} (this checkout's base) vs ${BASE_SHA} (${BASE_REF} now)"
4296+
4297+
- name: check:eager-closure — weigh the Console's eager closure against its budget
4298+
run: pnpm --dir ui run check:eager-closure
4299+
4300+
# The built-artifact pins of @object-ui/components (objectui's ci.yml
4301+
# "Test (dist pins)"). They import the package's `dist/`, which the
4302+
# Test UI lane never builds (it resolves every package to `src`), so this
4303+
# is the only lane that runs them. The task's `dependsOn: ["build"]`
4304+
# replays the build the SDUI manifest step above already ran.
4305+
- name: test:dist — run the built-artifact pins of @object-ui/components
4306+
run: pnpm exec turbo run test:dist --filter=@object-ui/components
4307+
42404308
- name: Get the Playwright version
42414309
id: playwright-version
42424310
run: |
@@ -4289,3 +4357,101 @@ jobs:
42894357
with:
42904358
path: ~/.cache/ms-playwright
42914359
key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
4360+
4361+
# ── UI Published Artifacts: two objectui gates over a full build (#26717) ─
4362+
# Rows 23 and 21 of the objectstack#25697 workflow roster, "kept, follow-up
4363+
# port" (objectui's published-dist-gate.yml and node-esm-load-gate.yml), both
4364+
# of which have run nowhere since #26058 deleted `ui/.github/workflows/`.
4365+
# Each one grades BUILT artifacts of every published @object-ui package, so
4366+
# each costs a full build, and objectui#4846's ruling (comment 5307574139)
4367+
# refused that build on every pull request. So this job is in the full
4368+
# battery only (THE TWO SPEEDS above `jobs:`): a pull request and a queue
4369+
# entry skip it, `main`'s push and schedule runs carry it, and the publish
4370+
# lane reads it before its approval. The blocking copy of the published-dist
4371+
# scan is on the publish path itself, `scripts/release-publish.sh`, which
4372+
# refuses to run `changeset publish` when the scan exits non-zero.
4373+
# `ui/scripts/__tests__/check-published-dist-tooling.test.ts` pins both
4374+
# halves of that.
4375+
#
4376+
# The build is the release's own (`pnpm build`, the first command of
4377+
# `pnpm run release`), and both gates then read it with `--no-build`, the
4378+
# order the publish path runs the scan in. `--no-build` cannot pass
4379+
# vacuously: a published package with nothing built is `no-build-output`,
4380+
# each gate asserts a floor on how much it inspected, and the ESM gate's
4381+
# provenance leg refuses an artifact whose turbo task log names another tree.
4382+
ui-published-artifacts:
4383+
name: UI Published Artifacts
4384+
needs: filter
4385+
# Build & E2E UI's condition, in the full battery only.
4386+
if: ${{ !cancelled() && needs.filter.outputs.ui_tree != 'false' && (needs.filter.outputs.ui != 'false' || needs.filter.outputs.ui_consumers != 'false') && github.event_name != 'pull_request' && github.event_name != 'merge_group' }}
4387+
runs-on: ubuntu-latest
4388+
# objectui gave each gate's full build 60 minutes, "generous, because a
4389+
# timeout here reads as a gate failure". One build now serves both.
4390+
timeout-minutes: 60
4391+
permissions:
4392+
contents: read
4393+
env:
4394+
NODE_OPTIONS: --max-old-space-size=4096
4395+
4396+
steps:
4397+
- name: Checkout repository
4398+
uses: actions/checkout@v7
4399+
4400+
# The setup is spelled out, as in the ui jobs above, rather than the
4401+
# `setup-workspace` composite: `ui/scripts/check-pre-install-import-graph.mjs`
4402+
# finds a job's install by its `pnpm install` line, and reads every step
4403+
# of a job with none as running before `node_modules` exists, which the
4404+
# two gates below (they import `typescript`) cannot.
4405+
- name: Setup Node.js
4406+
uses: actions/setup-node@v7
4407+
with:
4408+
node-version-file: .nvmrc
4409+
4410+
- name: Setup pnpm
4411+
uses: ./.github/actions/setup-pnpm
4412+
4413+
- name: Get pnpm store directory
4414+
shell: bash
4415+
run: |
4416+
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
4417+
4418+
# Restore-only, here and below: the per-push jobs above own saving the
4419+
# store and the turbo seeds.
4420+
- name: Restore pnpm cache
4421+
uses: actions/cache/restore@v6
4422+
with:
4423+
path: ${{ env.STORE_PATH }}
4424+
key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }}
4425+
restore-keys: |
4426+
${{ runner.os }}-pnpm-store-v3-
4427+
4428+
- name: Install dependencies
4429+
run: pnpm install --frozen-lockfile
4430+
4431+
- name: Restore Turbo cache
4432+
uses: actions/cache/restore@v6
4433+
with:
4434+
path: .turbo/cache
4435+
key: ${{ runner.os }}-turbo-build-core-${{ github.ref_name }}-${{ github.sha }}
4436+
restore-keys: |
4437+
${{ runner.os }}-turbo-build-core-${{ github.ref_name }}-
4438+
${{ runner.os }}-turbo-ui-deps-${{ github.ref_name }}-
4439+
4440+
- name: Build every package the release publishes
4441+
id: build
4442+
run: pnpm build
4443+
4444+
# objectui#4846: no published tarball may carry tooling material
4445+
# (`__tests__/`, `*.test.*`, `*.bench.*`, a `*.tsbuildinfo`, …). The file
4446+
# list is `npm pack --dry-run`'s, i.e. what npm would publish.
4447+
- name: check:published-dist — scan every published @object-ui tarball for tooling files
4448+
run: pnpm --dir ui run check:published-dist --no-build
4449+
4450+
# objectui#4538: every published ESM entry must load under Node's own
4451+
# resolver, with no bundler. The per-PR half is the specifier leg in
4452+
# Type Check UI (`check:esm-specifiers`); this is the load leg, the only
4453+
# one that sees a package broken by a DEPENDENCY. It runs whatever the
4454+
# scan above said, so one run reports both.
4455+
- name: check:node-esm-load — import every published ESM entry under plain Node
4456+
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
4457+
run: pnpm --dir ui run check:node-esm-load --no-build

‎scripts/release-publish.sh‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,21 @@ set -euo pipefail
6060
# recomputes it and overrides whatever we export.
6161
unset npm_config_globalconfig NPM_CONFIG_GLOBALCONFIG
6262

63+
# No published tarball may carry tooling material (objectui#4846): a test, a
64+
# mock, a benchmark that imports `vitest`, a `*.tsbuildinfo`. objectui ran this
65+
# scan in its own `changeset:publish`; the ruling on objectui#4846 (comment
66+
# 5307574139) put it on the publish path, where a red scan stops the publish
67+
# while stopping it is still free. `--no-build` because `pnpm run release` has
68+
# just built what is about to be packed, so the scan reads exactly those
69+
# artifacts; a published package with nothing built is a finding, never a pass.
70+
#
71+
# Before npm's record starts, on purpose: the scan's own `npm pack --dry-run`
72+
# runs must not land in the record that `release-verify-npm.mjs` reads back.
73+
if ! node ui/scripts/check-published-dist-tooling.mjs --no-build; then
74+
echo "::error::check-published-dist-tooling found tooling material in (or no build output for) a published @object-ui package. Refusing to publish; the scan above names each package and file."
75+
exit 1
76+
fi
77+
6378
# Keep npm's own answer for every package.
6479
#
6580
# `changeset publish` (@changesets/cli 3.0.3, its pnpm branch) counts a

‎scripts/release-verify-npm.mjs‎

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
317317
'13. The backfill builds from the version commit\'s tree, as the publish does': 13,
318318
'14. The 18.0.0 publish window: console readable at +16m22s, past the old 900 s': 14,
319319
'15. Lag or loss: npm\'s own answer per package, parsed from its real log': 26,
320-
'16. release-publish.sh keeps npm\'s record, prints it, and still pushes tags only after a clean publish': 8,
320+
'16. release-publish.sh keeps npm\'s record, prints it, and still pushes tags only after a clean publish': 12,
321321
});
322322

323323
// DELETING an entry silences that battery's floor exactly as effectively as
@@ -3014,9 +3014,13 @@ export async function selfTest() {
30143014
battery('16. release-publish.sh keeps npm\'s record, prints it, and still pushes tags only after a clean publish');
30153015
{
30163016
// The script runs from this repository's root (it calls this file by its
3017-
// repo path), with `changeset` and `git` stubbed on PATH. The stub
3017+
// repo path), with `changeset`, `git` and `npm` stubbed on PATH. The stub
30183018
// changeset records what npm would see and writes one npm log, for the
3019-
// anchor, into the logs dir it was handed.
3019+
// anchor, into the logs dir it was handed. The stub npm answers the
3020+
// published-dist scan's `npm pack --dry-run --json` for every published
3021+
// @object-ui package: one build-output file, plus a test file under
3022+
// `dist/` when STUB_PACK_TOOLING is set, which is the defect that scan
3023+
// refuses (objectui#4846).
30203024
const box = mkdtempSync(join(tmpdir(), 'release-verify-npm-publish-sh-'));
30213025
try {
30223026
const bin = join(box, 'bin');
@@ -3030,10 +3034,23 @@ export async function selfTest() {
30303034
'exit "${STUB_CHANGESET_EXIT:-0}"',
30313035
].join('\n'));
30323036
writeFileSync(join(bin, 'git'), '#!/bin/sh\necho "git $*" >> "$STUB_LOG"\n');
3037+
writeFileSync(join(bin, 'npm'), [
3038+
'#!/bin/sh',
3039+
'echo "npm $1" >> "$STUB_LOG"',
3040+
// Real npm writes a debug log of every run into its logs dir; so does
3041+
// this stub, so a scan moved inside npm's record would be counted.
3042+
'[ -z "$npm_config_logs_dir" ] || echo "0 verbose cli node npm" > "$npm_config_logs_dir/stub-npm-$$-debug-0.log"',
3043+
'if [ -n "$STUB_PACK_TOOLING" ]; then',
3044+
' echo \'[{"files":[{"path":"package.json"},{"path":"dist/index.js"},{"path":"dist/__tests__/index.test.js"}]}]\'',
3045+
'else',
3046+
' echo \'[{"files":[{"path":"package.json"},{"path":"dist/index.js"}]}]\'',
3047+
'fi',
3048+
].join('\n'));
30333049
chmodSync(join(bin, 'changeset'), 0o755);
30343050
chmodSync(join(bin, 'git'), 0o755);
3051+
chmodSync(join(bin, 'npm'), 0o755);
30353052

3036-
const run = (exitCode) => {
3053+
const run = (exitCode, { packTooling = false } = {}) => {
30373054
const runnerTemp = mkdtempSync(join(box, 'runner-'));
30383055
const recordDir = npmRecordDir({ RUNNER_TEMP: runnerTemp });
30393056
mkdirSync(recordDir, { recursive: true });
@@ -3049,6 +3066,7 @@ export async function selfTest() {
30493066
STUB_SEEN: seen,
30503067
STUB_LOG: stubLog,
30513068
STUB_CHANGESET_EXIT: String(exitCode),
3069+
...(packTooling ? { STUB_PACK_TOOLING: '1' } : {}),
30523070
npm_config_globalconfig: '/home/runner/.config/pnpm/rc',
30533071
},
30543072
encoding: 'utf8',
@@ -3059,6 +3077,7 @@ export async function selfTest() {
30593077
seen: existsSync(seen) ? readFileSync(seen, 'utf8') : '',
30603078
calls: existsSync(stubLog) ? readFileSync(stubLog, 'utf8').trim().split('\n') : [],
30613079
stale: existsSync(join(recordDir, '2026-01-01T00_00_00_000Z-debug-0.log')),
3080+
recordLogs: readdirSync(recordDir),
30623081
recordDir,
30633082
};
30643083
};
@@ -3073,11 +3092,24 @@ export async function selfTest() {
30733092
good.out.includes(`${ANCHOR_PACKAGE}@${cli.version} — npm ACCEPTED the upload: PUT 200 in 812 ms`) && /npm's answers: 1 of \d+ package\(s\) accepted/.test(good.out),
30743093
good.out.split('\n').filter((l) => l.includes('npm\'s answers')).join(' | '),
30753094
);
3076-
t('tags are pushed after the publish, in one push', good.calls.join(' ; ') === 'changeset ; git push origin --tags', good.calls.join(' ; '));
3095+
const notPack = (calls) => calls.filter((c) => c !== 'npm pack');
3096+
t('tags are pushed after the publish, in one push', notPack(good.calls).join(' ; ') === 'changeset ; git push origin --tags', good.calls.join(' ; '));
3097+
const packs = good.calls.filter((c) => c === 'npm pack').length;
3098+
t(
3099+
'the published-dist scan packs every published @object-ui package BEFORE the publish',
3100+
packs > 0 && good.calls.lastIndexOf('npm pack') < good.calls.indexOf('changeset') && good.out.includes(`Inspected ${packs} published package(s)`),
3101+
`${packs} npm pack call(s); ${good.out.split('\n').filter((l) => l.startsWith('Inspected ')).join(' | ')}`,
3102+
);
3103+
t('and its npm runs stay out of npm\'s record: the publish\'s log is the only one in it', good.recordLogs.length === 1 && !good.recordLogs.some((f) => f.startsWith('stub-npm-')), good.recordLogs.join(', '));
30773104

30783105
const bad = run(1);
30793106
t('a failed publish still fails the script, with npm\'s answers printed first', bad.status === 1 && bad.out.includes('npm\'s answer per package'), `status=${bad.status}`);
30803107
t('and pushes no tag', !bad.calls.some((c) => c.startsWith('git')), bad.calls.join(' ; '));
3108+
3109+
// The defect the scan exists for: a test file inside a published `dist/`.
3110+
const tooling = run(0, { packTooling: true });
3111+
t('a tarball carrying tooling material fails the script, naming the scan', tooling.status === 1 && tooling.out.includes('tooling-in-published-output') && tooling.out.includes('Refusing to publish'), `status=${tooling.status} ${tooling.out.slice(-300)}`);
3112+
t('and changeset publish never runs, so nothing reaches npm and no tag is pushed', notPack(tooling.calls).length === 0, tooling.calls.join(' ; '));
30813113
} finally {
30823114
rmSync(box, { recursive: true, force: true });
30833115
}

0 commit comments

Comments
 (0)