ci: authenticate docs preview build (#2115)
This commit is contained in:
@@ -11,6 +11,7 @@ on:
|
|||||||
- "docs/public/**"
|
- "docs/public/**"
|
||||||
- "scripts/validate-public-docs.mjs"
|
- "scripts/validate-public-docs.mjs"
|
||||||
- "scripts/validate-public-docs.test.mjs"
|
- "scripts/validate-public-docs.test.mjs"
|
||||||
|
- "scripts/notify-docs-workflow.test.mjs"
|
||||||
- ".github/workflows/notify-docs.yml"
|
- ".github/workflows/notify-docs.yml"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
@@ -34,13 +35,17 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Test public docs validator
|
- 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
|
- name: Validate public docs
|
||||||
run: node scripts/validate-public-docs.mjs
|
run: node scripts/validate-public-docs.mjs
|
||||||
|
|
||||||
preview:
|
preview:
|
||||||
name: Deploy Cloudflare Pages 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
|
needs: validate
|
||||||
if: >-
|
if: >-
|
||||||
github.event_name == 'pull_request' &&
|
github.event_name == 'pull_request' &&
|
||||||
@@ -49,8 +54,6 @@ jobs:
|
|||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout docs pull request
|
- name: Checkout docs pull request
|
||||||
@@ -87,6 +90,7 @@ jobs:
|
|||||||
- name: Build preview from pull request docs
|
- name: Build preview from pull request docs
|
||||||
working-directory: landing
|
working-directory: landing
|
||||||
env:
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
KANDEV_DOCS_SOURCE_PATH: ${{ github.workspace }}/docs
|
KANDEV_DOCS_SOURCE_PATH: ${{ github.workspace }}/docs
|
||||||
run: pnpm run build:pages
|
run: pnpm run build:pages
|
||||||
|
|
||||||
@@ -126,12 +130,23 @@ jobs:
|
|||||||
--branch=docs-pr-${{ github.event.pull_request.number }}
|
--branch=docs-pr-${{ github.event.pull_request.number }}
|
||||||
--commit-dirty=true
|
--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
|
- name: Publish docs preview link
|
||||||
if: steps.cloudflare.outputs.enabled == 'true'
|
|
||||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
|
||||||
env:
|
env:
|
||||||
DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }}
|
DEPLOYMENT_URL: ${{ needs.preview.outputs.deployment_url }}
|
||||||
ALIAS_URL: ${{ steps.deploy.outputs.pages-deployment-alias-url }}
|
ALIAS_URL: ${{ needs.preview.outputs.alias_url }}
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const marker = '<!-- kandev-docs-cloudflare-preview -->';
|
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