From fc938044bdc86ed803d4a632ae68601ded47f681 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:54:27 +0000 Subject: [PATCH 1/2] docs(ci): document every workflow, drop the hand-maintained count, pin the reverse direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ci-cd-pipeline.md` opened with "11 GitHub Actions workflows" against a directory that held 13, and three of them had never been documented at all: `lint.yml` (a real PR gate), `cross-repo-issue-closer.yml`, and `changeset-guard.yml`. The `ci.yml` section listed five jobs, of which three (Lint, Build Core, and an unsharded Test) do not exist. - Replace the count with an enumerated inventory table: file, the name each workflow appears under in the checks list, its trigger, and whether it can block a merge. The number is gone on purpose — it was wrong three times. - Drop the ASCII overview box; it was a second hand-maintained copy of the same set and the older of the two drifted first. - Rewrite the `ci.yml` section against the real job keys (`changeset-check`, `type-check`, `test` as a 4-shard PR-only matrix, `test-coverage` on push only, `e2e`, `docs`, `dev-server`), plus an explicit "what is not in ci.yml" for the two jobs readers were sent to look for. - Add sections for `lint.yml` (errors gate, warnings do not) and `cross-repo-issue-closer.yml`, and its `CROSS_REPO_ISSUE_TOKEN` secret. #3197 pinned only the forward direction (every workflow the page names must exist); the reverse was left out because it would have gone red on exactly these omissions. It is added here: every file in `.github/workflows/` must be named in a heading on the page, with a failure message that tells the next author what to write. `changeset-guard.yml` landing between #3212 being filed and being fixed is why the snapshot alone is not enough. Fixes #3212 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa --- content/docs/guide/ci-cd-pipeline.md | 184 +++++++++++++------ scripts/__tests__/ci-cd-pipeline-doc.test.ts | 63 +++++++ 2 files changed, 187 insertions(+), 60 deletions(-) diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 0d0d28653..7c1f94fa4 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -5,72 +5,96 @@ description: Overview of the ObjectUI continuous integration and deployment work # CI/CD Pipeline -ObjectUI uses **11 GitHub Actions workflows** to automate testing, quality checks, security scanning, releases, and repository maintenance. All workflow files live in `.github/workflows/`. +ObjectUI automates testing, quality checks, releases, and repository maintenance with GitHub Actions. All workflow files live in `.github/workflows/`. + +This page deliberately states **no workflow count**. It used to open with "11 GitHub Actions +workflows"; the directory held 12 when [#3212](https://github.com/objectstack-ai/objectui/issues/3212) +was filed and 13 by the time it was fixed. A hand-maintained number drifts by construction, and a +stale one still reads as authoritative. What is pinned instead is the *set*: +`scripts/__tests__/ci-cd-pipeline-doc.test.ts` fails `pnpm test` when a file in +`.github/workflows/` has no section on this page, **and** when this page names a `.yml` that is not +in that directory. Adding a workflow without documenting it is a red test, not a silent omission. + +## Workflow Inventory + +Every workflow, the name it appears under in the checks list (they are not the same string — +`performance-budget.yml` shows up as **Bundle Analysis**), and whether it can block a merge. Each +one has its own section below. + +| Workflow file | Appears as | Runs on | Blocks a PR? | +|---|---|---|---| +| `ci.yml` | CI | Push / PR to `main`, `develop` | **Yes** — 6 of its 7 jobs run on PRs | +| `lint.yml` | Lint | Push / PR to `main`, `develop`; manual | **Yes** — ESLint **errors** only | +| `changeset-guard.yml` | Changeset Bump Policy | PR / push touching `.changeset/**` | **Yes** | +| `performance-budget.yml` | Bundle Analysis | Push / PR touching `packages/**`, `apps/console/**`, `pnpm-lock.yaml` | **Yes** — the console entry gzip budget | +| `labeler.yml` | Auto Label PRs | PR `opened`, `synchronize`, `reopened` | No | +| `dependabot-auto-merge.yml` | Dependabot Auto-merge | PR to `main`/`develop` authored by `dependabot[bot]` | No | +| `cross-repo-issue-closer.yml` | Cross-repo Issue Closer | PR `closed` (acts only when merged) | No — runs after merge | +| `changeset-release.yml` | Changeset Release | Push to `main` | n/a | +| `release.yml` | Release | Push of a `v*` tag | n/a | +| `changelog.yml` | Auto Changelog | GitHub Release published; manual | n/a | +| `stale.yml` | Stale Issues & PRs | Daily cron `0 0 * * *`; manual | n/a | +| `shadcn-check.yml` | Check Shadcn Components | Weekly cron `0 9 * * 1`; manual | n/a | +| `check-links.yml` | Check Links | Manual dispatch only | n/a | + +Two path-filter facts explain most "why did nothing run on my PR?" questions: + +- `ci.yml` and `lint.yml` both list `**/*.md`, `content/**`, `docs/**` and `.changeset/**` under + `paths-ignore` (`ci.yml` also ignores `apps/site/**`). A docs-only or changeset-only PR starts + neither of them. +- `changeset-guard.yml` carries the inverse filter — it runs *only* when `.changeset/**` changes, + which is precisely why it is a separate workflow instead of a job inside `ci.yml`. -## Workflow Overview +## Core CI Workflow (`ci.yml`) -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Push / PR to main/develop │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────┐ ┌──────────────┐ │ -│ │ ci.yml │ │ performance- │ │ -│ │ (test, │ │ budget.yml │ │ -│ │ lint, │ │ (bundle size)│ │ -│ │ build) │ │ │ │ -│ └──────────┘ └──────────────┘ │ -│ │ -│ ┌──────────────┐ │ -│ │ labeler.yml │ │ -│ │ │ │ -│ └──────────────┘ │ -│ │ -├─────────────────────────────────────────────────────────────────┤ -│ Push to main │ -│ ┌───────────────────┐ │ -│ │ changeset-release │ → npm publish via changesets │ -│ │ .yml │ │ -│ └───────────────────┘ │ -│ │ -├─────────────────────────────────────────────────────────────────┤ -│ Tag push (v*) │ -│ ┌──────────┐ ┌───────────────┐ │ -│ │ release │ │ changelog.yml │ │ -│ │ .yml │ │ (git-cliff) │ │ -│ └──────────┘ └───────────────┘ │ -│ │ -├─────────────────────────────────────────────────────────────────┤ -│ Scheduled │ -│ ┌──────────┐ ┌───────────────────┐ ┌──────────────────┐ │ -│ │ stale │ │ shadcn-check.yml │ │ dependabot- │ │ -│ │ .yml │ │ (weekly Mon 9AM) │ │ auto-merge.yml │ │ -│ └──────────┘ └───────────────────┘ └──────────────────┘ │ -│ │ -├─────────────────────────────────────────────────────────────────┤ -│ Manual dispatch │ -│ ┌──────────────┐ │ -│ │ check-links │ → Lychee link validation │ -│ │ .yml │ │ -│ └──────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ -``` +**Triggers:** Push and PR to `main` and `develop`, unless the change touches only `**/*.md`, +`content/**`, `docs/**`, `apps/site/**` or `.changeset/**` (`paths-ignore`). -## Core CI Workflow (`ci.yml`) +Seven jobs, all parallel — there are no `needs:` edges between them: + +| Job key | Appears as | What it runs | When | +|---|---|---|---| +| `changeset-check` | Changeset Fixed Group Check | `scripts/check-changeset-fixed.mjs` — every workspace package must be in the changeset `fixed` group or explicitly ignored. It checks group *membership*; it does **not** check whether the PR added a changeset. | Every run | +| `type-check` | Type Check | `scripts/check-type-check-coverage.mjs`, then `pnpm check:spec-symbols`, then `pnpm type-check`. The coverage guard runs first because turbo silently skips packages that have no `type-check` script, so a package without one would otherwise read as passing (#2911). | Every run | +| `test` | Test (shard N/4) | `pnpm test --shard=N/4` across a 4-runner matrix with `fail-fast: false`, so every shard reports its own failures. No coverage instrumentation — v8 adds 40–100% overhead. | **Pull requests only** | +| `test-coverage` | Test (coverage) | One unsharded `pnpm test:coverage`, uploaded to Codecov. Nothing blocks on it, which is why it is not sharded. | **Push only** | +| `e2e` | Build & E2E | Builds the console with `vite build` (`VITE_BASE_PATH=/console/`), verifies the artifact, then `pnpm test:e2e --project=chromium`. Uploads the Playwright report on failure. | Every run | +| `docs` | Build Docs | `turbo run build --filter='@object-ui/site'`. On a PR it first diffs against the base and skips the build when nothing under `apps/site/` or `content/` changed. | Every run (build itself conditional) | +| `dev-server` | Dev-server fixture build | `pnpm --filter @object-ui/dev-server build` — guards `apps/dev-server`'s `objectstack.config.ts` against fixture / `@objectstack/spec` drift. | Every run | + +Uses: Node 22.x, pnpm via `corepack`, `actions/cache` over `.turbo/cache`. + +### What is *not* in `ci.yml` + +Two jobs this page used to list have never existed under those names, and looking for them in +`ci.yml` is a dead end: + +- **Lint** is not a `ci.yml` job. ESLint runs in its own workflow, `lint.yml` (next section), and + shows up as a separate **Lint** check on the PR. +- **Build Core** does not exist. `ci.yml` builds only the console SPA that Playwright consumes; + building the packages and measuring their size belongs to the Bundle Analysis workflow + (`performance-budget.yml`), as the comment on the `e2e` job states. + +## Lint (`lint.yml`) -**Triggers:** Push and PR to `main` and `develop` branches. +**Triggers:** Push and PR to `main`/`develop` (same `paths-ignore` as `ci.yml`, minus +`apps/site/**`), plus manual dispatch. -Runs five parallel jobs: +This is a **real PR gate**, and it is easy to miss because it is not part of CI — it is its own +**Lint** entry in the checks list. -| Job | Description | -|-----|-------------| -| **Test** | Runs `vitest` across all packages with Turbo caching. Uploads coverage to Codecov. | -| **Lint** | Runs ESLint via `eslint.config.js` (flat config) and TypeScript type-checking. | -| **Build Core** | Builds all packages using `turbo run build`. | -| **E2E Tests** | Runs Playwright end-to-end tests from the `e2e/` directory. | -| **Build Docs** | Builds the documentation site (`apps/site`). | +- `scripts/check-lint-coverage.mjs` runs first: every package must run ESLint or be declared a + known gap. turbo skips scriptless packages silently, so without this guard a package reads as + clean because nothing ever linted it. +- Then `pnpm lint`. -Uses: Node 22, pnpm (via `corepack`), Turbo remote caching. +**It gates errors, not warnings.** `--max-warnings` is deliberately unset: the repository carries +thousands of warnings (overwhelmingly `no-explicit-any`, plus React Compiler rules the config +downgrades on purpose), and failing on those would make the gate unusable. What must stay clean are +the rules `eslint.config.js` sets to `error` — including the custom `object-ui/*` ratchets +(ADR-0054 Phase 5, #2879, the `objectql.ts` ratchet, `no-dynamic-import-in-test-hook`). Until #2923 +this workflow was `workflow_dispatch`-only, so every one of those `error` ratchets was inert: each +was written specifically to fail CI, and nothing ran them. ## Performance Budget (`performance-budget.yml`) @@ -182,6 +206,35 @@ Uses [git-cliff](https://git-cliff.org/) with `cliff.toml` configuration to auto Automatically labels PRs based on file path patterns defined in `.github/labeler.yml`. Syncs labels on each push to the PR. +### Cross-repo Issue Closer (`cross-repo-issue-closer.yml`) + +**Trigger:** `pull_request_target` with type `closed`; the job acts only when the PR was actually +merged. + +GitHub's closing keywords work **only within a repository**. A PR here whose body says +`Fixes objectstack-ai/objectstack#4475` reads to a human exactly like a same-repo close, merges, +and leaves that issue open forever — with no reference to the PR on the issue's page either. That +is not hypothetical: during v17 verification it happened twice in one day, and both framework +issues had to be closed by hand. + +This workflow scans the merged PR body for **qualified** `owner/repo#N` closing keywords (the bare +`#N` form is left to GitHub) and takes one of two visible paths: + +| `CROSS_REPO_ISSUE_TOKEN` | Behaviour | +|---|---| +| Configured | Comments on each foreign issue with the PR link, then closes it as `completed`. | +| Absent | Comments **on this PR**, listing every issue that still has to be closed by hand. | + +The second path is the point. A workflow that quietly does nothing because a secret was never +provisioned is the same "declared but never enforced" shape both repositories keep having to fix, +so the missing credential announces itself — the run logs the token's presence before any early +return, and the PR comment names the cost. + +It uses `pull_request_target` rather than `pull_request` because the latter withholds repository +secrets from fork-originated runs. The usual hazard of `pull_request_target` does not apply here: +the job never checks out the head ref and never executes anything from the PR — it reads the body +and calls the issues API. + ### Stale Issues (`stale.yml`) **Trigger:** Daily at 00:00 UTC (cron), or manual dispatch. @@ -211,6 +264,12 @@ Exempt labels: `pinned`, `security`, `critical`, `in-progress`. ## Adding a New Workflow +> **Give it a section on this page in the same PR.** Not a convention — a test. +> `scripts/__tests__/ci-cd-pipeline-doc.test.ts` reads `.github/workflows/` and fails when a +> workflow has no heading here naming its file. Three workflows (`lint.yml`, +> `cross-repo-issue-closer.yml`, `changeset-guard.yml`) went undocumented for months precisely +> because nothing checked, and one of them is a PR gate. + 1. Create a new `.yml` file in `.github/workflows/`. 2. Follow the existing pattern for pnpm + Turbo setup: @@ -240,13 +299,18 @@ on: - 'pnpm-lock.yaml' ``` +5. Add a section for it under the right heading on this page, and a row to the + [inventory table](#workflow-inventory). State the display name if it differs from the file name, + and say plainly whether it can block a merge. + ## Environment Variables and Secrets | Secret / Variable | Used By | Purpose | |-------------------|---------|---------| | `GITHUB_TOKEN` | All workflows | GitHub API access (automatic) | | `NPM_TOKEN` | `changeset-release.yml` | npm package publishing | -| `CODECOV_TOKEN` | `ci.yml` | Coverage upload to Codecov | +| `CODECOV_TOKEN` | `ci.yml` (`test-coverage` job) | Coverage upload to Codecov | +| `CROSS_REPO_ISSUE_TOKEN` | `cross-repo-issue-closer.yml` | Closing issues in sibling repositories. `GITHUB_TOKEN` cannot do this — it is scoped to the repository running the workflow. When absent the workflow reports instead of closing. | | `TURBO_TOKEN` | Build workflows | Turbo remote cache authentication | | `TURBO_TEAM` | Build workflows | Turbo remote cache team identifier | diff --git a/scripts/__tests__/ci-cd-pipeline-doc.test.ts b/scripts/__tests__/ci-cd-pipeline-doc.test.ts index 7b77bd158..d3b41c023 100644 --- a/scripts/__tests__/ci-cd-pipeline-doc.test.ts +++ b/scripts/__tests__/ci-cd-pipeline-doc.test.ts @@ -95,9 +95,72 @@ describe('ci-cd-pipeline.md — advisory package size tiers', () => { }); }); +/** + * objectui#3212: the forward direction above (every workflow the page names must + * exist) was pinned by #3197; the reverse was deliberately left out because it + * would have gone red immediately — `lint.yml`, `cross-repo-issue-closer.yml` + * and later `changeset-guard.yml` had no section at all. `lint.yml` is a real PR + * gate, and a contributor reading this page had no way to learn it existed. + * + * Only the reverse direction actually stops the drift. Without it, fixing the + * page fixes one snapshot and guarantees the next workflow repeats the omission + * silently — `changeset-guard.yml` appearing between #3212 being filed and being + * fixed is the proof. + * + * A *heading* is required, not a passing mention: a filename buried in a table + * row or an ASCII box is how the page got here. The heading is what makes the + * workflow findable and forces someone to write down what it does. + */ + +/** + * `filename -> why this workflow must not be documented`. Deliberately empty. + * + * A workflow that runs in this repository is a workflow contributors can be + * blocked by, so "not worth a section" is a claim that has to be made + * explicitly and reviewed — never by quietly skipping the page. The test below + * also rejects entries that name a workflow which no longer exists, so the + * escape hatch cannot rot into a permanent hole. + */ +const DOCUMENTATION_EXEMPT = new Map(); + describe('ci-cd-pipeline.md — workflow inventory', () => { const workflowFiles = new Set(fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml'))); + /** Workflow filenames named in a markdown heading, e.g. `## Lint (\`lint.yml\`)`. */ + const documented = new Set( + doc + .split('\n') + .filter((line) => /^#{1,6}\s/.test(line)) + .flatMap((line) => [...line.matchAll(/([a-z0-9][a-z0-9-]*\.yml)\b/g)].map((m) => m[1])), + ); + + it('gives every workflow in .github/workflows/ its own section', () => { + const undocumented = [...workflowFiles].filter( + (f) => !documented.has(f) && !DOCUMENTATION_EXEMPT.has(f), + ); + + expect( + undocumented, + `These workflows exist in .github/workflows/ but no heading in ` + + `content/docs/guide/ci-cd-pipeline.md names them:\n` + + undocumented.map((f) => ` - ${f}`).join('\n') + + `\n\nAdd a section to that page — a heading that contains the file name ` + + `(e.g. "### Stale Issues (\`stale.yml\`)"), what triggers it, and whether it can ` + + `block a merge — and a row in the "Workflow Inventory" table. A workflow nobody ` + + `documented is a check contributors get blocked by without knowing it exists ` + + `(objectui#3212: \`lint.yml\` gated PRs for months while this page never mentioned it).` + + `\n\nIf a workflow genuinely must not be documented, add it to DOCUMENTATION_EXEMPT in ` + + `this file with the reason — the exemption is reviewable, skipping the page is not.`, + ).toEqual([]); + }); + + it('keeps the documentation exemption list honest', () => { + for (const [name, reason] of DOCUMENTATION_EXEMPT) { + expect(workflowFiles, `DOCUMENTATION_EXEMPT names ${name}, which no longer exists — drop it`).toContain(name); + expect(reason.length, `DOCUMENTATION_EXEMPT[${name}] must carry a real justification`).toBeGreaterThan(20); + } + }); + it('never names a workflow file that does not exist', () => { // Fenced blocks are excluded: the ASCII overview box wraps filenames across // lines (`performance-` / `budget.yml`), which no filename scan can read. From 07ce9d159fff967136dc80e2db5e86240097e932 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:58:02 +0000 Subject: [PATCH 2/2] docs(ci): the fence-stripping comment no longer points at a deleted diagram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit removed the ASCII overview box the comment cited as the sole reason for excluding fenced blocks. The exclusion is still right — the "Adding a New Workflow" section shows YAML for workflows that do not exist — so say that instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NVPjPzmmAJ2Ngtvgg5MSRa --- scripts/__tests__/ci-cd-pipeline-doc.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/__tests__/ci-cd-pipeline-doc.test.ts b/scripts/__tests__/ci-cd-pipeline-doc.test.ts index d3b41c023..d2b223bb6 100644 --- a/scripts/__tests__/ci-cd-pipeline-doc.test.ts +++ b/scripts/__tests__/ci-cd-pipeline-doc.test.ts @@ -162,8 +162,10 @@ describe('ci-cd-pipeline.md — workflow inventory', () => { }); it('never names a workflow file that does not exist', () => { - // Fenced blocks are excluded: the ASCII overview box wraps filenames across - // lines (`performance-` / `budget.yml`), which no filename scan can read. + // Fenced blocks are excluded: they hold YAML samples for workflows that do + // not exist yet ("Adding a New Workflow") and, until #3212, an ASCII overview + // box that wrapped filenames across lines (`performance-` / `budget.yml`), + // which no filename scan can read. const prose = doc.replace(/```[\s\S]*?```/g, ''); // Skip path-qualified mentions (`.github/labeler.yml` is the labeler *config*, // not the workflow of the same name).