Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions .github/workflows/docs-deploy.yml
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 1 addition & 1 deletion docs/rfcs/0037-cloudhal-dev-docs-site.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/rfcs/0046-release-bundles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/security/release-signing-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
79 changes: 79 additions & 0 deletions packages/cli/test/docs-deploy-workflow.test.ts
Original file line number Diff line number Diff line change
@@ -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'");
});
});
1 change: 1 addition & 0 deletions packages/cli/test/fixtures/release-bundle/workflow-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
Expand Down
Loading