Skip to content

Commit 10a4d3a

Browse files
lwwmanningclaude
andauthored
ci: deploy docs to Vercel alongside Cloudflare Pages (#10912)
This PR deploys the docs to the new `vortex-docs` Vercel project. The deploy uses the `deploy-vercel` workflow from #10911, so merge that PR first. The Cloudflare Pages deploy continues, so a DNS change can roll back the move. The Sphinx links are relative. Thus the docs need the Pages URL rules: `page.html` is at `/page` and `dir/index.html` is at `/dir/`. The new `pages-html-routes.py` script makes the same routes from each build. `docs/vercel-output-config.json` copies the headers from `docs/_headers`. I compared 598 URL forms against docs.vortex.dev. 597 are the same. An unknown path now returns 404, not the home page. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Will Manning <will@willmanning.io> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 3953e98 commit 10a4d3a

5 files changed

Lines changed: 151 additions & 2 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
"""Add Cloudflare Pages' HTML URL handling to a Build Output API config.
5+
6+
Pages serves `page.html` at `/page` and `dir/index.html` at `/dir/`, and
7+
redirects every other spelling of those URLs to them. Sphinx output relies
8+
on this: its links are relative, so a directory page must be served with
9+
its trailing slash, and links shared since the docs moved to Pages use the
10+
URLs without `.html`.
11+
12+
Usage: pages-html-routes.py OUTPUT_DIR
13+
14+
OUTPUT_DIR is a `.vercel/output` directory holding `static/` and a
15+
`config.json` whose routes only set headers. The routes this script adds
16+
follow those header routes.
17+
"""
18+
19+
import json
20+
import re
21+
import sys
22+
from pathlib import Path
23+
24+
25+
def redirect(src: str, location: str, status: int = 308) -> dict:
26+
return {"src": src, "status": status, "headers": {"Location": location}}
27+
28+
29+
def html_routes(static: Path) -> list[dict]:
30+
# Vercel serves `dir/index.html` at `/dir` as well as `/dir/`, and only a
31+
# route per directory can tell a directory from a page.
32+
directories = sorted(
33+
index.parent.relative_to(static).as_posix() for index in static.rglob("index.html") if index.parent != static
34+
)
35+
# Whether `/x` or `/x/` is canonical depends on whether this build has
36+
# `x.html` or `x/index.html`. Those redirects are temporary (307), so a
37+
# browser that cached one cannot loop after the docs are reorganized.
38+
return [
39+
*(redirect(f"^/{re.escape(d)}$", f"/{d}/", 307) for d in directories),
40+
redirect(r"^/index(?:\.html)?$", "/"),
41+
redirect(r"^/(.+)/index(?:\.html)?$", "/$1/"),
42+
redirect(r"^/(.+)\.html$", "/$1"),
43+
{"handle": "filesystem"},
44+
# Only reached when no file or directory matched.
45+
redirect(r"^/(.+)/$", "/$1", 307),
46+
{"src": r"^/(.+)$", "dest": "/$1.html", "check": True},
47+
]
48+
49+
50+
def main(output: Path) -> None:
51+
config_path = output / "config.json"
52+
config = json.loads(config_path.read_text())
53+
routes = config.get("routes", [])
54+
if any("handle" in route for route in routes):
55+
sys.exit(f"{config_path}: routes must only set headers when HTML handling is added")
56+
config["routes"] = routes + html_routes(output / "static")
57+
config_path.write_text(json.dumps(config, indent=2) + "\n")
58+
59+
60+
if __name__ == "__main__":
61+
main(Path(sys.argv[1]))

‎.github/workflows/deploy-vercel.yml‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ on:
3232
description: Repository path to the Build Output API config.json.
3333
type: string
3434
required: true
35+
pages-html-handling:
36+
description: Serve HTML at the URLs Cloudflare Pages uses (see .github/vercel-cli/pages-html-routes.py).
37+
type: boolean
38+
default: false
3539
production:
3640
description: Deploy to production instead of a preview.
3741
type: boolean
@@ -48,6 +52,11 @@ jobs:
4852
name: Deploy
4953
runs-on: ubuntu-latest
5054
timeout-minutes: 10
55+
# One production deploy per project at a time, so the check of the live
56+
# production commit in the Deploy step cannot race. Previews never wait.
57+
concurrency:
58+
group: deploy-vercel-${{ inputs.environment }}-${{ inputs.production && 'production' || github.run_id }}
59+
cancel-in-progress: false
5160
permissions:
5261
contents: read
5362
deployments: write
@@ -95,6 +104,9 @@ jobs:
95104
env:
96105
CONFIG: ${{ inputs.config }}
97106
run: cp "$CONFIG" "$RUNNER_TEMP/site/.vercel/output/config.json"
107+
- name: Add Pages HTML routes
108+
if: inputs.pages-html-handling
109+
run: python3 -I .github/vercel-cli/pages-html-routes.py "$RUNNER_TEMP/site/.vercel/output"
98110
- name: Deploy
99111
id: deploy
100112
working-directory: ${{ runner.temp }}/site
@@ -105,14 +117,28 @@ jobs:
105117
VERCEL_CLI: ${{ github.workspace }}/.github/vercel-cli/node_modules/.bin/vercel
106118
PRODUCTION: ${{ inputs.production }}
107119
ENVIRONMENT: ${{ inputs.environment }}
120+
GH_TOKEN: ${{ github.token }}
108121
run: |
109122
set -euo pipefail
110123
if [ -z "$VERCEL_TOKEN" ]; then
111124
echo "::error::VERCEL_TOKEN is empty. Store it in the $ENVIRONMENT environment and call this workflow with 'secrets: inherit'."
112125
exit 1
113126
fi
114-
args=(deploy --prebuilt --non-interactive --format json)
127+
# The commit tag lets a later production deploy see what is live.
128+
args=(deploy --prebuilt --non-interactive --format json --meta "githubCommitSha=$GITHUB_SHA")
115129
if [ "$PRODUCTION" = "true" ]; then
130+
# Runs for overlapping pushes do not cancel each other, so an older
131+
# build can finish last. Do not replace a newer production deploy.
132+
# If production cannot be read, deploy anyway.
133+
live=$(curl -fsS -H "Authorization: Bearer $VERCEL_TOKEN" \
134+
"https://api.vercel.com/v6/deployments?projectId=$VERCEL_PROJECT_ID&teamId=$VERCEL_ORG_ID&target=production&state=READY&limit=1" \
135+
| jq -r '.deployments[0].meta.githubCommitSha // empty') ||
136+
{ echo "::warning::Could not read the production deployment."; live=""; }
137+
if [ -n "$live" ] && [ "$live" != "$GITHUB_SHA" ] &&
138+
[ "$(gh api "repos/$GITHUB_REPOSITORY/compare/$live...$GITHUB_SHA" --jq .status)" = "behind" ]; then
139+
echo "::notice::Skipped: production serves $live, which is newer than $GITHUB_SHA."
140+
exit 0
141+
fi
116142
args+=(--prod)
117143
fi
118144
url=$("$VERCEL_CLI" "${args[@]}" | jq -er '.deployment.url')

‎.github/workflows/docs-preview.yml‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,11 @@ jobs:
5151
'.github/workflows/docs.yml',
5252
'.github/workflows/docs-build.yml',
5353
'.github/workflows/docs-preview.yml',
54+
'.github/workflows/deploy-vercel.yml',
5455
]);
5556
eligible = files.some(file => [file.filename, file.previous_filename].some(
56-
path => path && (path.startsWith('docs/') || workflows.has(path)),
57+
path => path && (path.startsWith('docs/') ||
58+
path.startsWith('.github/vercel-cli/') || workflows.has(path)),
5759
));
5860
}
5961
core.setOutput('eligible', eligible);
@@ -140,3 +142,22 @@ jobs:
140142
.addRaw(`\n\nCommit: \`${pr.head.sha}\`\n\n`)
141143
.addLink('Immutable deployment', process.env.DEPLOYMENT_URL)
142144
.write();
145+
146+
# Publishes the same preview to Vercel while docs move off Pages. Each
147+
# Vercel deployment has its own URL, so a stale build cannot replace a
148+
# newer preview, and this job skips the recheck that the Pages job needs.
149+
publish-vercel:
150+
name: Publish preview to Vercel
151+
needs: build
152+
permissions:
153+
contents: read
154+
deployments: write
155+
uses: ./.github/workflows/deploy-vercel.yml
156+
# A called workflow only sees the secrets its caller passes, including
157+
# the secrets of the environment the called job deploys to.
158+
secrets: inherit
159+
with:
160+
environment: vercel-docs
161+
artifact-ids: ${{ needs.build.outputs.artifact-id }}
162+
config: docs/vercel-output-config.json
163+
pages-html-handling: true

‎.github/workflows/docs.yml‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,22 @@ jobs:
4545
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
4646
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
4747
command: pages deploy docs/ --project-name=vortex-docs --branch=${{ github.head_ref || github.ref_name }}
48+
49+
# Deploys the same build to Vercel. Both hosts run until docs.vortex.dev
50+
# has served from Vercel for two weeks, then the Pages job above goes.
51+
deploy-vercel:
52+
name: Deploy to Vercel
53+
needs: build
54+
permissions:
55+
contents: read
56+
deployments: write
57+
uses: ./.github/workflows/deploy-vercel.yml
58+
# A called workflow only sees the secrets its caller passes, including
59+
# the secrets of the environment the called job deploys to.
60+
secrets: inherit
61+
with:
62+
environment: vercel-docs
63+
artifact-ids: ${{ needs.build.outputs.artifact-id }}
64+
config: docs/vercel-output-config.json
65+
pages-html-handling: true
66+
production: ${{ github.ref == 'refs/heads/develop' }}

‎docs/vercel-output-config.json‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"version": 3,
3+
"routes": [
4+
{
5+
"src": "^/(.*)$",
6+
"headers": {
7+
"Content-Security-Policy": "default-src 'none'; script-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
8+
"X-Content-Type-Options": "nosniff",
9+
"Referrer-Policy": "strict-origin-when-cross-origin",
10+
"Cross-Origin-Opener-Policy": "same-origin"
11+
},
12+
"continue": true
13+
},
14+
{
15+
"src": "^/_static/vortex-(jni|spark)/(.*)$",
16+
"headers": {
17+
"Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
18+
},
19+
"continue": true
20+
}
21+
]
22+
}

0 commit comments

Comments
 (0)