From ff350f93eb2fa3853ab5ec4c70a55f368f7b2a37 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 17 Sep 2026 18:12:35 +0900 Subject: [PATCH] ci: gate the docs site build on pull requests The Astro toolchain had no pull-request build gate. ci.yml contained no docs-site reference and built no docs, and deploy-docs.yml triggers only on push to main, which is after promotion. The first machine that could discover a broken docs build was the deploy, so a dependency bump under docs-site/ could only be backed by an author's local run. Adds a docs filter to the existing changes job and one job selected by it. docs-site/** is deliberately not added to the ci filter: a prose edit has no business starting the cross-platform matrix, it only has to build. A separate filter output keeps those two questions apart. The workflow file itself is in the docs filter so an edit to the job verifies itself, without which this change's own pull request would skip the job it adds. One Linux leg. The site is static output from a Node/Bun toolchain with no OS-specific behaviour to promise, so another platform would spend queue time without buying coverage. --frozen-lockfile carries as much of the value as the build does, because it fails on a manifest and lockfile that disagree, which is the shape a hand-edited override introduces. The aggregate ci gate is event-aware, so the job is declared in the four places that have to agree: needs, CHANGES_DOCS, GATED_JOBS, and expected_for. No existing job's sharding, timeout, or runner selection changes, no Windows leg is added, and permissions stay contents: read. --- .github/workflows/ci.yml | 59 ++++++++++++++++++- .../030_unit_c_docs_site_build_gate.md | 58 ++++++++++++++++++ structure/ops/docs-and-release.md | 13 +++- 3 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 devlog/_plan/260917_l1_preview_read_fence_and_dep_audit/030_unit_c_docs_site_build_gate.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4684d392d..06aa6ccc84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -156,6 +156,7 @@ jobs: ci: ${{ steps.scope.outputs.ci }} gui: ${{ steps.filter.outputs.gui }} packaging: ${{ steps.filter.outputs.packaging }} + docs: ${{ steps.filter.outputs.docs }} steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 @@ -207,6 +208,20 @@ jobs: - '.github/workflows/stale-needs-info.yml' gui: - 'gui/**' + # The docs site is built by nothing else on a pull request. `ci` above + # deliberately omits `docs-site/**` -- a prose edit has no business + # starting the cross-platform suite -- and `deploy-docs.yml` triggers + # only on `push` to `main`. That left the Astro toolchain with no + # pull-request build gate at all, so a dependency bump under + # `docs-site/` could only be proven by an author's local run and would + # otherwise surface at promotion. + # + # `.github/workflows/ci.yml` is here so an edit to the job below + # verifies itself. Without it this filter's own pull request would + # skip the thing it adds. + docs: + - 'docs-site/**' + - '.github/workflows/ci.yml' # Everything that ends up inside `npm pack`, or that decides what # does. `src/**` belongs here because package.json ships `src` and # bin/ocx.mjs executes it: without that entry an ordinary source PR @@ -936,6 +951,40 @@ jobs: - name: Build, start, and recreate the container run: bun scripts/ci/docker-smoke.ts + # Proves the Astro toolchain still builds the site, on the only event that can + # prove it before promotion. + # + # Linux only, and one leg. The site is static output from a Node/Bun toolchain + # with no OS-specific behaviour to promise, so a Windows or macOS leg would buy + # queue time rather than coverage. `docs-site` keeps its own manifest and + # lockfile, so this installs there and nowhere else. + # + # `--frozen-lockfile` is the point of the job as much as the build is: it fails + # on a manifest and lockfile that disagree, which is exactly the shape a + # hand-edited override introduces. + docs-site-build: + name: docs site build + needs: changes + if: needs.changes.outputs.docs == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Install docs-site dependencies + working-directory: docs-site + run: bun install --frozen-lockfile + + - name: Build the docs site + working-directory: docs-site + run: bun run build + npm-global-smoke: name: npm-global ${{ matrix.os }} needs: changes @@ -1014,7 +1063,7 @@ jobs: # direct dependencies only, so a failing `select-windows-runner` would # otherwise reach this gate as nothing at all while its dependents report # `skipped`, which is the shape the step below is written to catch. - needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, npm-global-smoke] + needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, npm-global-smoke] runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -1031,6 +1080,7 @@ jobs: LANE: ${{ github.event.inputs.lane }} CHANGES_CI: ${{ needs.changes.outputs.ci }} CHANGES_PACKAGING: ${{ needs.changes.outputs.packaging }} + CHANGES_DOCS: ${{ needs.changes.outputs.docs }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail @@ -1055,6 +1105,10 @@ jobs: if [ "$CHANGES_PACKAGING" = "true" ]; then packaging=requested fi + docs=not-requested + if [ "$CHANGES_DOCS" = "true" ]; then + docs=requested + fi dispatch=not-requested windows=not-requested if [ "$EVENT_NAME" = "workflow_dispatch" ]; then @@ -1069,7 +1123,7 @@ jobs: # without adding it here fails the gate by name rather than passing unnoticed. GATED_JOBS="changes select-windows-runner test storage-policy api-usage gates" GATED_JOBS="$GATED_JOBS platform-macos keyring-smoke docker-smoke npm-global-smoke" - GATED_JOBS="$GATED_JOBS macos-control platform-windows" + GATED_JOBS="$GATED_JOBS macos-control platform-windows docs-site-build" expected_for() { case "$1" in @@ -1077,6 +1131,7 @@ jobs: test|storage-policy|api-usage|gates|platform-macos|keyring-smoke|docker-smoke) echo "$scoped" ;; npm-global-smoke) echo "$packaging" ;; + docs-site-build) echo "$docs" ;; macos-control) echo "$dispatch" ;; platform-windows) echo "$windows" ;; *) echo undeclared ;; diff --git a/devlog/_plan/260917_l1_preview_read_fence_and_dep_audit/030_unit_c_docs_site_build_gate.md b/devlog/_plan/260917_l1_preview_read_fence_and_dep_audit/030_unit_c_docs_site_build_gate.md new file mode 100644 index 0000000000..ef3f618fde --- /dev/null +++ b/devlog/_plan/260917_l1_preview_read_fence_and_dep_audit/030_unit_c_docs_site_build_gate.md @@ -0,0 +1,58 @@ +# Unit C — a pull-request build gate for the docs site + +Opened because unit B's review could not honestly close. The Astro 7.2 to 7.3 bump +in #4873 had no pull-request build gate anywhere, so no amount of green CI on that +head was evidence the docs site still built. The alternative was to accept an +author's local result as the record, and that is not a standard this repository +applies elsewhere. + +## The gap + +`.github/workflows/ci.yml` contained no `docs-site` reference and built no docs. +`deploy-docs.yml` triggers only on `push` to `main`, which is after promotion. So +the first machine to discover a broken docs build was the deploy, and the only +pre-merge evidence available was an unverifiable attestation. + +## Shape + +A new `docs` filter in the existing `changes` job, and one job selected by it. + +`docs-site/**` is deliberately **not** added to the `ci` filter. Widening `ci` +would start the whole cross-platform matrix for a prose edit, which is a cost with +no matching evidence: a docs change has to build, not to pass the runtime suite. +A separate filter output keeps the two questions apart. + +`.github/workflows/ci.yml` is in the `docs` filter so an edit to the job verifies +itself. Without that entry this unit's own pull request would skip the job it adds, +which is the failure mode the unit exists to remove. + +One Linux leg. The site is static output from a Node/Bun toolchain with no +OS-specific behaviour to promise, so a Windows or macOS leg would spend queue time +without buying coverage. `--frozen-lockfile` carries as much of the value as the +build does: it fails on a manifest and lockfile that disagree, which is exactly the +shape a hand-edited override introduces. + +## Constraints honoured + +No existing job's sharding, timeout, or runner selection is touched. No new Windows +leg. Workflow-level `permissions` stay `contents: read` and the job adds none. +Actions stay pinned to immutable SHAs with their version comments. + +The aggregate gate is the part that is easy to get wrong. `ci` is event-aware since +#4837: it derives what the event requested and demands `success` from each requested +job and `skipped` from every other one, and it fails by name on any job with no +declared expectation. So the job is added in four places that must agree: +`needs`, `CHANGES_DOCS`, `GATED_JOBS`, and `expected_for`. And +`tests/ci-workflows/ci-workflows.test.ts` independently derives the expected +`needs` list from the workflow's own job keys, so a missing entry fails rather than +passing quietly. + +`structure/ops/docs-and-release.md` owns `docs-site/` and is updated in the same +change, including the workflow map. + +## Sequencing + +This unit lands before #4873 merges. #4873 then needs a fresh run for the new job +to appear on its head; a re-run is the cheap way to find out whether the merge-ref +workflow already carries it, before considering anything that rewrites that branch. +Both calls belong to the host. diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 65f519d38b..74267d090f 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -47,6 +47,17 @@ https://opencodex.me/ The workflow runs on `main` pushes touching `docs-site/**` or the workflow itself, builds `docs-site`, uploads the artifact, and deploys with GitHub Pages. +That workflow is the deploy path, not a review gate: it first runs after promotion to `main`, +so on its own it can only report a broken site once the change has already left review. The +pull-request gate is the `docs-site-build` job in `.github/workflows/ci.yml`, selected by the +`changes` job's `docs` filter (`docs-site/**` and the workflow itself). One Linux leg installs +`docs-site` with `--frozen-lockfile` and runs the Astro build, so a manifest and lockfile that +disagree fail before the build does. The `ci` aggregate treats it exactly like the other scoped +jobs: requested when the filter is true, required `skipped` otherwise. + +The deliberate omission is that `docs-site/**` is not in the `ci` filter. A prose edit has no +business starting the cross-platform suite; it only has to build. + > Decision record: [ADR-0080](../decisions/ADR-0080-github-pages.md) Local validation: @@ -92,7 +103,7 @@ Those controls still have no owner, so there is no image-publish workflow or off | `.github/workflows/ci.yml` | Any `pull_request`; runtime/package `push` to `main`/`preview`/`dev`; manual dispatch | Linux runs four suite shards plus `gates`; macOS runs two shards. Windows runs nine shards only on manual dispatch with `lane=all` (or empty), not on push events. Linux runs at-most-12-file processes with a 120-second process bound; Windows uses measured six-file/480-second processes and all-file scope so its full-suite contract is unchanged. The dedicated Windows batch step sets `OCX_TEST_NO_QUEUE=1` because its sequential processes are one logical runner; each process still creates an isolated home and arms the test guards before the lock boundary. No lane retries: a test failure, a process timeout and a Bun runtime crash each fail their job on the first occurrence. Aggregate `ci` is event-aware — it derives which jobs this event requested and requires `success` from each of them and `skipped` from the rest, and on a `lane=all` dispatch it reads the run's own job list and requires nine concrete successful `windows N/9` results. `npm-global-smoke` remains GitHub-hosted because it mutates the global package prefix. | | `.github/workflows/dev-version-bump.yml` | Manual dispatch with an intended version and `pre-move` or `repair` mode | Opens the reviewed pull request that moves `dev` past a release target. The default `pre-move` mode runs before promotion and publication; explicit `repair` mode retains the post-publish catch-up path. It is neither called by `release.yml` nor triggered by publication. | | `.github/workflows/release.yml` | Manual dispatch only | npm publish/dry-run workflow. It requires successful Cross-platform CI for the exact `GITHUB_SHA`, requires `dev` to outrank the target, then checks the target against the freshly fetched global tag set before publish or dry-run. | -| `.github/workflows/deploy-docs.yml` | `push` to `main` touching `docs-site/**` or the workflow, or manual dispatch | Build and publish the Astro/Starlight docs site to GitHub Pages. | +| `.github/workflows/deploy-docs.yml` | `push` to `main` touching `docs-site/**` or the workflow, or manual dispatch | Build and publish the Astro/Starlight docs site to GitHub Pages. This is the deploy path; the pull-request build gate is the `docs-site-build` job in `ci.yml`. | | `.github/workflows/service-lifecycle.yml` | `pull_request` to `main`/`dev` and `push`, both filtered on the service path set (`src/service.ts`, `src/cli.ts`, `src/cli/index.ts`, `src/lib/bun-runtime.ts`, `package.json`, `bun.lock`, the workflow), or manual dispatch | Service-lifecycle smoke on three platforms: Linux systemd, macOS launchd, and Windows Scheduled Tasks. Each installs, verifies, stops via `ocx stop`, and uninstalls. The path list is kept in sync with the `release.yml` service-gate regex. | | `.github/workflows/enforce-pr-target.yml` | `pull_request_target` (opened, reopened, edited, labeled, unlabeled, ready_for_review, synchronize) plus default-branch `status` events filtered to successful `CodeRabbit` statuses | The `enforce-target` gate: rejects pull requests whose head ancestry sits on the `main` tip while far behind `dev`, rejects empty or malformed descriptions, requires a GUI screenshot when the title/body mentions `gui` (immediately waivable with the maintainer-controlled `gui-screenshot-waived` label; legacy maintainer comments remain compatibility evidence on later PR events), keeps contributor PRs in draft until a four-box readiness checklist is complete, verifies the CI / latest-dev / Codex+CodeRabbit-findings claims (review threads plus current-head CodeRabbit review-body findings outside the diff range), and adds a `review-ready` status label at the ready moment. CodeRabbit status SHAs must resolve to exactly one open current-head PR before writes. Stacked child PRs targeting another open PR's head skip the wrong-base gate. | | `.github/workflows/enforce-issue-quality.yml` | `issues` (opened, edited, reopened), `issue_comment` (created, edited), or manual dispatch with an issue number | Issue-template compliance gate. |