diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml new file mode 100644 index 00000000..9c0c7cda --- /dev/null +++ b/.github/workflows/docs-deploy.yml @@ -0,0 +1,92 @@ +name: Docs deploy + +# RFC-0037 (#236): a push to main deploys cloudhal.dev (the `cloudhal-dev` Worker, apps/docs). +# It runs when something the site is built from changes: +# apps/docs/** the site, its Worker and its build scripts +# docs/** every page (the repository's docs are the site) +# packages/core-contracts/schemas/** /schemas/v1/* (scripts/postbuild.ts) +# packages/cli/src/cli/help.ts the generated CLI reference (src/lib/reference.ts) +# security/** /.well-known/cloudhal/keys.json and .sig (RFC-0046 §9) +# pnpm-lock.yaml Astro, Starlight and wrangler versions +# and by hand (Actions › Docs deploy › Run workflow). yanked.json needs no deploy: the Worker +# serves it live from the yank-list branch. +# +# - The `docs-deploy` environment (deployment branches: main only) holds CLOUDFLARE_API_TOKEN as a +# secret and CLOUDFLARE_ACCOUNT_ID as a variable. Until the secret exists the job skips with a +# notice, so this file can land before the owner adds it. +# - The token reaches the deploy step only, after the install; the job's GitHub token can only +# read. Actions are pinned by SHA. + +on: + push: + branches: [main] + paths: + - "apps/docs/**" + - "docs/**" + - "packages/core-contracts/schemas/**" + - "packages/cli/src/cli/help.ts" + - "security/**" + - "pnpm-lock.yaml" + - ".github/workflows/docs-deploy.yml" + workflow_dispatch: + +permissions: {} + +# One deploy at a time; a newer push waits rather than cancelling one that is uploading. +concurrency: + group: docs-deploy + cancel-in-progress: false + +jobs: + deploy: + name: Build and deploy cloudhal.dev + if: github.repository == 'cloudhal-dev/cloudhal' + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: + name: docs-deploy + url: https://cloudhal.dev + permissions: + contents: read + steps: + - name: Is the Cloudflare token configured + id: token + env: + HAS_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN != '' }} + ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -euo pipefail + if [ "$HAS_TOKEN" = "true" ] && [ -n "$ACCOUNT_ID" ]; then + echo "ready=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::docs-deploy has no CLOUDFLARE_API_TOKEN secret or CLOUDFLARE_ACCOUNT_ID variable yet: nothing deployed" + echo "Skipped: the docs-deploy environment has no Cloudflare token or account id yet." >> "$GITHUB_STEP_SUMMARY" + echo "ready=false" >> "$GITHUB_OUTPUT" + fi + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: steps.token.outputs.ready == 'true' + with: + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + if: steps.token.outputs.ready == 'true' + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + if: steps.token.outputs.ready == 'true' + with: + node-version: 24 + cache: pnpm + - name: Install + if: steps.token.outputs.ready == 'true' + run: pnpm install --frozen-lockfile + - name: Build and deploy + if: steps.token.outputs.ready == 'true' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + run: pnpm --filter @cloudhal/docs run deploy + - name: Summary + if: always() && steps.token.outputs.ready == 'true' + env: + OUTCOME: ${{ job.status }} + SHA: ${{ github.sha }} + run: | + echo "cloudhal.dev deploy of ${SHA:0:12} (${OUTCOME})" >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/rfcs/0037-cloudhal-dev-docs-site.md b/docs/rfcs/0037-cloudhal-dev-docs-site.md index 12b1d569..8566d17a 100644 --- a/docs/rfcs/0037-cloudhal-dev-docs-site.md +++ b/docs/rfcs/0037-cloudhal-dev-docs-site.md @@ -87,7 +87,7 @@ Who links where: ### 6. Build and deploy - CI builds `apps/docs` on every PR that touches `docs/**`, `apps/docs/**`, the schemas, `help.ts` or the MCP tool list, and runs the link checks (§3.5). - Every PR gets a preview: `wrangler versions upload` with a preview alias, posted as a PR comment. -- A push to `main` deploys `cloudhal-dev`. This needs a Cloudflare API token, limited to Workers Scripts edit on this one Worker, stored as a GitHub Actions secret. +- A push to `main` deploys `cloudhal-dev`. This needs a Cloudflare API token, limited to Workers Scripts edit on this one Worker, stored as a GitHub Actions secret. Since #236 this is `.github/workflows/docs-deploy.yml`, with the token in the `docs-deploy` environment. - `cloudhal.dev` sends no mail. DNS gets `v=spf1 -all`, DMARC `p=reject` and a null MX (ADR-0037). ### 7. Phases diff --git a/docs/rfcs/0046-release-bundles.md b/docs/rfcs/0046-release-bundles.md index 3139e353..dba299b5 100644 --- a/docs/rfcs/0046-release-bundles.md +++ b/docs/rfcs/0046-release-bundles.md @@ -203,7 +203,7 @@ Tar format: ustar with PAX `path` records for long names; regular files only (no - The sources are committed under `security/` in this repository. - `keys.json` changes yearly. cloudhal.dev (`apps/docs`) serves it as a static asset, copied at build time. - `yanked.json` is re-signed weekly. The *Yank list* workflow commits it to the append-only `yank-list` branch. The cloudhal.dev Worker proxies exactly that branch's raw file (a fixed URL, cached for at most five minutes, bytes unchanged), and answers 502 when the upstream fails, never with an old copy. - - No workflow deploys cloudhal.dev yet (RFC-0037's "push to `main` deploys `cloudhal-dev`" is a separate follow-up), so the route ships with one manual docs deploy. + - The route first shipped with one manual docs deploy. Since #236 the *Docs deploy* workflow deploys cloudhal.dev on every push to `main` that changes what it is built from, `security/**` included (RFC-0037). - The signatures make the location trust-free; the location only matters for availability. - **The root.** An Ed25519 root key signs only `keys.json`, at the yearly rotation or in an emergency. - The owner generates it offline with `node scripts/release-keys.ts root-init`. The script writes the private key encrypted with a passphrase, never prints it, and writes the public key next to it. diff --git a/docs/security/release-signing-keys.md b/docs/security/release-signing-keys.md index d0ddd554..5530762f 100644 --- a/docs/security/release-signing-keys.md +++ b/docs/security/release-signing-keys.md @@ -120,7 +120,7 @@ This runs once, offline where it says so. Nobody else generates or holds these k - The branch then only ever gains commits, and only from a job that holds the deploy key, which is a job in `release-yank` on `main`. - **What remains.** Anyone who can run a job in `release-yank` (a reviewed change to a workflow on `main`) or a repository admin (who can edit the ruleset) can push to it. A deploy key with write access can also push to other branches no ruleset protects. Such a push can only deny service: a crafted list fails the next run and the cloudhal.dev pair check. A replayed older, genuinely signed pair would be served to stateless consumers until it expires (at most 30 days); consumers with memory refuse it. A rollback needs a new commit that the branch history shows. 8. **First `yanked.json`.** Run the *Yank list* workflow once by hand with `first_run` checked (Actions › Yank list › Run workflow). It creates the `yank-list` branch with serial 1. After that it runs weekly, on Mondays. Before `security/keys.json` exists, the weekly run skips with a notice. -9. **Deploy cloudhal.dev once.** Run `pnpm --filter @cloudhal/docs run deploy`. This ships the Worker route that serves `/.well-known/cloudhal/yanked.json` from the branch, and the static `keys.json`. Nothing deploys cloudhal.dev automatically yet, so redeploy it after every change to `security/keys.json` as well. +9. **Deploy cloudhal.dev once.** Run `pnpm --filter @cloudhal/docs run deploy`. This ships the Worker route that serves `/.well-known/cloudhal/yanked.json` from the branch, and the static `keys.json`. From then on the *Docs deploy* workflow (`.github/workflows/docs-deploy.yml`, in the `docs-deploy` environment) redeploys it on every push to `main` that changes `security/**`, so a re-signed `keys.json` is online once it merges. Check its run after a rotation or an emergency re-sign. ## Rotation (yearly) diff --git a/packages/cli/test/docs-deploy-workflow.test.ts b/packages/cli/test/docs-deploy-workflow.test.ts new file mode 100644 index 00000000..c2a34195 --- /dev/null +++ b/packages/cli/test/docs-deploy-workflow.test.ts @@ -0,0 +1,79 @@ +/** + * .github/workflows/docs-deploy.yml (RFC-0037, #236): a push to main that touches what cloudhal.dev + * is built from deploys it, with the least it needs: pinned actions, a read-only token, the + * Cloudflare token in the `docs-deploy` environment and in the deploy step only (after the + * install), one deploy at a time, and a skip with a notice while the secret is missing. + */ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + code, + DOCS, + gha, + job, + OPEN, + ROOT, + runs, + step, +} from "./fixtures/release-bundle/workflow-text.ts"; + +describe("the Docs deploy workflow", () => { + const deploy = job(DOCS, "deploy"); + + it("runs on pushes to main that touch what the site is built from, and by hand", () => { + expect(code(DOCS)).toContain(" branches: [main]"); + for (const p of [ + "apps/docs/**", + "docs/**", + "packages/core-contracts/schemas/**", + "packages/cli/src/cli/help.ts", + "security/**", + "pnpm-lock.yaml", + ".github/workflows/docs-deploy.yml", + ]) + expect(code(DOCS)).toContain(` - "${p}"`); + expect(code(DOCS)).toContain(" workflow_dispatch:"); + }); + + it("follows what the docs build actually reads (so the paths cannot drift)", () => { + const postbuild = readFileSync(path.join(ROOT, "apps/docs/scripts/postbuild.ts"), "utf8"); + expect(postbuild).toContain('"packages/core-contracts/schemas"'); + expect(postbuild).toContain('"security"'); + const reference = readFileSync(path.join(ROOT, "apps/docs/src/lib/reference.ts"), "utf8"); + expect(reference).toContain("packages/cli/src/cli/help.ts"); + }); + + it("pins every action to a 40-hex commit, grants contents: read, and runs one deploy at a time", () => { + for (const l of DOCS.split("\n").filter((x) => /^\s*(- )?uses: /.test(x))) + expect(l.split("uses: ")[1]).toMatch(/^[\w.-]+\/[\w./-]+@[0-9a-f]{40} # v\d+(\.\d+)*$/); + expect(code(DOCS)).toContain("\npermissions: {}\n"); + expect(code(deploy)).toMatch(/permissions:\n\s+contents: read\n/); + expect(code(DOCS)).not.toMatch(/: write/); + expect(code(DOCS)).toMatch(/concurrency:\n\s+group: docs-deploy\n\s+cancel-in-progress: false/); + expect(code(deploy)).toContain("if: github.repository == 'cloudhal-dev/cloudhal'"); + expect(code(deploy)).toContain("name: docs-deploy"); + }); + + it("hands the Cloudflare token to the deploy step only, after the install", () => { + expect(code(DOCS).match(/secrets\.CLOUDFLARE_API_TOKEN \}\}/g)).toHaveLength(1); + const build = step(deploy, "Build and deploy"); + expect(build).toContain(`CLOUDFLARE_API_TOKEN: ${gha("secrets.CLOUDFLARE_API_TOKEN")}`); + expect(build).toContain(`CLOUDFLARE_ACCOUNT_ID: ${gha("vars.CLOUDFLARE_ACCOUNT_ID")}`); + expect(runs(build).join("\n")).toBe("pnpm --filter @cloudhal/docs run deploy"); + expect(code(deploy).indexOf("pnpm install --frozen-lockfile")).toBeLessThan( + code(deploy).indexOf("CLOUDFLARE_API_TOKEN: "), + ); + expect(step(deploy, "Install")).not.toContain("CLOUDFLARE"); + for (const script of runs(code(deploy))) expect(script).not.toContain(OPEN); + expect(code(deploy)).toContain("persist-credentials: false"); + }); + + it("skips with a notice while the secret or the account id is missing", () => { + const check = step(deploy, "Is the Cloudflare token configured"); + expect(check).toContain("secrets.CLOUDFLARE_API_TOKEN != ''"); + expect(check).toContain("::notice::"); + for (const name of ["Install", "Build and deploy"]) + expect(step(deploy, name)).toContain("if: steps.token.outputs.ready == 'true'"); + }); +}); diff --git a/packages/cli/test/fixtures/release-bundle/workflow-text.ts b/packages/cli/test/fixtures/release-bundle/workflow-text.ts index b9e0c406..08de524b 100644 --- a/packages/cli/test/fixtures/release-bundle/workflow-text.ts +++ b/packages/cli/test/fixtures/release-bundle/workflow-text.ts @@ -11,6 +11,7 @@ const read = (f: string) => readFileSync(path.join(ROOT, ".github/workflows", f) export const RELEASE = read("release.yml"); export const BUILD = read("bundle-build.yml"); export const YANK = read("yank-list.yml"); +export const DOCS = read("docs-deploy.yml"); /** A GitHub Actions expression `${{ inner }}` (written so, not as a JS template). */ export const gha = (inner: string) => ["$", "{{ ", inner, " }}"].join("");