diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ddfc7d30c4..64f5ee2a4da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,8 +163,10 @@ jobs: crosspkg: ${{ github.event_name != 'merge_group' && steps.changes.outputs.crosspkg || 'true' }} # The objectui tree under `ui/` (objectstack#25677). Three outputs, read # together by the four `* UI` jobs at the bottom of this file: - # ui — a path under ui/, or a root file the ui jobs build - # from (the lockfile, the workspace, turbo.json, ci.yml); + # ui — a path under ui/, a root file the ui jobs build from + # (the lockfile, the workspace, turbo.json), or a root + # the ui gates read (every root workflow, the prompts, + # the objectui skill bundle; see the filter below); # ui_consumers — one of the seven packages objectui installs from this # tree changed, so the Console is compiled (Type Check # UI) and built (Build & E2E UI) against the new shape in @@ -446,14 +448,29 @@ jobs: # narrower audits entry is left as the package that declared it # spelled it, exactly as the two narrower `skills/` entries were. - 'docs/**' - # The objectui tree and the root files the ui jobs build from. A - # change to the guard runs the guard, so ci.yml itself is listed. + # The objectui tree, the root files the ui jobs build from, and the + # three roots outside ui/ that the ui jobs' gates and tests read + # (objectstack#25712): + # - every root workflow, not ci.yml alone: Lint UI's pre-install + # import graph check scans all of `.github/workflows/` for ui + # scripts run before an install. ci.yml, the guard itself, is + # one of them. Measured over the 211 merges of the 7 days before + # this entry: 3 changed another workflow and no ui path; + # - `.github/prompts/**`, where objectui's prompts moved in + # objectstack#25957 and which Lint UI's prompt-keys check reads; + # - `skills/objectui/**`, objectui's skill bundle since + # objectstack#26097. Lint UI's skill gates (skills-paths, + # skill-examples, skill-eval-tokens, doc-json, + # shell-escape-residue) and Test UI's skill readers find it + # through `ui/scripts/skill-bundle-root.mjs`. ui: - 'ui/**' - 'pnpm-lock.yaml' - 'pnpm-workspace.yaml' - 'turbo.json' - - '.github/workflows/ci.yml' + - '.github/workflows/**' + - '.github/prompts/**' + - 'skills/objectui/**' # The seven packages objectui installs from this tree (the set its # `.objectstack-pin` used to pack). A change here compiles and builds # the Console in the same PR — the check the retired Spec Shape Gate @@ -3797,10 +3814,11 @@ jobs: # when the tree moved under `ui/` (it reads only this repository's # `.github/workflows/`), and which objectstack#25712 deleted with the # rest of `ui/.github/workflows/`. They judge what objectui ships - # — `ui/content/docs/**`, `ui/skills/**`, the package READMEs and the + # — `ui/content/docs/**`, the skill bundle (`skills/objectui/**` at the + # repository root since objectstack#26097), the package READMEs and the # documented component types — so the change most likely to trip them is # a docs-only or skills-only PR, which the `filter` job's `ui` globs - # already include. + # include. # # Ported as that workflow ran them, in its order: the install-free gates # here, before the install; the gates that read built `dist/*.d.ts` after @@ -3883,6 +3901,18 @@ jobs: if: ${{ !cancelled() }} run: pnpm install --frozen-lockfile + # The spec liveness ledger cites the renderer in-repo, under ui/ + # (objectstack#26150), so a ui/ change can delete a file a ledger row + # cites, move a cited line past the end of its file, drop an anchored + # symbol or stop naming the cited key. Spec Liveness Check does not run on + # a ui/-only pull request (its `paths:` are the spec side) and is not a + # required context, so the gate runs here too, where every ui/ change + # starts it (objectstack#25712 F26). It runs tsx over spec's sources and + # needs the install alone; about 3 s. + - name: Spec Liveness Check — Check the liveness ledger's citations, ui/ included + if: ${{ !cancelled() && steps.install.outcome == 'success' }} + run: pnpm --filter @objectstack/spec check:liveness + - name: Build the @objectstack packages objectui consumes run: | pnpm exec turbo run build --concurrency=4 \ diff --git a/ui/scripts/__tests__/check-pre-install-import-graph.test.ts b/ui/scripts/__tests__/check-pre-install-import-graph.test.ts index 50ae21893ea..e5282fba65d 100644 --- a/ui/scripts/__tests__/check-pre-install-import-graph.test.ts +++ b/ui/scripts/__tests__/check-pre-install-import-graph.test.ts @@ -410,13 +410,15 @@ describe('the gate is wired, not merely present', () => { // Deliberate: its input is the workflows themselves, and a workflow edit is // the change most likely to break it. A filter would also make the check // unrequirable (objectui#3523). The root trigger filters no path; the job - // runs on the `filter` job's `ui` globs, which must match the workflow - // whose steps are the population today and the gate itself. + // runs on the `filter` job's `ui` globs, which must match EVERY root + // workflow, not only ci.yml, whose steps are the population today: the scan + // reads them all, so a ui step added before an install in any one of them + // is this gate's subject (objectstack#25712) — and the gate itself. const on = withoutComments(triggerBlock(ci)); expect(on).not.toMatch(/paths-ignore:/); expect(on).not.toMatch(/^\s+paths:/m); const globs = uiFilterGlobs(ci); - const missed = ['.github/workflows/ci.yml', repoPath(SCRIPT)] + const missed = [...rootWorkflowFiles().map((file) => `.github/workflows/${file}`), repoPath(SCRIPT)] .filter((file) => !globs.some((glob) => globMatches(glob, file))); expect(missed, `a change to these would skip ${WORKFLOW}`).toEqual([]); }); diff --git a/ui/scripts/__tests__/check-prompt-component-keys.test.ts b/ui/scripts/__tests__/check-prompt-component-keys.test.ts index 43c13bbc274..1f0b062617e 100644 --- a/ui/scripts/__tests__/check-prompt-component-keys.test.ts +++ b/ui/scripts/__tests__/check-prompt-component-keys.test.ts @@ -45,6 +45,7 @@ import { scanPromptSections, } from '../check-prompt-component-keys.mjs'; import { INDIRECT_REGISTRATIONS, deriveRegistryKeys } from '../check-doc-component-types.mjs'; +import { globMatches, jobBlock, rootCi, uiFilterGlobs, uiRun, UI_JOBS } from './helpers/root-ci'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); /** The repository root, whose `.github/prompts/` the live prompts sit in (objectstack#25712). */ @@ -312,7 +313,8 @@ describe('wiring', () => { // this gate. objectstack#25880 ports the `repo-gates.yml` gates (row 24 of // the objectstack#25697 workflow roster) into the root `Lint UI` job and // brings these pins back against that job, by path. That case's import-graph - // walk reads no workflow and stays below. + // walk reads no workflow and stays below. The Lint UI home and the filter that + // starts it on a prompts-only change are pinned by the second case below. it('is exposed as a root package script', () => { const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) as { @@ -321,6 +323,22 @@ describe('wiring', () => { expect(pkg.scripts['check:prompt-keys']).toBe(`node ${SCRIPT}`); }); + it('runs in Lint UI, which a prompts-only change starts: the root `ui` filter covers the prompt surface', () => { + // The prompts left `ui/` for the repository root's `PROMPT_DIR` + // (objectstack#25957), where `ui/**` does not reach them, so a prompts-only + // pull request started no ui job until the filter named that root + // (objectstack#25712). + const ci = rootCi(); + const lint = jobBlock(ci, UI_JOBS.lint); + expect(lint).toContain(uiRun('check:prompt-keys')); + expect(lint).toContain("needs.filter.outputs.ui != 'false'"); + const probe = `${PROMPT_DIR}/any.prompt`; + expect( + uiFilterGlobs(ci).some((glob) => globMatches(glob, probe)), + `no \`ui:\` filter glob covers ${PROMPT_DIR}/`, + ).toBe(true); + }); + it('needs no install: its import graph reaches no package', () => { // Walk the WHOLE static import graph, not just this gate's first line: it // imports a sibling gate, and "needs no node_modules" has to stay true of diff --git a/ui/scripts/__tests__/check-skills-paths.test.ts b/ui/scripts/__tests__/check-skills-paths.test.ts index dc0dec8a1f9..e69364e8c8b 100644 --- a/ui/scripts/__tests__/check-skills-paths.test.ts +++ b/ui/scripts/__tests__/check-skills-paths.test.ts @@ -16,6 +16,8 @@ import { readBaseline, BASELINE_FILE, } from '../check-skills-paths.mjs'; +import { SKILL_BUNDLE } from '../skill-bundle-root.mjs'; +import { globMatches, jobBlock, rootCi, uiFilterGlobs, uiRun, UI_JOBS } from './helpers/root-ci'; /** * objectui#3735 — the test for `scripts/check-skills-paths.mjs`. @@ -516,7 +518,9 @@ describe('wiring — the gate is reachable by name, beside its baseline', () => // the monorepo: GitHub runs the root `.github/workflows/` alone, and no // workflow there runs this gate. objectstack#25880 ports the `repo-gates.yml` // gates (row 24 of the objectstack#25697 workflow roster) into the root `Lint - // UI` job and brings these pins back against that job, by path. + // UI` job and brings these pins back against that job, by path. The Lint UI + // home and the filter that starts it on a skills-only change are pinned by + // the last case below. it('is exposed as a root package script', () => { const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); @@ -526,4 +530,20 @@ describe('wiring — the gate is reachable by name, beside its baseline', () => it('ships its baseline next to the script', () => { expect(fs.existsSync(path.join(repoRoot, BASELINE_FILE))).toBe(true); }); + + it('runs in Lint UI, which a skills-only change starts: the root `ui` filter covers the bundle', () => { + // The bundle left `ui/` for the repository root's `SKILL_BUNDLE` + // (objectstack#26097), where `ui/**` does not reach it, so a skills-only + // pull request started no ui job until the filter named that root + // (objectstack#25712). + const ci = rootCi(); + const lint = jobBlock(ci, UI_JOBS.lint); + expect(lint).toContain(uiRun('check:skills-paths')); + expect(lint).toContain("needs.filter.outputs.ui != 'false'"); + const probe = `${SKILL_BUNDLE}/any`; + expect( + uiFilterGlobs(ci).some((glob) => globMatches(glob, probe)), + `no \`ui:\` filter glob covers ${SKILL_BUNDLE}/`, + ).toBe(true); + }); });