Skip to content
Open
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
66 changes: 66 additions & 0 deletions .github/workflows/deploy-docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Deploy Docs
permissions:
contents: read

on:
push:
branches: [main]
# For redeploying the current main without an empty commit -- a rolled-back
# Cloudflare deployment, or a failed run that needs another go.
workflow_dispatch:

# One deploy at a time, and no cancelling. Cancelling would let an older run
# finish after a newer one and leave stale content live; queueing keeps the last
# push to main the last thing deployed.
concurrency:
group: deploy-docs
cancel-in-progress: false

jobs:
deploy:
if: ${{ github.repository_owner == 'cloudflare' }}
timeout-minutes: 10
runs-on: ubuntu-latest
environment: docs

steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # PageActions reads `git log %at` for each page's "Updated" date

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
cache: "npm"
# packages/docs is excluded from the workspaces and the React example's
# client is its own install, so both have lockfiles the root one does not
# cover. Missing one here means a cache key that does not change when its
# dependencies do.
cache-dependency-path: |
package-lock.json
packages/docs/package-lock.json
examples/worker-react/client/package-lock.json

# Three installs, because there are three lockfiles. The docs build bundles
# each example's real source, so the React example's client needs its own
# node_modules for esbuild to resolve react out of.
- name: Install dependencies
run: |
npm ci
npm ci --prefix packages/docs
npm ci --prefix examples/worker-react/client

# `build:docs` is the library build followed by the site build, in that order
# on purpose: the playgrounds vendor `dist/index.js` and the prose substitutes
# the measured bundle size, so the site is built against the library from this
# same commit rather than whatever was there last.
- name: Build docs
run: npm run build:docs

- name: Deploy to Cloudflare
run: npx wrangler deploy -c packages/docs/wrangler.jsonc
env:
# Account ID is committed in packages/docs/wrangler.jsonc; only the
# credential is a secret.
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
172 changes: 172 additions & 0 deletions .github/workflows/preview-docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
name: Preview Docs
permissions:
contents: read
pull-requests: write # to post the Preview URL back on the pull request

on:
pull_request:
types: [opened, synchronize, reopened, closed]

# One Preview per pull request, and the newest commit wins. Unlike the production
# deploy, cancelling here is what we want: an in-flight Preview of a commit that has
# already been superseded is not worth waiting for.
concurrency:
group: preview-docs-${{ github.event.pull_request.number }}
cancel-in-progress: true

env:
# `<preview-name>-<worker-name>.<subdomain>.workers.dev` is the Preview URL format,
# and all three parts are known before the build runs. That matters: the site bakes
# its origin into canonical URLs, OG image URLs, robots.txt and the sitemap, so the
# build has to be told where it is going to live *before* it happens. Deriving the
# URL instead of reading it back from `wrangler preview` is what makes that possible.
#
# The subdomain belongs to the account in `packages/docs/wrangler.jsonc`. It is fixed
# per account, not a secret, and there is no way to ask for it before deploying.
PREVIEW_NAME: ${{ github.event.pull_request.number }}
PREVIEW_URL: https://${{ github.event.pull_request.number }}.pr.capnweb.com

jobs:
preview:
# A pull request from a fork gets a read-only token and no access to secrets, so it
# cannot deploy -- and `pull_request_target`, which would hand it both, would be
# running a contributor's build scripts with a credential that can deploy the real
# site. Fork pull requests are checked by the `build-docs` job in test.yml instead.
if: ${{ github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository }}
timeout-minutes: 15
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # PageActions reads `git log %at` for each page's "Updated" date

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
cache: "npm"
cache-dependency-path: |
package-lock.json
packages/docs/package-lock.json
examples/worker-react/client/package-lock.json

- name: Install dependencies
run: |
npm ci
npm ci --prefix packages/docs
npm ci --prefix examples/worker-react/client

- name: Build docs
# Without this the Preview publishes canonical URLs, a sitemap and an
# /llms.txt all claiming to be https://capnweb.com.
env:
DOCS_SITE_URL: ${{ env.PREVIEW_URL }}
run: npm run build:docs

- name: Keep the Preview out of search results
# A Preview URL is public, and this repo is public, so the comment below puts a
# crawlable link to it on the open web. `noindex` is the header rather than a
# robots.txt `Disallow` on purpose: disallowing the crawl would stop the crawler
# ever reading the `noindex`, which is how staging sites end up indexed as bare
# URLs anyway.
#
# Appended rather than committed to `public/_headers`, because that file ships
# to production too. A request matching two rules inherits both, and no other
# rule sets `X-Robots-Tag`, so there is nothing here to collide with.
run: |
printf '\n/*\n X-Robots-Tag: noindex\n' >> packages/docs/dist/_headers

- name: Deploy Preview
id: deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
# `--json` does not mean "only JSON": wrangler prints its asset-upload progress
# to stdout first, so the payload has to be sliced out from the first brace.
# The URL is read back and compared with the one the build was given -- if the
# two ever disagree, the site has been built for the wrong origin and saying so
# is better than shipping a Preview full of wrong canonicals.
run: |
set -euo pipefail
npx wrangler preview -c packages/docs/wrangler.jsonc --name "$PREVIEW_NAME" --json | tee /tmp/preview.log
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json, os, sys
raw = open("/tmp/preview.log").read()
data = json.loads(raw[raw.index("{"):])
urls = data["preview"]["urls"]
if not urls:
sys.exit("Preview has no URL. Is `preview_urls` still true in wrangler.jsonc?")
expected = os.environ["PREVIEW_URL"]
if urls[0].rstrip("/") != expected.rstrip("/"):
sys.exit(f"Preview URL {urls[0]} is not the {expected} the site was built for.")
print(f"url={urls[0]}")
print(f"deployment={data['deployment']['urls'][0]}")
PY

- name: Comment the Preview URL
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
URL: ${{ steps.deploy.outputs.url }}
DEPLOYMENT: ${{ steps.deploy.outputs.deployment }}
SHA: ${{ github.event.pull_request.head.sha }}
# Edited in place rather than appended, so a pull request with thirty commits
# has one comment and not thirty. The marker is how the comment is found again.
run: |
set -euo pipefail
marker='<!-- capnweb-docs-preview -->'
body=$(printf '%s\n### Docs preview\n\n| | |\n| --- | --- |\n| **Preview** | %s |\n| **This commit** | %s |\n\nUpdates on every push. Deleted when this pull request closes. Not indexed by search engines.\n\n<sub>`%s`</sub>\n' \
"$marker" "$URL" "$DEPLOYMENT" "$SHA")
id=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --paginate \
--jq "[.[] | select(.user.type == \"Bot\" and (.body | startswith(\"$marker\")))] | first | .id // empty")
if [ -n "$id" ]; then
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${id}" -f body="$body" --silent
else
gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" -f body="$body" --silent
fi

cleanup:
# Previews are capped per Worker and Cloudflare evicts the least recently deployed
# one when the cap is hit, so this is hygiene rather than a hard requirement -- but
# without it a merged pull request leaves a live copy of its branch serving forever.
if: ${{ github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository }}
timeout-minutes: 10
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v7

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
cache: "npm"
cache-dependency-path: package-lock.json

# Only the root install: deleting a Preview needs wrangler and nothing else. In
# particular it does not need `dist/`, so this job never builds.
- name: Install wrangler
run: npm ci

- name: Delete the Preview
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
# `--name` is a flag here, not a positional -- the positional is the entry point
# script. `wrangler preview delete pr-1` would try to deploy a Worker called
# `pr-1` rather than delete the Preview.
#
# Deleting a Preview that is not there exits 1 (`code: 10025`), which would put
# a failed check on every pull request that closed without one: anything opened
# before this workflow existed, anything from a fork, and anything whose preview
# job never got as far as deploying. That case is success here. Any other
# failure is still a failure.
run: |
set -uo pipefail
out=$(npx wrangler preview delete -c packages/docs/wrangler.jsonc --name "$PREVIEW_NAME" --skip-confirmation 2>&1) && status=0 || status=$?
printf '%s\n' "$out"
if [ "$status" -ne 0 ]; then
case "$out" in
*"code: 10025"*) echo "No Preview named $PREVIEW_NAME. Nothing to delete." ;;
*) exit "$status" ;;
esac
fi
43 changes: 43 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,49 @@ jobs:
- name: Lint Markdown
run: npm run lint:md

# The docs site is deployed from main by deploy-docs.yml, so without this the
# first place its build ever runs is the deploy. Fail it on the pull request
# instead.
build-docs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v7
with:
# Same full history the deploy uses. Each page's "Updated" date comes from
# `git log -1 --format=%at <file>`, and in a shallow clone that resolves to
# the one commit that was fetched -- so every page would claim to have been
# updated at the same moment, and this job would be checking a build that is
# not the one main deploys.
fetch-depth: 0

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
cache: "npm"
# packages/docs is excluded from the workspaces and the React example's
# client is its own install, so both have lockfiles the root one does not
# cover.
cache-dependency-path: |
package-lock.json
packages/docs/package-lock.json
examples/worker-react/client/package-lock.json

# The docs build bundles each example's real source, so the React example's
# client needs its own node_modules for esbuild to resolve react out of.
- name: Install dependencies
run: |
npm ci
npm ci --prefix packages/docs
npm ci --prefix examples/worker-react/client

- name: Build docs
run: npm run build:docs

- name: Check docs
run: npm --prefix packages/docs run check

test:
runs-on: ubuntu-latest
container:
Expand Down
Loading
Loading