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. |