diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31febc892d1..aec5024d936 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,13 +117,13 @@ concurrency: # A `pull_request` or `merge_group` run carries the FAST SET: `Build Core`, # `Test Core` (affected-only) and `Build Docs`. `main`'s 4-hourly `schedule` run, # its `push` runs and an on-demand `workflow_dispatch` carry the FULL BATTERY: -# the fast set plus the three jobs below that spell +# the fast set plus the four jobs below that spell # # github.event_name != 'pull_request' && github.event_name != 'merge_group' # -# in their `if:` — `temporal-conformance`, `dogfood` (three shards) and -# `dogfood-verify`. Nothing is deleted: the jobs moved off the -# per-PR path. A red in one of them is a main-red for the owning lane, and the +# in their `if:` — `temporal-conformance`, `dogfood` (three shards), +# `dogfood-verify` and `ui-published-artifacts`. Nothing is deleted: the jobs +# 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 # publish lane reads the full battery before its approval (release.yml's # `full-battery` job, `scripts/ci/scheduled-full-run.mjs --release-gate`). # @@ -4237,6 +4237,74 @@ jobs: fi echo "Console build artifact is ready." + # ── Two objectui gates that read this job's build (#26717) ────────── + # Rows 7 and 22 of the objectstack#25697 workflow roster, "kept, + # follow-up port": objectui ran them in its own workflows, which have run + # nowhere since #26058 deleted `ui/.github/workflows/`. Folded in here + # because this job already builds what they read, so neither pays a + # build of its own (row 22's note: no second Console build). + # + # The Console's eager-closure budget (objectui's performance-budget.yml). + # The Vite build above writes `ui/apps/console/dist/eager-closure.json` + # and `chunk-membership.json` in every build; the checker weighs them + # against the ceilings it carries, next to the reasoning behind each, so + # no size is copied into this file. Exit 1 is a size verdict, exit 2 a + # gauge that produced no trustworthy reading; both fail the step. + # + # On a pull request the checker also asks whether the ceilings it was + # checked out with are still the ones on the base branch (objectui#6245: + # GitHub does not re-run a PR's checks when the base moves). This step + # hands it the base branch's copy of the checker at two commits. It is + # `continue-on-error` and that is NOT a fail-open: when it cannot write + # both files the checker finds the variables unset and exits 2. + - name: check:eager-closure — resolve the base-branch ceiling constants + if: ${{ github.event_name == 'pull_request' }} + continue-on-error: true + env: + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + BASE_REF: ${{ github.base_ref }} + run: | + set -euo pipefail + CHECKER='ui/scripts/check-eager-closure-budget.mjs' + OUT="${RUNNER_TEMP}/eager-closure-ceilings" + mkdir -p "$OUT" + + # The commit the merge ref was computed against. + if ! git cat-file -e "${PR_BASE_SHA}^{commit}" 2>/dev/null; then + git fetch --no-tags --depth=1 origin "$PR_BASE_SHA" + fi + git cat-file -p "${PR_BASE_SHA}:${CHECKER}" > "$OUT/pr-base.partial" + mv "$OUT/pr-base.partial" "$OUT/pr-base.mjs" + + # The base branch as it stands now, which the merge would land on. + git fetch --no-tags --depth=1 origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" + BASE_SHA=$(git rev-parse "refs/remotes/origin/${BASE_REF}") + git cat-file -p "${BASE_SHA}:${CHECKER}" > "$OUT/base.partial" + mv "$OUT/base.partial" "$OUT/base.mjs" + + # Written only after both moves: a half-written pair would let the + # checker compare against a file it takes for the base branch. + { + echo "EAGER_CLOSURE_PR_BASE_SOURCE=$OUT/pr-base.mjs" + echo "EAGER_CLOSURE_BASE_SOURCE=$OUT/base.mjs" + echo "EAGER_CLOSURE_PR_BASE_SHA=$PR_BASE_SHA" + echo "EAGER_CLOSURE_BASE_SHA=$BASE_SHA" + echo "EAGER_CLOSURE_BASE_REF=$BASE_REF" + } >> "$GITHUB_ENV" + + echo "Ceilings resolved: ${PR_BASE_SHA} (this checkout's base) vs ${BASE_SHA} (${BASE_REF} now)" + + - name: check:eager-closure — weigh the Console's eager closure against its budget + run: pnpm --dir ui run check:eager-closure + + # The built-artifact pins of @object-ui/components (objectui's ci.yml + # "Test (dist pins)"). They import the package's `dist/`, which the + # Test UI lane never builds (it resolves every package to `src`), so this + # is the only lane that runs them. The task's `dependsOn: ["build"]` + # replays the build the SDUI manifest step above already ran. + - name: test:dist — run the built-artifact pins of @object-ui/components + run: pnpm exec turbo run test:dist --filter=@object-ui/components + - name: Get the Playwright version id: playwright-version run: | @@ -4289,3 +4357,101 @@ jobs: with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} + + # ── UI Published Artifacts: two objectui gates over a full build (#26717) ─ + # Rows 23 and 21 of the objectstack#25697 workflow roster, "kept, follow-up + # port" (objectui's published-dist-gate.yml and node-esm-load-gate.yml), both + # of which have run nowhere since #26058 deleted `ui/.github/workflows/`. + # Each one grades BUILT artifacts of every published @object-ui package, so + # each costs a full build, and objectui#4846's ruling (comment 5307574139) + # refused that build on every pull request. So this job is in the full + # battery only (THE TWO SPEEDS above `jobs:`): a pull request and a queue + # entry skip it, `main`'s push and schedule runs carry it, and the publish + # lane reads it before its approval. The blocking copy of the published-dist + # scan is on the publish path itself, `scripts/release-publish.sh`, which + # refuses to run `changeset publish` when the scan exits non-zero. + # `ui/scripts/__tests__/check-published-dist-tooling.test.ts` pins both + # halves of that. + # + # The build is the release's own (`pnpm build`, the first command of + # `pnpm run release`), and both gates then read it with `--no-build`, the + # order the publish path runs the scan in. `--no-build` cannot pass + # vacuously: a published package with nothing built is `no-build-output`, + # each gate asserts a floor on how much it inspected, and the ESM gate's + # provenance leg refuses an artifact whose turbo task log names another tree. + ui-published-artifacts: + name: UI Published Artifacts + needs: filter + # Build & E2E UI's condition, in the full battery only. + 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' }} + runs-on: ubuntu-latest + # objectui gave each gate's full build 60 minutes, "generous, because a + # timeout here reads as a gate failure". One build now serves both. + timeout-minutes: 60 + permissions: + contents: read + env: + NODE_OPTIONS: --max-old-space-size=4096 + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + # The setup is spelled out, as in the ui jobs above, rather than the + # `setup-workspace` composite: `ui/scripts/check-pre-install-import-graph.mjs` + # finds a job's install by its `pnpm install` line, and reads every step + # of a job with none as running before `node_modules` exists, which the + # two gates below (they import `typescript`) cannot. + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + + - name: Setup pnpm + uses: ./.github/actions/setup-pnpm + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + # Restore-only, here and below: the per-push jobs above own saving the + # store and the turbo seeds. + - name: Restore pnpm cache + uses: actions/cache/restore@v6 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-v3-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store-v3- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Restore Turbo cache + uses: actions/cache/restore@v6 + with: + path: .turbo/cache + key: ${{ runner.os }}-turbo-build-core-${{ github.ref_name }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-turbo-build-core-${{ github.ref_name }}- + ${{ runner.os }}-turbo-ui-deps-${{ github.ref_name }}- + + - name: Build every package the release publishes + id: build + run: pnpm build + + # objectui#4846: no published tarball may carry tooling material + # (`__tests__/`, `*.test.*`, `*.bench.*`, a `*.tsbuildinfo`, …). The file + # list is `npm pack --dry-run`'s, i.e. what npm would publish. + - name: check:published-dist — scan every published @object-ui tarball for tooling files + run: pnpm --dir ui run check:published-dist --no-build + + # objectui#4538: every published ESM entry must load under Node's own + # resolver, with no bundler. The per-PR half is the specifier leg in + # Type Check UI (`check:esm-specifiers`); this is the load leg, the only + # one that sees a package broken by a DEPENDENCY. It runs whatever the + # scan above said, so one run reports both. + - name: check:node-esm-load — import every published ESM entry under plain Node + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: pnpm --dir ui run check:node-esm-load --no-build diff --git a/scripts/release-publish.sh b/scripts/release-publish.sh index 5375945bfce..6d228a57a8b 100755 --- a/scripts/release-publish.sh +++ b/scripts/release-publish.sh @@ -60,6 +60,21 @@ set -euo pipefail # recomputes it and overrides whatever we export. unset npm_config_globalconfig NPM_CONFIG_GLOBALCONFIG +# No published tarball may carry tooling material (objectui#4846): a test, a +# mock, a benchmark that imports `vitest`, a `*.tsbuildinfo`. objectui ran this +# scan in its own `changeset:publish`; the ruling on objectui#4846 (comment +# 5307574139) put it on the publish path, where a red scan stops the publish +# while stopping it is still free. `--no-build` because `pnpm run release` has +# just built what is about to be packed, so the scan reads exactly those +# artifacts; a published package with nothing built is a finding, never a pass. +# +# Before npm's record starts, on purpose: the scan's own `npm pack --dry-run` +# runs must not land in the record that `release-verify-npm.mjs` reads back. +if ! node ui/scripts/check-published-dist-tooling.mjs --no-build; then + 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." + exit 1 +fi + # Keep npm's own answer for every package. # # `changeset publish` (@changesets/cli 3.0.3, its pnpm branch) counts a diff --git a/scripts/release-verify-npm.mjs b/scripts/release-verify-npm.mjs index 0e8c4fed80a..8b1f84becf2 100644 --- a/scripts/release-verify-npm.mjs +++ b/scripts/release-verify-npm.mjs @@ -317,7 +317,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ '13. The backfill builds from the version commit\'s tree, as the publish does': 13, '14. The 18.0.0 publish window: console readable at +16m22s, past the old 900 s': 14, '15. Lag or loss: npm\'s own answer per package, parsed from its real log': 26, - '16. release-publish.sh keeps npm\'s record, prints it, and still pushes tags only after a clean publish': 8, + '16. release-publish.sh keeps npm\'s record, prints it, and still pushes tags only after a clean publish': 12, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -3014,9 +3014,13 @@ export async function selfTest() { battery('16. release-publish.sh keeps npm\'s record, prints it, and still pushes tags only after a clean publish'); { // The script runs from this repository's root (it calls this file by its - // repo path), with `changeset` and `git` stubbed on PATH. The stub + // repo path), with `changeset`, `git` and `npm` stubbed on PATH. The stub // changeset records what npm would see and writes one npm log, for the - // anchor, into the logs dir it was handed. + // anchor, into the logs dir it was handed. The stub npm answers the + // published-dist scan's `npm pack --dry-run --json` for every published + // @object-ui package: one build-output file, plus a test file under + // `dist/` when STUB_PACK_TOOLING is set, which is the defect that scan + // refuses (objectui#4846). const box = mkdtempSync(join(tmpdir(), 'release-verify-npm-publish-sh-')); try { const bin = join(box, 'bin'); @@ -3030,10 +3034,23 @@ export async function selfTest() { 'exit "${STUB_CHANGESET_EXIT:-0}"', ].join('\n')); writeFileSync(join(bin, 'git'), '#!/bin/sh\necho "git $*" >> "$STUB_LOG"\n'); + writeFileSync(join(bin, 'npm'), [ + '#!/bin/sh', + 'echo "npm $1" >> "$STUB_LOG"', + // Real npm writes a debug log of every run into its logs dir; so does + // this stub, so a scan moved inside npm's record would be counted. + '[ -z "$npm_config_logs_dir" ] || echo "0 verbose cli node npm" > "$npm_config_logs_dir/stub-npm-$$-debug-0.log"', + 'if [ -n "$STUB_PACK_TOOLING" ]; then', + ' echo \'[{"files":[{"path":"package.json"},{"path":"dist/index.js"},{"path":"dist/__tests__/index.test.js"}]}]\'', + 'else', + ' echo \'[{"files":[{"path":"package.json"},{"path":"dist/index.js"}]}]\'', + 'fi', + ].join('\n')); chmodSync(join(bin, 'changeset'), 0o755); chmodSync(join(bin, 'git'), 0o755); + chmodSync(join(bin, 'npm'), 0o755); - const run = (exitCode) => { + const run = (exitCode, { packTooling = false } = {}) => { const runnerTemp = mkdtempSync(join(box, 'runner-')); const recordDir = npmRecordDir({ RUNNER_TEMP: runnerTemp }); mkdirSync(recordDir, { recursive: true }); @@ -3049,6 +3066,7 @@ export async function selfTest() { STUB_SEEN: seen, STUB_LOG: stubLog, STUB_CHANGESET_EXIT: String(exitCode), + ...(packTooling ? { STUB_PACK_TOOLING: '1' } : {}), npm_config_globalconfig: '/home/runner/.config/pnpm/rc', }, encoding: 'utf8', @@ -3059,6 +3077,7 @@ export async function selfTest() { seen: existsSync(seen) ? readFileSync(seen, 'utf8') : '', calls: existsSync(stubLog) ? readFileSync(stubLog, 'utf8').trim().split('\n') : [], stale: existsSync(join(recordDir, '2026-01-01T00_00_00_000Z-debug-0.log')), + recordLogs: readdirSync(recordDir), recordDir, }; }; @@ -3073,11 +3092,24 @@ export async function selfTest() { 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), good.out.split('\n').filter((l) => l.includes('npm\'s answers')).join(' | '), ); - t('tags are pushed after the publish, in one push', good.calls.join(' ; ') === 'changeset ; git push origin --tags', good.calls.join(' ; ')); + const notPack = (calls) => calls.filter((c) => c !== 'npm pack'); + t('tags are pushed after the publish, in one push', notPack(good.calls).join(' ; ') === 'changeset ; git push origin --tags', good.calls.join(' ; ')); + const packs = good.calls.filter((c) => c === 'npm pack').length; + t( + 'the published-dist scan packs every published @object-ui package BEFORE the publish', + packs > 0 && good.calls.lastIndexOf('npm pack') < good.calls.indexOf('changeset') && good.out.includes(`Inspected ${packs} published package(s)`), + `${packs} npm pack call(s); ${good.out.split('\n').filter((l) => l.startsWith('Inspected ')).join(' | ')}`, + ); + 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(', ')); const bad = run(1); 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}`); t('and pushes no tag', !bad.calls.some((c) => c.startsWith('git')), bad.calls.join(' ; ')); + + // The defect the scan exists for: a test file inside a published `dist/`. + const tooling = run(0, { packTooling: true }); + 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)}`); + t('and changeset publish never runs, so nothing reaches npm and no tag is pushed', notPack(tooling.calls).length === 0, tooling.calls.join(' ; ')); } finally { rmSync(box, { recursive: true, force: true }); } diff --git a/ui/scripts/__tests__/check-published-dist-tooling.test.ts b/ui/scripts/__tests__/check-published-dist-tooling.test.ts index d2724c5294b..4cb86711859 100644 --- a/ui/scripts/__tests__/check-published-dist-tooling.test.ts +++ b/ui/scripts/__tests__/check-published-dist-tooling.test.ts @@ -19,7 +19,7 @@ import { packedFiles, toolingConventionFrom, } from '../check-published-dist-tooling.mjs'; -import { rootWorkflow, rootWorkflowFiles } from './helpers/root-ci'; +import { jobBlock, rootCi, rootWorkflow, rootWorkflowFiles, uiRun, withoutComments } from './helpers/root-ci'; /** * objectui#4846 — no published package's build output may carry tooling material. @@ -473,20 +473,52 @@ describe('wiring', () => { ); }); - // `runs nightly` retired with objectstack#25712. It read objectui's - // `published-dist-gate.yml`, which runs nowhere under the monorepo: GitHub - // runs the root `.github/workflows/` alone. That workflow is row 23 of the - // objectstack#25697 workflow roster, "kept, follow-up port", and the port - // brings the pin back against the root file that schedules the scan. + // The full-battery half (objectstack#26717). objectui scheduled the scan + // nightly in `published-dist-gate.yml` (roster row 23 of objectstack#25697); + // under the monorepo it runs in root `ci.yml`'s `ui-published-artifacts` job, + // which a pull request and a queue entry skip, over the release's own build. + const FULL_BATTERY_JOB = 'ui-published-artifacts'; + const PER_PR_EVENTS = ['pull_request', 'merge_group']; - it('is NOT wired into any per-PR workflow — the ruling forbade a per-PR full-repo build', () => { + /** A job's own `if:` line, at the job's indent; '' when it has none. */ + const jobIf = (block: string) => block.match(/^ {4}if: (.*)$/m)?.[1] ?? ''; + const deniesPerPrEvents = (condition: string) => + PER_PR_EVENTS.every((event) => condition.includes(`github.event_name != '${event}'`)); + + it("runs in ci.yml's full battery, after the release's build, with --no-build", () => { + const block = withoutComments(jobBlock(rootCi(), FULL_BATTERY_JOB)); + expect(block).toContain(`run: ${uiRun('check:published-dist')} --no-build`); + expect(block.indexOf('run: pnpm build\n')).toBeGreaterThan(-1); + expect(block.indexOf('run: pnpm build\n')).toBeLessThan(block.indexOf(uiRun('check:published-dist'))); + expect(deniesPerPrEvents(jobIf(block)), jobIf(block)).toBe(true); + }); + + it('is NOT run by any per-PR or queue job — the ruling forbade a per-PR full-repo build', () => { // Asked of the root `.github/workflows/`, the only workflows GitHub runs - // under the monorepo (objectstack#25712); `ui/.github/` runs nowhere. - const perPr = rootWorkflowFiles().filter((file) => /^on:[\s\S]*?\bpull_request:/m.test(rootWorkflow(file))); + // under the monorepo (objectstack#25712); `ui/.github/` runs nowhere. A + // workflow that subscribes a per-PR event may carry the scan only in a job + // whose own `if:` denies both of them: the ruling is about the build a + // pull request pays for, and such a job costs it nothing. + const perPr = rootWorkflowFiles().filter((file) => + new RegExp(`^on:[\\s\\S]*?\\b(${PER_PR_EVENTS.join('|')}):`, 'm').test(rootWorkflow(file)), + ); expect(perPr.length).toBeGreaterThan(0); // the scan itself must not be empty + let carriers = 0; for (const file of perPr) { const text = rootWorkflow(file); - expect(text.includes(GATE) || text.includes('check:published-dist'), file).toBe(false); + const jobsAt = text.search(/^jobs:[ \t]*$/m); + expect(jobsAt, file).toBeGreaterThan(-1); + const keys = [...text.slice(jobsAt).matchAll(/^ {2}([a-z0-9][a-z0-9_-]*):[ \t]*$/gm)].map((m) => m[1]); + expect(keys.length, file).toBeGreaterThan(0); + for (const key of keys) { + const block = withoutComments(jobBlock(text, key)); + if (!block.includes(GATE) && !block.includes('check:published-dist')) continue; + carriers += 1; + expect(deniesPerPrEvents(jobIf(block)), `${file} job ${key}: if: ${jobIf(block)}`).toBe(true); + } } + // The carrier the case above pins is seen here too, so this loop cannot + // pass by reading no job at all. + expect(carriers).toBeGreaterThan(0); }); });