From 9f7662d540a2e91d29f45beb39f4ab1db08aeafa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 31 Jul 2026 21:52:05 +0100 Subject: [PATCH] ci: authenticate docs preview build (#2115) --- .github/workflows/notify-docs.yml | 27 +++-- .../plan.md | 98 +++++++++++++++++++ ...ask-01-authenticate-and-isolate-preview.md | 71 ++++++++++++++ .../spec.md | 72 ++++++++++++++ scripts/notify-docs-workflow.test.mjs | 94 ++++++++++++++++++ 5 files changed, 356 insertions(+), 6 deletions(-) create mode 100644 docs/plans/published-docs-preview-reliability/plan.md create mode 100644 docs/plans/published-docs-preview-reliability/task-01-authenticate-and-isolate-preview.md create mode 100644 docs/specs/published-docs-preview-reliability/spec.md create mode 100644 scripts/notify-docs-workflow.test.mjs diff --git a/.github/workflows/notify-docs.yml b/.github/workflows/notify-docs.yml index 0f12e44e57..3893b1514e 100644 --- a/.github/workflows/notify-docs.yml +++ b/.github/workflows/notify-docs.yml @@ -11,6 +11,7 @@ on: - "docs/public/**" - "scripts/validate-public-docs.mjs" - "scripts/validate-public-docs.test.mjs" + - "scripts/notify-docs-workflow.test.mjs" - ".github/workflows/notify-docs.yml" workflow_dispatch: @@ -34,13 +35,17 @@ jobs: persist-credentials: false - name: Test public docs validator - run: node --test scripts/validate-public-docs.test.mjs + run: node --test scripts/validate-public-docs.test.mjs scripts/notify-docs-workflow.test.mjs - name: Validate public docs run: node scripts/validate-public-docs.mjs preview: name: Deploy Cloudflare Pages preview + outputs: + enabled: ${{ steps.cloudflare.outputs.enabled }} + deployment_url: ${{ steps.deploy.outputs.deployment-url }} + alias_url: ${{ steps.deploy.outputs.pages-deployment-alias-url }} needs: validate if: >- github.event_name == 'pull_request' && @@ -49,8 +54,6 @@ jobs: timeout-minutes: 15 permissions: contents: read - issues: write - pull-requests: write steps: - name: Checkout docs pull request @@ -87,6 +90,7 @@ jobs: - name: Build preview from pull request docs working-directory: landing env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} KANDEV_DOCS_SOURCE_PATH: ${{ github.workspace }}/docs run: pnpm run build:pages @@ -126,12 +130,23 @@ jobs: --branch=docs-pr-${{ github.event.pull_request.number }} --commit-dirty=true + publish-preview-link: + name: Publish docs preview link + needs: preview + if: needs.preview.outputs.enabled == 'true' + runs-on: ubuntu-latest + timeout-minutes: 4 + permissions: + contents: read + issues: write + pull-requests: write + + steps: - name: Publish docs preview link - if: steps.cloudflare.outputs.enabled == 'true' uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 env: - DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }} - ALIAS_URL: ${{ steps.deploy.outputs.pages-deployment-alias-url }} + DEPLOYMENT_URL: ${{ needs.preview.outputs.deployment_url }} + ALIAS_URL: ${{ needs.preview.outputs.alias_url }} with: script: | const marker = ''; diff --git a/docs/plans/published-docs-preview-reliability/plan.md b/docs/plans/published-docs-preview-reliability/plan.md new file mode 100644 index 0000000000..5daacace69 --- /dev/null +++ b/docs/plans/published-docs-preview-reliability/plan.md @@ -0,0 +1,98 @@ +--- +spec: docs/specs/published-docs-preview-reliability/spec.md +created: 2026-07-31 +status: implemented +--- + +# Implementation Plan: Published Docs Preview Reliability + +## Overview + +The cited preview failed because `.github/workflows/notify-docs.yml` launches +landing without `GITHUB_TOKEN`; the renderer therefore consumes the hosted +runner IP's 60-request unauthenticated quota and rejects any generated fallback +community data. The repair first adds a static regression test for the workflow +permission boundary, then authenticates the build with a read-only job token +and moves preview-comment publication to a separate write-capable job. + +## Root Cause + +- The preview build step exports `KANDEV_DOCS_SOURCE_PATH` but not + `GITHUB_TOKEN`, confirmed by the failing log's + `[github-auth] configured=false` diagnostic. +- Landing fetches repository metrics and contribution activity during static + generation. A non-success response produces fallback markup that + `build-pages.mjs` rejects. +- GitHub's unauthenticated REST quota is IP-scoped and limited to 60 requests + per hour, so runner starting quota and concurrent consumption make the result + intermittent. +- The existing preview job also owns issue and pull-request write permissions + for its final comment step. Passing that job token directly to the build + would make the preview reliable but violate least privilege. + +## Workflow + +### Static permission contract + +- Add `scripts/notify-docs-workflow.test.mjs` to inspect the checked-in + workflow and prove the preview build has an explicit token, the preview job + is read-only, and preview-link publication is isolated in a write-capable job + that does not build pull request content. +- Add the new test to the workflow's pull-request path filter and validator + test command so future workflow changes keep exercising the contract. + +### Authenticated preview build + +- Restrict `jobs.preview.permissions` in + `.github/workflows/notify-docs.yml` to `contents: read`. +- Export `GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}` only from the + `Build preview from pull request docs` step. Landing already recognizes this + environment variable through its existing `githubApiHeaders()` contract. +- Export the Cloudflare-enabled flag, deployment URL, and alias URL as preview + job outputs. + +### Isolated preview comment + +- Move `Publish docs preview link` into a `publish-preview-link` job that needs + `preview`, runs only when preview deployment is enabled, and owns + `issues: write` plus `pull-requests: write`. +- Pass deployment outputs through `needs.preview.outputs` and retain the + existing idempotent marker-based create-or-update script. + +## Tests + +- **What:** the landing build receives an explicit authenticated token while + its job cannot write issues or pull requests. + **File:** `scripts/notify-docs-workflow.test.mjs`. + **How:** extract the checked-in preview job and named build step from the YAML + text, then assert the token mapping and permission exclusions. +- **What:** preview comment publication retains write permissions without + executing the landing build. + **File:** `scripts/notify-docs-workflow.test.mjs`. + **How:** extract the publication job and assert its dependency, output inputs, + permissions, and absence of checkout/build steps. +- **What:** public-doc validation and the workflow contract remain green under + the exact command used by CI. + **File:** `.github/workflows/notify-docs.yml`. + **How:** run both Node test files together, then run the public-doc validator. + +## Implementation Waves And Parallel Candidates + +Execution is sequential because the regression test and workflow repair share +the same permission and job-output contract. + +- [x] [Task 01: Authenticate and isolate docs preview publication](task-01-authenticate-and-isolate-preview.md) — done + +## Documentation Impact + +The concise repair spec records CI behavior and permissions. No public CLI, +configuration, API, deployment, or UI documentation changes are required. + +## Risks + +- Job outputs must preserve the exact Cloudflare action output values across + the new job boundary. +- The publication job must remain skipped when Cloudflare is unconfigured or + preview fails. +- Exposing the token at workflow or job scope would unnecessarily widen its + availability; the implementation must keep it scoped to the build step. diff --git a/docs/plans/published-docs-preview-reliability/task-01-authenticate-and-isolate-preview.md b/docs/plans/published-docs-preview-reliability/task-01-authenticate-and-isolate-preview.md new file mode 100644 index 0000000000..c1773381c5 --- /dev/null +++ b/docs/plans/published-docs-preview-reliability/task-01-authenticate-and-isolate-preview.md @@ -0,0 +1,71 @@ +--- +id: "01-authenticate-and-isolate-preview" +title: "Authenticate and isolate docs preview publication" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/published-docs-preview-reliability/spec.md" +--- + +# Task 01: Authenticate and Isolate Docs Preview Publication + +## Acceptance + +- The landing build receives `${{ secrets.GITHUB_TOKEN }}` while its job has + only `contents: read`. +- Preview deployment URLs flow to a separate comment-publication job with issue + and pull-request write permissions, and that job never checks out or builds + pull request content. +- A checked-in regression test enforces both permission boundaries and runs in + the public-doc validation job. + +## Verification + +```bash +node --test scripts/notify-docs-workflow.test.mjs +node --test scripts/validate-public-docs.test.mjs scripts/notify-docs-workflow.test.mjs +node scripts/validate-public-docs.mjs +``` + +## Files likely touched + +- `.github/workflows/notify-docs.yml` +- `scripts/notify-docs-workflow.test.mjs` + +## Dependencies + +None. + +## Parallelism + +`sequential`; the test and workflow share one security contract. + +## Inputs + +- Repair spec scenarios and permission boundary. +- Failing job `91236751922`, which reported + `[github-auth] configured=false` and rejected unavailable community data. +- Passing job `91230452438`, which used the same unauthenticated path with more + starting quota. +- Landing `scripts/build-pages.mjs`, `lib/github-community.ts`, and + `packages/site/src/github-api.ts` contracts on `kdlbs/landing` `main`. + +## Output contract + +Report the RED workflow-contract failure, minimal workflow repair, files +changed, exact verification results, residual risks, and update this task plus +`plan.md` status in the same conversation. + +## Result + +- RED coverage failed against the original workflow because the preview job + still had issue/pull-request write permissions, the build had no + `GITHUB_TOKEN`, and no isolated publication job existed. +- The preview job now uses `contents: read`, passes `GITHUB_TOKEN` only to the + landing build, and exports deployment URLs to a separate write-capable + publication job. +- Verified with `node --test scripts/notify-docs-workflow.test.mjs` (2 tests), + `node --test scripts/validate-public-docs.test.mjs scripts/notify-docs-workflow.test.mjs` + (60 tests), `node scripts/validate-public-docs.mjs` (41 pages), + `git diff --check`, and a PyYAML parse of the workflow. diff --git a/docs/specs/published-docs-preview-reliability/spec.md b/docs/specs/published-docs-preview-reliability/spec.md new file mode 100644 index 0000000000..ba2c186a9c --- /dev/null +++ b/docs/specs/published-docs-preview-reliability/spec.md @@ -0,0 +1,72 @@ +--- +status: shipped +created: 2026-07-31 +owner: kdlbs +--- + +# Published Docs Preview Reliability + +## Why + +Pull request documentation previews intermittently fail when the landing build +cannot fetch live public GitHub community data. Contributors need preview +results to depend on the proposed documentation and publisher contract, not on +the shared unauthenticated GitHub API quota of a hosted runner. + +## What + +- Same-repository pull request previews authenticate landing's GitHub API + reads with the workflow job's short-lived `GITHUB_TOKEN`. +- The landing build receives only a token whose job permissions are restricted + to read-only repository contents. +- Publishing or updating the preview link remains available through a separate + job with issue and pull-request write permissions. +- The workflow retains its existing same-repository pull request gate, + Cloudflare deployment behavior, stable alias, and preview comment format. +- A repository test fails if the build loses authenticated GitHub API access or + regains issue or pull-request write permissions. + +## Permissions + +- The preview build and Cloudflare deployment job has `contents: read` and no + issue or pull-request write permission. Its `GITHUB_TOKEN` is exposed only to + the landing build step for authenticated public GitHub API reads. +- The preview-link publication job has `contents: read`, `issues: write`, and + `pull-requests: write`. It consumes only the deployment URLs exported by the + successful preview job and does not check out or build pull request content. +- Fork pull requests remain excluded from the preview path. + +## Failure modes + +- A failed landing build or Cloudflare deployment fails the preview job and + prevents preview-link publication. +- Missing Cloudflare configuration retains the existing warning-and-skip + behavior and does not start the publication job. +- A missing deployment URL fails the publication job rather than publishing an + incomplete comment. +- GitHub API failure after authenticated access remains a real build failure; + the workflow does not publish stale or fallback community data as a valid + preview. + +## Scenarios + +- **GIVEN** a same-repository pull request changes public documentation, + **WHEN** the landing preview build requests public Kandev repository data, + **THEN** the request uses the job-scoped `GITHUB_TOKEN` under read-only + contents permissions. +- **GIVEN** a successful configured Cloudflare preview deployment, **WHEN** the + deployment exports its URL and stable alias, **THEN** a separate write-capable + job creates or updates the pull request preview comment. +- **GIVEN** the preview build executes pull request documentation, **WHEN** that + process inspects its job token, **THEN** the token has no issue or pull-request + write permission. +- **GIVEN** Cloudflare preview deployment is not configured or the preview job + fails, **WHEN** downstream jobs are evaluated, **THEN** no preview-link + publication job runs. + +## Out of scope + +- Changing landing's community-data fallback or artifact validation logic. +- Supporting preview deployments for fork pull requests. +- Changing Cloudflare credentials, project configuration, deployment aliases, + or the production rebuild hook. diff --git a/scripts/notify-docs-workflow.test.mjs b/scripts/notify-docs-workflow.test.mjs new file mode 100644 index 0000000000..6f116e6bd3 --- /dev/null +++ b/scripts/notify-docs-workflow.test.mjs @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const workflowPath = path.join( + repoRoot, + ".github/workflows/notify-docs.yml", +); + +function extractJob(workflow, name) { + const marker = ` ${name}:\n`; + const start = workflow.indexOf(marker); + assert.notEqual(start, -1, `workflow job not found: ${name}`); + + const bodyStart = start + marker.length; + const remainder = workflow.slice(bodyStart); + const nextJob = remainder.search(/\n [a-z0-9-]+:\n/); + return remainder.slice(0, nextJob === -1 ? remainder.length : nextJob); +} + +function extractStep(job, name) { + const marker = ` - name: ${name}\n`; + const start = job.indexOf(marker); + assert.notEqual(start, -1, `workflow step not found: ${name}`); + + const bodyStart = start + marker.length; + const remainder = job.slice(bodyStart); + const nextStep = remainder.search(/\n - name: /); + return remainder.slice(0, nextStep === -1 ? remainder.length : nextStep); +} + +const tokenExpression = String.raw`\$\{\{ secrets\.GITHUB_TOKEN \}\}`; + +test("docs preview build uses an authenticated read-only token", async () => { + const workflow = await fs.readFile(workflowPath, "utf8"); + const preview = extractJob(workflow, "preview"); + const build = extractStep(preview, "Build preview from pull request docs"); + const validate = extractJob(workflow, "validate"); + const testStep = extractStep(validate, "Test public docs validator"); + + assert.match( + preview, + /permissions:\n contents: read\n\n steps:/, + "preview job permissions must be limited to contents: read", + ); + assert.match(build, new RegExp(`GITHUB_TOKEN: ${tokenExpression}`)); + assert.doesNotMatch( + preview.replace(build, ""), + /GITHUB_TOKEN/, + "GITHUB_TOKEN must remain scoped to the landing build step", + ); + assert.match(testStep, /scripts\/validate-public-docs\.test\.mjs/); + assert.match(testStep, /scripts\/notify-docs-workflow\.test\.mjs/); + assert.match(workflow, /- "scripts\/notify-docs-workflow\.test\.mjs"/); +}); + +test("preview comment publication is isolated from pull request content", async () => { + const workflow = await fs.readFile(workflowPath, "utf8"); + const preview = extractJob(workflow, "preview"); + const publication = extractJob(workflow, "publish-preview-link"); + const publishStep = extractStep(publication, "Publish docs preview link"); + + assert.match( + preview, + /outputs:\n enabled: \$\{\{ steps\.cloudflare\.outputs\.enabled \}\}\n deployment_url: \$\{\{ steps\.deploy\.outputs\.deployment-url \}\}\n alias_url: \$\{\{ steps\.deploy\.outputs\.pages-deployment-alias-url \}\}/, + ); + assert.match(publication, /needs: preview/); + assert.match( + publication, + /if: needs\.preview\.outputs\.enabled == 'true'/, + ); + assert.match(publication, /permissions:\n contents: read\n issues: write\n pull-requests: write\n/); + assert.match( + publishStep, + new RegExp( + `DEPLOYMENT_URL: ${String.raw`\$\{\{ needs\.preview\.outputs\.deployment_url \}\}`}`, + ), + ); + assert.match( + publishStep, + new RegExp( + `ALIAS_URL: ${String.raw`\$\{\{ needs\.preview\.outputs\.alias_url \}\}`}`, + ), + ); + assert.match(publishStep, /uses: actions\/github-script@/); + assert.doesNotMatch(publication, /actions\/checkout@/); + assert.doesNotMatch(publication, /pnpm run build:pages/); +});