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
214 changes: 214 additions & 0 deletions .github/workflows/yank-list.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
name: Yank list

# RFC-0046 §9 (#226): re-signs yanked.json with the yank key, weekly and on demand, and publishes
# it to the append-only `yank-list` branch, which cloudhal.dev serves at
# /.well-known/cloudhal/yanked.json (apps/docs/src/worker/trust.ts). The list expires 30 days
# after it is signed, so the weekly run keeps it fresh; a stale list blocks only new installs.
#
# - The list signed is every entry of the published list, carried forward only after it verifies
# as a consumer would (its signature by the active yank key of the pinned-root keys.json), plus
# security/yank-list.json (main), plus the emergency entries from the inputs below, with a serial
# one above the published one (scripts/release-keys.ts sign-yanked). A published list that does
# not verify fails the run loudly; it is never re-signed.
# - `check` skips everything, with a notice, until the root ceremony has committed
# security/keys.json, and outside cloudhal-dev/cloudhal.
# - The `release-yank` environment has no required reviewer (the key only reduces trust); its
# branch policy allows `main` only. The job installs exactly @cloudhal/release-verify's pinned
# dependencies, without lifecycle scripts, so sign-yanked can verify the exact bytes it signs as
# a consumer would. The key reaches the Sign step only.
# - The `yank-list` branch is append-only: a ruleset blocks force-pushes and deletion, and its
# only bypass for updates is a deploy key held as YANK_LIST_DEPLOY_KEY by `release-yank`, so no
# other workflow's token can push to it (docs/security/release-signing-keys.md, First-time
# setup). That key reaches the Publish step only; the job's token can only read.
# - Emergency: Actions › Yank list › Run workflow with `yank` (versions) and/or `revoke` (release
# key ids), comma-separated, and a `reason`. Wait for a running dispatch to finish before
# starting another: GitHub keeps only one pending run per concurrency group and cancels older
# pending ones. Then record the entries in security/yank-list.json in a reviewed PR. A failed or
# cancelled run opens (or comments on) an issue titled "Yank list run failed".

on:
schedule:
- cron: "17 4 * * 1" # Mondays 04:17 UTC
workflow_dispatch:
inputs:
yank:
description: Emergency yank of these versions (X.Y.Z or X.Y.Z-pre, comma-separated)
type: string
required: false
revoke:
description: Emergency revocation of these release key ids (comma-separated)
type: string
required: false
reason:
description: Why (required with yank or revoke; public)
type: string
required: false
first_run:
description: The very first list (no yank-list branch yet)
type: boolean
required: false
default: false
rotate:
description: Prune - start the yank key's list from security/yank-list.json alone (serial 1)
type: boolean
required: false
default: false
accept_expired:
description: Carry forward a published list that expired during an outage (still verified)
type: boolean
required: false
default: false

permissions: {}

# One run at a time. A pending run is replaced by a newer one (GitHub's rule), so dispatches wait
# for the previous run; several entries go in one dispatch.
concurrency:
group: yank-list
cancel-in-progress: false

jobs:
check:
name: Ready to sign
if: github.repository == 'cloudhal-dev/cloudhal'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
ready: ${{ steps.keys.outputs.ready }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Is there a signed key list yet
id: keys
run: |
set -euo pipefail
if [ -f security/keys.json ] && [ -f security/keys.json.sig ]; then
echo "ready=true" >> "$GITHUB_OUTPUT"
else
echo "::notice::no security/keys.json yet (before the root ceremony): nothing to sign"
echo "Skipped: no security/keys.json yet (before the root ceremony)." >> "$GITHUB_STEP_SUMMARY"
echo "ready=false" >> "$GITHUB_OUTPUT"
fi

sign:
name: Sign and publish yanked.json
needs: check
if: needs.check.outputs.ready == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
environment: release-yank
permissions:
contents: read # the push uses the environment's deploy key, the ruleset's only bypass
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
- name: Install the verifier only (no lifecycle scripts)
run: pnpm install --frozen-lockfile --ignore-scripts --filter @cloudhal/release-verify
- name: Read the published list
env:
FIRST_RUN: ${{ inputs.first_run && 'true' || 'false' }}
run: |
set -euo pipefail
if git ls-remote --exit-code --heads "https://github.com/${GITHUB_REPOSITORY}.git" yank-list >/dev/null; then
git clone --quiet --branch yank-list --single-branch "https://github.com/${GITHUB_REPOSITORY}.git" "$RUNNER_TEMP/yank-list"
elif [ "$FIRST_RUN" = "true" ]; then
git init --quiet -b yank-list "$RUNNER_TEMP/yank-list"
git -C "$RUNNER_TEMP/yank-list" remote add origin "https://github.com/${GITHUB_REPOSITORY}.git"
else
echo "::error::there is no yank-list branch: run once with first_run (runbook, First-time setup)"
exit 1
fi
- name: Sign
id: sign
env:
RELEASE_YANK_KEY: ${{ secrets.RELEASE_YANK_KEY }}
FIRST_RUN: ${{ inputs.first_run && 'true' || 'false' }}
ROTATE: ${{ inputs.rotate && 'true' || 'false' }}
ACCEPT_EXPIRED: ${{ inputs.accept_expired && 'true' || 'false' }}
YANK: ${{ inputs.yank }}
REVOKE: ${{ inputs.revoke }}
REASON: ${{ inputs.reason }}
run: |
set -euo pipefail
args=(sign-yanked --key-id cloudhal-yank-2026a --published "$RUNNER_TEMP/yank-list/yanked.json" --out "$RUNNER_TEMP/signed")
if [ "$FIRST_RUN" = "true" ]; then args+=(--first-run); fi
if [ "$ROTATE" = "true" ]; then args+=(--rotate); fi
if [ "$ACCEPT_EXPIRED" = "true" ]; then args+=(--accept-expired); fi
# --name=value, so a value that starts with "-" is never read as a flag.
if [ -n "$YANK" ]; then args+=("--yank=$YANK"); fi
if [ -n "$REVOKE" ]; then args+=("--revoke=$REVOKE"); fi
if [ -n "$REASON" ]; then args+=("--reason=$REASON"); fi
node scripts/release-keys.ts "${args[@]}"
- name: Publish to the yank-list branch
id: publish
env:
YANK_LIST_DEPLOY_KEY: ${{ secrets.YANK_LIST_DEPLOY_KEY }}
# github.com's published Ed25519 host key, pinned.
GITHUB_HOST_KEY: "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl"
run: |
set -euo pipefail
umask 077
printf '%s\n' "$YANK_LIST_DEPLOY_KEY" > "$RUNNER_TEMP/deploy-key"
printf '%s\n' "$GITHUB_HOST_KEY" > "$RUNNER_TEMP/known_hosts"
export GIT_SSH_COMMAND="ssh -i $RUNNER_TEMP/deploy-key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$RUNNER_TEMP/known_hosts"
trap 'rm -f "$RUNNER_TEMP/deploy-key"' EXIT
cd "$RUNNER_TEMP/yank-list"
git remote set-url origin "[email protected]:${GITHUB_REPOSITORY}.git"
cp "$RUNNER_TEMP/signed/yanked.json" "$RUNNER_TEMP/signed/yanked.json.sig" .
serial="$(node -e 'process.stdout.write(String(JSON.parse(require("fs").readFileSync("yanked.json","utf8")).serial))')"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add yanked.json yanked.json.sig
git commit --quiet -m "chore(yank-list): serial ${serial}"
# Never --force: the branch only ever gains commits.
git push --quiet origin yank-list
echo "published yanked.json serial ${serial}"
echo "Published yanked.json serial ${serial}." >> "$GITHUB_STEP_SUMMARY"
- name: Summary
if: always()
env:
SIGN: ${{ steps.sign.outcome }}
PUBLISH: ${{ steps.publish.outcome }}
YANK: ${{ inputs.yank }}
REVOKE: ${{ inputs.revoke }}
run: |
{
echo "| Step | Outcome |"
echo "| --- | --- |"
echo "| Sign | ${SIGN:-not run} |"
echo "| Publish | ${PUBLISH:-not run} |"
if [ -n "$YANK$REVOKE" ]; then echo; echo "Emergency entries requested: yank \`${YANK:-none}\`, revoke \`${REVOKE:-none}\`."; fi
} >> "$GITHUB_STEP_SUMMARY"

notify:
name: Report a failed or cancelled run
needs: [check, sign]
if: always() && github.repository == 'cloudhal-dev/cloudhal' && (needs.check.result == 'failure' || needs.sign.result == 'failure' || needs.sign.result == 'cancelled')
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
issues: write # one issue per streak of failures; nothing else
steps:
- name: Open or comment on the issue
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
title="Yank list run failed"
body="The Yank list workflow failed or was cancelled: ${run_url}. A cancelled dispatch may have carried an emergency yank or revocation: check its inputs and run it again (docs/security/release-signing-keys.md)."
existing="$(gh issue list --state open --search "in:title \"${title}\"" --json number --jq '.[0].number // empty')"
if [ -n "$existing" ]; then
gh issue comment "$existing" --body "$body"
else
gh issue create --title "$title" --body "$body"
fi
21 changes: 19 additions & 2 deletions apps/docs/scripts/postbuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@
* fails when a schema's `$id` is not its URL here;
* - every page as Markdown at `<path>.md` and `<path>/index.md`, links absolute;
* - `/llms.txt`, `/llms-full.txt` and one pair per section (llmstxt.org);
* - the docs MCP's search index (`/_mcp/search-index.json`, read by `src/worker`).
* - the docs MCP's search index (`/_mcp/search-index.json`, read by `src/worker`);
* - the root-signed key list at `/.well-known/cloudhal/keys.json` and `.sig`, from the repo's
* `security/` once the root ceremony has made them (RFC-0046 §9; `yanked.json` is served live by
* `src/worker/trust.ts`).
*/
import { existsSync } from "node:fs";
import { cp, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -114,12 +118,25 @@ async function writeLlms(pages: readonly Page[], schemas: readonly SchemaEntry[]
}
}

/** `security/keys.json` and `.sig`, when both exist, at `/.well-known/cloudhal/`. */
async function copyKeyList(): Promise<boolean> {
const files = ["keys.json", "keys.json.sig"].map((f) => path.join(REPO, "security", f));
if (!files.every((f) => existsSync(f))) return false;
const out = path.join(DIST, ".well-known/cloudhal");
await mkdir(out, { recursive: true });
for (const f of files) await cp(f, path.join(out, path.basename(f)));
return true;
}

await cp(path.join(APP, "static"), DIST, { recursive: true });
const keyList = await copyKeyList();
await writeFile(path.join(DIST, "_redirects"), `${redirectLines().join("\n")}\n`);
const schemas = await copySchemas();
const pages = allPages(REPO, true);
await writeMarkdown(pages);
await writeLlms(pages, schemas);
await mkdir(path.dirname(path.join(DIST, SEARCH_INDEX_PATH)), { recursive: true });
await writeFile(path.join(DIST, SEARCH_INDEX_PATH), JSON.stringify(buildSearchIndex(pages)));
console.log(`cloudhal.dev: ${pages.length} pages, ${schemas.length} schemas`);
console.log(
`cloudhal.dev: ${pages.length} pages, ${schemas.length} schemas${keyList ? ", keys.json" : ""}`,
);
26 changes: 26 additions & 0 deletions apps/docs/src/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,20 @@
* - `/mcp`: the read-only docs MCP (`mcp.ts`), rate limited per IP;
* - `Accept: text/markdown` on a page answers its Markdown copy, with `Vary: Accept` and an
* `x-markdown-tokens` estimate (RFC-0037 §4);
* - `/.well-known/cloudhal/yanked.json` and `.sig`: the signed yank list, from the `yank-list`
* branch, served only as a pair that verifies (`trust.ts`, RFC-0046 §9);
* - everything else goes to the assets binding unchanged.
*/
import { SEARCH_INDEX_PATH, type SearchIndex } from "../lib/search.ts";
import { CORS, handleMcp } from "./mcp.ts";
import { isPagePath, markdownPath, pageId } from "./paths.ts";
import {
cachePairStore,
KEY_LIST_PATH,
type PairCache,
YANK_LIST_PATHS,
yankList,
} from "./trust.ts";

/** The parts of the Workers bindings this script uses. */
export interface Env {
Expand All @@ -35,6 +44,18 @@ async function markdownAsset(env: Env, origin: string, path: string): Promise<st
return res.status === 200 ? res.text() : undefined;
}

/** The colo's cache (`caches.default`) on workerd; absent under Node (tests). */
const colo = (globalThis as { caches?: { default?: PairCache } }).caches?.default;

async function staticBytes(
env: Env,
origin: string,
path: string,
): Promise<Uint8Array<ArrayBuffer> | undefined> {
const res = await env.ASSETS.fetch(new Request(new URL(path, origin)));
return res.status === 200 ? new Uint8Array(await res.arrayBuffer()) : undefined;
}

/** True when the client asks for Markdown (`text/markdown` in `Accept`, not with `q=0`). */
export function wantsMarkdown(accept: string | null): boolean {
return (accept ?? "").split(",").some((part) => {
Expand Down Expand Up @@ -89,6 +110,11 @@ export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/mcp" || url.pathname === "/mcp/") return mcp(request, env, url);
if (Object.hasOwn(YANK_LIST_PATHS, url.pathname))
return yankList(request, url.pathname, {
keyList: () => staticBytes(env, url.origin, KEY_LIST_PATH),
...(colo ? { store: cachePairStore(colo) } : {}),
});
const page =
(request.method === "GET" || request.method === "HEAD") && isPagePath(url.pathname);
return page ? negotiate(request, env, url) : env.ASSETS.fetch(request);
Expand Down
Loading
Loading