ci: authenticate docs preview build (#2115)

This commit is contained in:
Carlos Florêncio
2026-07-31 21:52:05 +01:00
committed by GitHub
parent 9a3f224224
commit 9f7662d540
5 changed files with 356 additions and 6 deletions
+21 -6
View File
@@ -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 = '<!-- kandev-docs-cloudflare-preview -->';
@@ -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.
@@ -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.
@@ -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.
+94
View File
@@ -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/);
});