From 21689ef59b0eb34b1a29eda739dc10d33d25b44a Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Fri, 10 Jul 2026 17:14:13 +0300 Subject: docs: Cloudflare Workers hosting pipeline (apex, content workers, CI, previews) (#2140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs-infra: scaffold Cloudflare workers workspace (versions.json v2, matrix, toolchain) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: record full Phase-0 plan decision in workers/PLAN.md ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: shared content worker โ€” asset serving, cache classes, X-Docs-Build, canary no-store ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: run worker script before assets; test fetch entrypoint ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: apex manifest loader + dispatch map + runtime binding guard (TDD) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: apex redirects (aliases, PDF, trailing-slash) + special paths (TDD) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: PDF redirect honors pdf:null and preserves query ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: apex UA gate โ€” allowlist-wins, log-only AI crawlers, empty block list at launch ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: apex router (pipeline ยง3.2), themed 404/503, /kb seam, env configs + congruence test ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: add missing-User-Agent regression test for apex UA gate ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: R2-streaming preview worker โ€” MIME map, noindex, no-store (TDD) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: preview 404 no-store + fetch handler tests ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: bootstrap script โ€” binding-target workers must exist before apex deploys ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: apex run_worker_first, lockfile for npm ci, PDF Location from manifest ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: derive html_baseurl from DOCS_VERSION_SLUG with RTD fallback (canonical gate prereq) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: version picker + status banner + language scaffold (vanilla JS, TDD pure core) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: picker preserves query+hash across switch; valid breadcrumb markup ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: Pagefind search wrapper with runtime base-path + preview prefix handling (TDD) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: pagefind wrapper โ€” asset-failure notice + UI stylesheet load ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: gate Pagefind searchbox to CF builds (RTD keeps stock search until cutover) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: deploy sanity gates โ€” limits, critical pages, count-delta, canonical (TDD) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: hermetic gate tests via fixture versions.json ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: docs-build workflow โ€” candidate/smoke/promote two-stage deploy + registry + rollback Two-stage CF Workers pipeline: build in pinned container, assemble artifact, sanity gates, deploy candidate, scoped pre-traffic smoke via canary apex, promote (rollback-id capture, hostname purge, registry upload), post-promote probe + auto-rollback. DOCS_CF_LIVE repo variable gates every docs.vyos.io production interaction pre-cutover. scripts/docs_gates/smoke.py adds one authorized check beyond the spec: the version's index.html probe asserts the #vyos-search mount div is present in the response body, guarding CI silently forgetting DOCS_VERSION_SLUG (which would otherwise ship stock RTD search without the Pagefind gate noticing). ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: build docs image in-workflow with buildx cache (v4.1 โ€” digest pin dropped) Plan v4.1 amendment: the ghcr.io digest-pinned image does not exist (workflow would hard-fail at the first docker step on every push). Replace the BUILD_IMAGE env placeholder with an in-workflow docker build from docker/Dockerfile via docker/setup-buildx-action@v3 + docker/build-push-action@v6 (context: docker/, load: true, tags: docs-build:local, GHA cache from/to). The checked-out commit is the pin; buildx GHA cache keeps repeat builds cheap. Sphinx-build step swaps to docs-build:local; inner script unchanged. ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: apex/preview deploy workflow โ€” canary auto, production behind environment approval * docs-infra: apex-deploy concurrency guard (per-ref, cancel-in-progress) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: fork-safe PR preview pipeline โ€” approval record, R2 prefixes, label consumption, cleanup * docs-infra: nightly preview sweep โ€” pipefail + per-prefix failure isolation ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: nightly canary QA โ€” per-entry sweep + URL-parity corpus vs RTD ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: parity sweep scoped to CF-built versions; transport-error resilience ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: one-off bootstrap workflow (binding targets โ€” runs once on this push) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: remove one-off bootstrap workflow (bootstrap complete) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: one-off canary apex + preview deploy (route targets for Task 3.6 step 2c) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: remove one-off canary deploy workflow (targets live) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: address Phase-0 CodeRabbit findings (canonical gate, error caching, registry pointer, validation) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: strengthen manifest tests (full dispatch iteration, mutation-free validate) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: address GitHub CodeRabbit review (pointer-after-probe, fail-closed sweeps, block-precedence UA gate, preview hardening) ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: adversarial review fixes โ€” error no-store, probe retry, PR-list membership, preview dotted-segment ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: serve oversized legacy PDF from R2 via apex (spec ยง5 fallback) The 1.3 PDF (29.2 MiB) exceeds the 25 MiB static-asset cap and is absent from the legacy content Worker's build, so /_/downloads/en/1.3/pdf/ (and the picker's PDF link) 301'd into a dead-end 404 post-cutover. Add the R2 object fallback spec ยง5 already documented but never implemented: a DOCS_PDFS R2 bucket binding on the apex Worker, a manifest pdf_r2_key field (1.3 only), and a router step ahead of version dispatch that streams the object with its own cache class (canary/error still force no-store). ๐Ÿค– Generated by [robots](https://vyos.io) * docs-infra: PDF R2 fallback honors Range + If-None-Match, preserves ETag ๐Ÿค– Generated by [robots](https://vyos.io) --- .github/workflows/docs-preview-deploy.yml | 86 +++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .github/workflows/docs-preview-deploy.yml (limited to '.github/workflows/docs-preview-deploy.yml') diff --git a/.github/workflows/docs-preview-deploy.yml b/.github/workflows/docs-preview-deploy.yml new file mode 100644 index 00000000..3e3de0d3 --- /dev/null +++ b/.github/workflows/docs-preview-deploy.yml @@ -0,0 +1,86 @@ +name: Docs preview deploy +on: + workflow_run: + workflows: ["Docs preview build"] + types: [completed] +permissions: + contents: read + pull-requests: write + issues: write # label removal + PR comment go through the issues API + statuses: read + actions: read # cross-run artifact download +jobs: + deploy: + if: github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 # BASE repo code only โ€” never PR head (ยง10) + - uses: actions/download-artifact@v4 + with: + name: docs-preview + path: out + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + - name: Authorization gate (approval record + SHA match, ยง10) + id: gate + env: { GH_TOKEN: "${{ github.token }}" } + run: | + set -eu + # TRUST MODEL (ยง10): out/PR_NUMBER + out/HEAD_SHA come from the UNPRIVILEGED + # run and are attacker-controlled. Trusted anchors: the workflow_run event's + # head_sha, and the approval record (commit status) written by the privileged + # approve workflow. The artifact is accepted only when all three agree. + event_sha='${{ github.event.workflow_run.head_sha }}' + art_sha=$(cat out/HEAD_SHA) + [ "$art_sha" = "$event_sha" ] || { echo "::error::artifact SHA ($art_sha) != workflow_run head_sha ($event_sha)"; exit 1; } + # Validate the artifact's claimed PR number BEFORE it's used anywhere โ€” it comes + # from the unprivileged run and must be a bare digit string. + art_pr=$(cat out/PR_NUMBER) + case "$art_pr" in + ''|*[!0-9]*) echo "::error::artifact PR_NUMBER is not a bare digit string: '$art_pr'"; exit 1 ;; + esac + # Resolve EVERY PR associated with the trusted head SHA โ€” a single commit can be + # the head of more than one open PR, so picking just .[0].number is not reliable. + # Never trust out/PR_NUMBER for authorization on its own: require it to be a + # MEMBER of this trusted list. The membership check happens in bash (below), not + # inside a jq filter, so the untrusted artifact value is never interpolated into + # jq code. + pr_list=$(gh api "repos/${{ github.repository }}/commits/$event_sha/pulls" --jq '.[].number') + [ -n "$pr_list" ] || { echo "no open PR for $event_sha"; exit 78; } + pr="" + while IFS= read -r candidate; do + if [ "$candidate" = "$art_pr" ]; then + pr="$candidate" + break + fi + done <<< "$pr_list" + [ -n "$pr" ] || { echo "::error::artifact PR number ($art_pr) is not among the trusted PRs for $event_sha ($pr_list)"; exit 1; } + state=$(gh pr view "$pr" --repo '${{ github.repository }}' --json state,labels) + echo "$state" | jq -e '.state == "OPEN"' >/dev/null || { echo "PR closed"; exit 78; } + echo "$state" | jq -e '.labels[].name | select(. == "preview")' >/dev/null || { echo "no preview label"; exit 78; } + gh api "repos/${{ github.repository }}/commits/$event_sha/statuses" \ + --jq '.[] | select(.context=="docs-preview-approved" and .state=="success")' | grep -q . \ + || { echo "::error::no approval record on $event_sha โ€” artifact predates/postdates labeling"; exit 1; } + echo "pr=$pr" >> "$GITHUB_OUTPUT" + - name: Upload to R2 prefix (bulk, S3 API โ€” ยง10 MIME via content-type detection) + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + R2_ENDPOINT: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com + run: | + set -eu + pr='${{ steps.gate.outputs.pr }}' + rm -f out/PR_NUMBER out/HEAD_SHA + # aws s3 sync = parallel bulk upload + per-file Content-Type guessing. + # Sequential per-file `wrangler r2 object put` was rejected: one Node + # process per file ร— thousands of files โ‰ˆ runner-timeout territory. + aws s3 sync out "s3://vyos-docs-previews/pr-$pr/" \ + --endpoint-url "$R2_ENDPOINT" --delete --only-show-errors + - name: Consume label + comment URL (ยง10) + env: { GH_TOKEN: "${{ github.token }}" } + run: | + pr='${{ steps.gate.outputs.pr }}' + gh pr edit "$pr" --remove-label preview --repo '${{ github.repository }}' || true + gh pr comment "$pr" --repo '${{ github.repository }}' \ + --body "๐Ÿ“– Docs preview: https://docs-preview.vyos.io/pr-$pr/en/rolling/ (label consumed โ€” re-apply \`preview\` to redeploy after new pushes)" -- cgit v1.2.3