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
141 changes: 141 additions & 0 deletions .github/workflows/skill-examples.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
name: Skill Examples

# Compiles every MARKED `ts` / `tsx` / `typescript` fence under `skills/`,
# `--strict`, against the packages' BUILT `dist/*.d.ts`, and parses every marked
# `json` / `jsonc` fence. The opt-in marker convention, the adjacency rule, the
# inherited harness controls and the declared exit codes are documented at length
# in `scripts/check-skill-examples.mjs`.
#
# ── Why this is its own workflow, with NO path filter ───────────────────────
#
# Same reason `skills-paths.yml`, `docs-links.yml`, `control-bytes.yml` and
# `doc-snippet-types.yml` are theirs, and their headers say it best: this gate's
# entire scan surface is markdown under `skills/`, and both `ci.yml` and
# `lint.yml` list `'**/*.md'`, `content/**` and `docs/**` under the `paths-ignore`
# of their `push` trigger. GitHub has no per-job path filter, so a push that only
# edits a guide would start neither — and editing only a guide is the single most
# likely way a worked example goes stale. `control-bytes.yml`'s header names the
# consequence: a gate that cannot see a markdown-only change "rebuilds the hole
# it exists to close".
#
# Hence: no `paths` and no `paths-ignore` here, deliberately.
# `scripts/__tests__/check-skill-examples.test.ts` fails if either is ever added,
# and fails too if a second workflow starts running the same script — one gate,
# one home.
#
# ── Why it builds, unlike its install-free sibling `skills-paths.yml` ───────
#
# `skills-paths.yml`'s header asks that it stay a checkout plus one `node` call,
# and this gate cannot be that: its whole criterion is the PUBLISHED type
# surface, so the packages the marked fences import have to exist as `dist/*.d.ts`
# first. That is why this is a separate workflow rather than a second step there.
# Resolving against `src/` instead would be a weaker check the root
# `tsconfig.json` makes one inherited config away, and the script's RESOLUTION
# control fails the run rather than letting it pass quietly.
#
# The build is FILTERED to the packages the MARKED fences import, emitted by the
# gate itself (`--build-filter`) rather than hand-maintained here, so it grows
# only as the marked population grows and the growth is visible in this job's log
# rather than hidden in a workflow edit. That is what keeps this inside the
# 2026-08-16 ruling on objectui#4846 (recorded in `published-dist-gate.yml`),
# which rejected a per-PR FULL-REPO build.
#
# ⛔ Do not replace the filtered build with `pnpm build`. The filter is the reason
# this job is allowed to run on every pull request at all.

on:
pull_request:
branches: [main, develop]
push:
branches: [main, develop]
# Merge queue (objectui#3523 — see `ci.yml`'s trigger block for the full note
# and the measurements behind it). A required check that does not report on a
# queue build stalls the queue until the ruleset's 60-minute timeout fails it,
# so an unfiltered gate that could become required subscribes here from the
# start. `types:` is named although `checks_requested` is currently the only
# activity type GitHub defines for `merge_group`.
merge_group:
types: [checks_requested]
workflow_dispatch:

concurrency:
group: skill-examples-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
skill-examples:
name: Skill Example Check
runs-on: ubuntu-latest
timeout-minutes: 25

steps:
- name: Checkout code
uses: actions/checkout@v7

- name: Enable Corepack
run: corepack enable

- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22.x'
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile

# The filter comes from the gate, so it can never drift from what the
# marked fences import.
#
# ⛔ Do not fold this back into `echo "args=$(node …)" >> "$GITHUB_OUTPUT"`
# (objectui#6221). A command substitution contributes its STDOUT to the
# surrounding word and nothing else — the step's status is `echo`'s — so a
# gate that failed reads as a gate that named no packages, `args` is
# silently empty, and the step below expands to a bare `turbo run build`
# over the whole workspace: the one thing this workflow's header forbids,
# with no signal anywhere. Capture the status, then write the output.
- name: Derive the packages the marked examples import
id: filter
run: |
status=0
args="$(node scripts/check-skill-examples.mjs --build-filter)" || status=$?
if [ "$status" -ne 0 ]; then
echo "::error::Could not derive the build filter: \`node scripts/check-skill-examples.mjs --build-filter\` exited $status. Refusing to continue — carrying on would build every package in the workspace instead of the ones the marked examples import." >&2
exit "$status"
fi
echo "args=$args" >> "$GITHUB_OUTPUT"

# The empty-filter refusal is the second half, deliberately kept HERE
# rather than beside the status check above: it holds for every route to an
# empty filter, including a gate that exits 0 while naming nothing. The
# gate's own empty-population floor already refuses a run in which nothing
# is marked, and an unfiltered `turbo run build` is a far worse answer than
# a red step. `args` arrives through the environment so the check has a
# value to test; it stays unquoted on the `turbo` line because it is a LIST
# of `--filter=` words that must word-split.
- name: Build those packages
env:
FILTER_ARGS: ${{ steps.filter.outputs.args }}
run: |
case "$FILTER_ARGS" in
*--filter=*) ;;
*)
echo "::error::The derived build filter names no package (got: '$FILTER_ARGS'). Refusing to run an unfiltered build — see this workflow's header." >&2
exit 1
;;
esac
pnpm exec turbo run build $FILTER_ARGS --concurrency=2

# The self-test runs BEFORE the corpus, and after the build because its
# compiler legs need the same built tree. A probe that cannot fail is not a
# probe: it plants a fence that must go red and a marker that must be
# reported, so a harness broken into permanent green is caught here rather
# than by nobody.
- name: Self-test the marker convention, both directions
run: node scripts/check-skill-examples.mjs --self-test

- name: Check the marked skill examples against the built types
run: node scripts/check-skill-examples.mjs
54 changes: 54 additions & 0 deletions content/docs/guide/ci-cd-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ one has its own section below.
| `control-bytes.yml` | Control Byte Scan | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** |
| `docs-links.yml` | Internal Docs Link Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** |
| `skills-paths.yml` | Skill Guide Path Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a path stated in a `skills/` guide does not exist |
| `skill-examples.yml` | Skill Example Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a MARKED fenced example in a `skills/` guide no longer compiles against the packages' built types, no longer parses as JSON, or carries a marker that opts nothing in |
| `doc-component-types.yml` | Doc Component Type Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a `content/docs/**.mdx` snippet teaches a `type` nothing registers |
| `doc-snippet-types.yml` | Doc Snippet Type Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a covered documentation snippet no longer compiles against the packages' built types |
| `doc-fence-languages.yml` | Doc Fence Language Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a TypeScript block sits under a fence the snippet gate does not read |
Expand Down Expand Up @@ -594,6 +595,59 @@ the sentence's whole point is that the path does not exist. Run it locally with
`pnpm check:skills-paths`, or `node scripts/check-skills-paths.mjs --list` to see every candidate and
how it was classified.

## Skill Examples (`skill-examples.yml`)

**Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no
path filter at all**, for the same reason as the section above: the scan surface is markdown under
`skills/`, and both `ci.yml` and `lint.yml` list `'**/*.md'` under the `paths-ignore` of their `push`
trigger. It appears in the checks list as **Skill Example Check**.

Runs `scripts/check-skill-examples.mjs`. Where the section above checks the *paths* a guide states in
prose, this one checks the *worked examples* themselves: a marked `ts` / `tsx` / `typescript` fence
must compile `--strict` against the packages' built `dist/*.d.ts`, and a marked `json` / `jsonc`
fence must parse.

**Why it was needed:** at the time it landed, `skills/objectui/` carried 112 TypeScript fences and 56
JSON fences and **not one gate in the repository read inside any of them**
([#7359](https://github.com/objectstack-ai/objectui/issues/7359)). Every gate that could have is
scoped elsewhere by construction — `check-skills-paths.mjs` deliberately reads inline code spans in
prose only, the three `content/docs` gates say so in their own headers. So a fence could import a
symbol that does not exist and stay green forever, and two rounds of exactly that had already been
cleaned by hand.

**Opt-in, and why:** a fence is checked only when the line **immediately above** it is
`<!-- os:check -->`. Most skill fences are fragments by construction — a `columns: [...]` subtree, a
block that continues the one above it — so compiling all of them at once would red on prose that is
not wrong, and a gate that reds on correct code gets deleted by the first person who hits it. The
marker is an inert HTML comment and leaves the fence info string bare, so the three gates that key on
the info string still see the block. The convention is ported byte-for-byte from objectstack's
`packages/spec/scripts/check-skill-examples.ts`.

**Adjacency is strict:** a marker that is not directly above a checkable fence — separated by a blank
line, above a `bash` fence, or left behind when its example was deleted — is an **orphan** and fails
the run. A lenient rule would opt in nothing while its author believed otherwise, which is this
repository's recurring "looks like enforcement, isn't" class. A marker shown as *example text* inside
another fence is neither an opt-in nor an orphan: it is not at top level, so it claims nothing.

**It builds, unlike `skills-paths.yml`:** the criterion is the *published* type surface, so the
packages the marked fences import must exist as `dist/*.d.ts` first. The build is filtered to exactly
those packages, and the filter is emitted by the gate itself
(`node scripts/check-skill-examples.mjs --build-filter`) rather than hand-maintained in the workflow,
so it grows only as the marked population grows.

**Three exit codes, not two.** `0` = every marked example held up. `1` = the gate ran and found
errors — a verdict about a guide. `2` = the gate **could not run**: the packages are unbuilt or typed
from source, a harness control failed, or nothing is marked at all. Nothing printed under exit 2 is a
verdict about any guide, and it is never a pass — zero with nothing run reads as coverage, which is
the failure shape this gate family exists to prevent
([#4846](https://github.com/objectstack-ai/objectui/issues/4846)).

**If it fails:** it prints `file:line` for every failing fence with the compiler's own diagnostic, or
the JSON parser's message, or the orphan marker's line. Fix the example — or, if it was never meant
to stand alone, remove its marker rather than weakening the gate. Run it locally with
`pnpm check:skill-examples`, `node scripts/check-skill-examples.mjs --list` to see every candidate
fence and its verdict, and `--measure` to judge every candidate whether marked or not.

## Documented Component Types (`doc-component-types.yml`)

**Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — with **no
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"check:i18n-drift": "node scripts/check-i18n-en-drift.mjs",
"check:i18n-dead-keys": "node scripts/check-i18n-dead-keys.mjs",
"check:skills-paths": "node scripts/check-skills-paths.mjs",
"check:skill-examples": "node scripts/check-skill-examples.mjs",
"check:doc-types": "node scripts/check-doc-component-types.mjs",
"check:doc-snippets": "node scripts/check-doc-snippet-types.mjs",
"check:doc-fences": "node scripts/check-doc-fence-languages.mjs",
Expand Down
Loading
Loading