Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 170 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
#
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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
15 changes: 15 additions & 0 deletions scripts/release-publish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 37 additions & 5 deletions scripts/release-verify-npm.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand All @@ -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 });
Expand All @@ -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',
Expand All @@ -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,
};
};
Expand All @@ -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 });
}
Expand Down
Loading
Loading