ci: authenticate docs preview build (#2115)
This commit is contained in:
@@ -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.
|
||||
+71
@@ -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.
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user