Skip to content

Commit b2eb1b1

Browse files
committed
chore: add wrangler dep, deploy script, example workflow, and PRD
1 parent 64c8127 commit b2eb1b1

4 files changed

Lines changed: 576 additions & 1 deletion

File tree

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
#!/usr/bin/env bash
2+
#
3+
# cloudflare-deploy.sh — post-deploy GitHub integration for Cloudflare Workers/Pages
4+
#
5+
# Usage:
6+
# cloudflare-deploy.sh comment Post or update a PR comment with the preview URL
7+
# cloudflare-deploy.sh deployment Create a GitHub Deployment and job summary
8+
#
9+
# Required environment:
10+
# WRANGLER_OUTPUT_FILE_DIRECTORY Directory where wrangler wrote its output artifacts
11+
# GH_TOKEN GitHub token (usually secrets.GITHUB_TOKEN)
12+
# GITHUB_REPOSITORY owner/repo (set automatically by Actions)
13+
#
14+
# For 'comment':
15+
# PR_NUMBER Pull request number
16+
#
17+
# For 'deployment':
18+
# GITHUB_HEAD_REF / GITHUB_REF_NAME Branch ref (set automatically by Actions)
19+
# CLOUDFLARE_ACCOUNT_ID Cloudflare account ID (for dashboard link)
20+
21+
set -euo pipefail
22+
23+
die() {
24+
echo "error: $*" >&2
25+
exit 1
26+
}
27+
28+
# Read the first wrangler output entry matching one of the supported types.
29+
#
30+
# Wrangler writes newline-delimited JSON files named
31+
# wrangler-output-<timestamp>-<hex>.json into WRANGLER_OUTPUT_FILE_DIRECTORY.
32+
# We read all files once and search in priority order:
33+
# pages-deploy-detailed > deploy > version-upload
34+
read_deploy_output() {
35+
local dir="${WRANGLER_OUTPUT_FILE_DIRECTORY:?WRANGLER_OUTPUT_FILE_DIRECTORY must be set}"
36+
37+
# Gather all matching files. Use nullglob-safe find to avoid errors on
38+
# empty directories.
39+
local files
40+
files=$(find "$dir" -maxdepth 1 -name 'wrangler-output-*.json' 2>/dev/null | sort)
41+
42+
if [[ -z "${files}" ]]; then
43+
die "no wrangler output files found in ${dir}"
44+
fi
45+
46+
# Slurp all lines from all output files into a single stream, then filter.
47+
# This avoids re-reading the directory for each entry type.
48+
local -a file_list
49+
mapfile -t file_list <<< "${files}"
50+
51+
local all_entries
52+
all_entries=$(cat "${file_list[@]}" 2>/dev/null)
53+
54+
local entry_type
55+
local match
56+
for entry_type in "pages-deploy-detailed" "deploy" "version-upload"; do
57+
match=$(jq -c "select(.type == \"${entry_type}\")" <<< "${all_entries}" 2>/dev/null | head -n1)
58+
if [[ -n "${match}" ]]; then
59+
echo "${match}"
60+
return
61+
fi
62+
done
63+
64+
die "no deployment output entry found in wrangler artifacts"
65+
}
66+
67+
# Extract the deployment URL from whichever entry type we found.
68+
extract_url() {
69+
local entry="$1"
70+
local entry_type
71+
entry_type=$(jq -r '.type' <<< "${entry}")
72+
73+
case "${entry_type}" in
74+
pages-deploy-detailed)
75+
jq -r '.url // empty' <<< "${entry}"
76+
;;
77+
deploy)
78+
jq -r '.targets[0] // empty' <<< "${entry}"
79+
;;
80+
version-upload)
81+
jq -r '.preview_url // empty' <<< "${entry}"
82+
;;
83+
*)
84+
die "unknown entry type: ${entry_type}"
85+
;;
86+
esac
87+
}
88+
89+
# Post or update a PR comment with the preview URL.
90+
cmd_comment() {
91+
local pr="${PR_NUMBER:?PR_NUMBER must be set}"
92+
93+
local entry
94+
entry=$(read_deploy_output)
95+
96+
local url
97+
url=$(extract_url "${entry}")
98+
[[ -z "${url}" ]] && die "could not extract deployment URL from wrangler output"
99+
100+
local body
101+
body="**Cloudflare Preview**"$'\n\n'"🔗 ${url}"
102+
103+
# Include alias URL for Pages deployments.
104+
local alias_url
105+
alias_url=$(jq -r '.alias // empty' <<< "${entry}" 2>/dev/null)
106+
if [[ -n "${alias_url}" ]]; then
107+
body+=$'\n'"🔀 ${alias_url} (branch alias)"
108+
fi
109+
110+
# Look for an existing comment to update (avoids spamming on repeated pushes).
111+
local existing_comment
112+
existing_comment=$(
113+
gh api "repos/${GITHUB_REPOSITORY}/issues/${pr}/comments" \
114+
--jq '.[] | select(.body | startswith("**Cloudflare Preview**")) | .id' \
115+
2>/dev/null | head -n1
116+
) || true
117+
118+
if [[ -n "${existing_comment}" ]]; then
119+
gh api "repos/${GITHUB_REPOSITORY}/issues/comments/${existing_comment}" \
120+
-X PATCH -f body="${body}" --silent
121+
echo "Updated existing comment ${existing_comment}"
122+
else
123+
gh api "repos/${GITHUB_REPOSITORY}/issues/${pr}/comments" \
124+
-f body="${body}" --silent
125+
echo "Posted new comment on PR #${pr}"
126+
fi
127+
}
128+
129+
# Create a GitHub Deployment + status and write a job summary.
130+
cmd_deployment() {
131+
local entry
132+
entry=$(read_deploy_output)
133+
134+
local url
135+
url=$(extract_url "${entry}")
136+
[[ -z "${url}" ]] && die "could not extract deployment URL from wrangler output"
137+
138+
local entry_type
139+
entry_type=$(jq -r '.type' <<< "${entry}")
140+
141+
local ref="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:?}}"
142+
local environment="preview"
143+
local log_url=""
144+
145+
# Pages deployments have richer metadata.
146+
if [[ "${entry_type}" == "pages-deploy-detailed" ]]; then
147+
environment=$(jq -r '.environment // "preview"' <<< "${entry}")
148+
149+
local project_name
150+
project_name=$(jq -r '.pages_project // empty' <<< "${entry}")
151+
152+
local cf_deployment_id
153+
cf_deployment_id=$(jq -r '.deployment_id // empty' <<< "${entry}")
154+
155+
local account_id="${CLOUDFLARE_ACCOUNT_ID:-}"
156+
157+
if [[ -n "${account_id}" && -n "${project_name}" && -n "${cf_deployment_id}" ]]; then
158+
log_url="https://dash.cloudflare.com/${account_id}/pages/view/${project_name}/${cf_deployment_id}"
159+
fi
160+
fi
161+
162+
# Create the deployment.
163+
# Passing an empty JSON array for required_contexts disables commit status
164+
# checks on the deployment object. The gh cli -f flag cannot represent an
165+
# empty array, so we pipe raw JSON via --input.
166+
local gh_deployment_id
167+
gh_deployment_id=$(
168+
jq -n \
169+
--arg ref "${ref}" \
170+
--arg env "${environment}" \
171+
--arg desc "Cloudflare Deploy" \
172+
'{
173+
ref: $ref,
174+
environment: $env,
175+
auto_merge: false,
176+
description: $desc,
177+
required_contexts: []
178+
}' \
179+
| gh api "repos/${GITHUB_REPOSITORY}/deployments" \
180+
--method POST --input - --jq '.id'
181+
)
182+
183+
if [[ -z "${gh_deployment_id}" ]]; then
184+
die "failed to create GitHub deployment"
185+
fi
186+
187+
# Set deployment status to success.
188+
local status_body
189+
status_body=$(
190+
jq -n \
191+
--arg env "${environment}" \
192+
--arg url "${url}" \
193+
--arg desc "Cloudflare Deploy" \
194+
--arg log_url "${log_url}" \
195+
'{
196+
state: "success",
197+
environment: $env,
198+
environment_url: $url,
199+
description: $desc,
200+
auto_inactive: false
201+
}
202+
| if $log_url != "" then . + {log_url: $log_url} else . end'
203+
)
204+
205+
gh api "repos/${GITHUB_REPOSITORY}/deployments/${gh_deployment_id}/statuses" \
206+
--method POST --input - --silent <<< "${status_body}"
207+
208+
echo "Created GitHub deployment ${gh_deployment_id}${url}"
209+
210+
# Write job summary if the variable is available.
211+
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
212+
{
213+
echo "### Cloudflare Deploy"
214+
echo ""
215+
echo "| | |"
216+
echo "|---|---|"
217+
echo "| **URL** | ${url} |"
218+
echo "| **Environment** | ${environment} |"
219+
if [[ -n "${log_url}" ]]; then
220+
echo "| **Dashboard** | [View](${log_url}) |"
221+
fi
222+
} >> "${GITHUB_STEP_SUMMARY}"
223+
fi
224+
}
225+
226+
main() {
227+
case "${1:-}" in
228+
comment) cmd_comment ;;
229+
deployment) cmd_deployment ;;
230+
*)
231+
echo "Usage: $(basename "$0") {comment|deployment}" >&2
232+
exit 1
233+
;;
234+
esac
235+
}
236+
237+
main "$@"
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Migrate Hosting to Cloudflare Pages
2+
3+
## Problem Statement
4+
5+
The documentation site is currently hosted on GitHub Pages at `chinmina.github.io`. The `chinmina.dev` domain has been registered with Cloudflare, and the canonical home for the docs should be `docs.chinmina.dev`. GitHub Pages does not integrate with the Cloudflare-managed domain, and the two deployments need to coexist during the transition while search engines index the new URL.
6+
7+
## Solution
8+
9+
Deploy the site to Cloudflare Pages (project: `chinmina`) via the existing GitHub Actions pipeline. The Astro `site` config is updated to `https://docs.chinmina.dev`, which causes all builds — both Cloudflare Pages and GitHub Pages — to emit canonical links pointing to the new domain. GitHub Pages remains live and up to date throughout, serving as a fallback and preserving the old URL until a redirect strategy is decided separately.
10+
11+
## Requirements
12+
13+
### Canonical URL and SEO
14+
15+
1. The site shall set `https://docs.chinmina.dev` as the canonical base URL in `astro.config.mjs`.
16+
2. The site shall emit a `<link rel="canonical">` tag on every page, resolving to the corresponding URL under `https://docs.chinmina.dev`.
17+
3. When a build is deployed to GitHub Pages, the system shall emit canonical links pointing to `https://docs.chinmina.dev`, not to `chinmina.github.io`.
18+
19+
### Cloudflare Pages Deployment
20+
21+
4. When a commit is pushed to `main`, the CI shall build the site and deploy it to Cloudflare Pages as a production deployment under `https://chinmina.pages.dev`.
22+
5. When a pull request is opened or updated, the CI shall build the site and deploy it to a Cloudflare Pages preview URL.
23+
6. While a pull request is open, its Cloudflare Pages preview deployment shall remain accessible at its preview URL.
24+
7. When a Cloudflare Pages deployment completes, the CI shall surface the deployment URL in the workflow summary.
25+
8. If the Cloudflare Pages deployment step fails, then the CI shall fail and not mark the workflow as successful.
26+
27+
### GitHub Pages Deployment (Continued)
28+
29+
9. When a commit is pushed to `main`, the CI shall also deploy the same build to GitHub Pages.
30+
10. If the GitHub Pages deployment step fails, then the CI shall fail and not mark the workflow as successful.
31+
32+
### Build Pipeline
33+
34+
11. The CI shall install the D2 diagramming tool before running the Astro build.
35+
12. The CI shall produce a single build artifact shared by both the Cloudflare Pages and GitHub Pages deployment jobs.
36+
13. If the build step fails, then the CI shall not attempt either deployment.
37+
38+
### DNS and Domain
39+
40+
14. The system shall serve the Cloudflare Pages production deployment at `https://docs.chinmina.dev` via a DNS CNAME record in Cloudflare.
41+
15. The Cloudflare Pages project shall enforce HTTPS for all requests to `docs.chinmina.dev`.
42+
43+
### Optional
44+
45+
16. Where a pull request triggers a CI build, the CI shall output the Cloudflare Pages preview URL as a GitHub Actions step summary.
46+
47+
## Implementation Decisions
48+
49+
**Workflow restructure**: The current `withastro/action` couples the build to GitHub Pages artifact upload. To share one build between two deploy targets, the build must be extracted into explicit steps: install D2, set up Node.js (via `actions/setup-node` with `enable-corepack: true` — corepack reads the `packageManager` field in `package.json` and provisions the pinned pnpm version automatically), run `pnpm install`, run `pnpm run build`, then upload two artifacts — one as a GitHub Pages artifact (`actions/upload-pages-artifact`) and one as a generic `dist/` artifact (`actions/upload-artifact`). The two deploy jobs run in parallel after the build job completes.
50+
51+
**GitHub Pages deploy job**: Unchanged in behaviour. Conditional on `github.ref == 'refs/heads/main'`. Consumes the GitHub Pages artifact via `actions/deploy-pages`.
52+
53+
**Cloudflare Pages deploy job**: Runs on all branches (for preview support). Downloads the `dist/` artifact and deploys via `cloudflare/wrangler-action` with `command: pages deploy dist --project-name=chinmina`. Requires `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` as GitHub Actions secrets.
54+
55+
**`wrangler.toml`**: A minimal `wrangler.toml` at the repo root declares `name = "chinmina"` and `pages_build_output_dir = "dist"`. This makes the project identity explicit and removes the need to pass flags in the workflow command.
56+
57+
**Canonical link handling**: Astro emits `<link rel="canonical">` automatically based on the `site` config. Changing `site` to `https://docs.chinmina.dev` is sufficient — no changes to `Head.astro` are required. The `site` change and the Cloudflare Pages go-live must ship in the same merge to avoid a window where GitHub Pages serves canonicals pointing to a domain not yet live.
58+
59+
**Cloudflare Pages project**: Must be created in the Cloudflare dashboard (or via Wrangler) before the first deployment. Custom domain `docs.chinmina.dev` is configured in the Cloudflare Pages project settings. DNS is a CNAME record: `docs.chinmina.dev``chinmina.pages.dev`.
60+
61+
**GitHub Pages remains live**: GitHub Pages is not disabled as part of this work. It continues to receive deployments from `main` and serves the site at `chinmina.github.io` with canonical links pointing to `docs.chinmina.dev`. Decommissioning GitHub Pages is deferred to a future redirect-strategy workstream.
62+
63+
**GitHub Actions secrets needed**:
64+
- `CLOUDFLARE_API_TOKEN` — scoped to Cloudflare Pages edit permissions
65+
- `CLOUDFLARE_ACCOUNT_ID` — the Cloudflare account hosting the `chinmina` project
66+
67+
## Testing Decisions
68+
69+
This is an infrastructure and configuration change. There are no unit tests. All requirements map to manual acceptance checks performed after deployment:
70+
71+
| Requirement | Verification |
72+
|---|---|
73+
| 1–3 (canonical) | View page source on both `docs.chinmina.dev` and `chinmina.github.io`; confirm canonical tag resolves to `docs.chinmina.dev` |
74+
| 4 (production deploy) | Merge to `main`; confirm Cloudflare Pages production deployment succeeds and site is reachable at `chinmina.pages.dev` |
75+
| 5–6 (preview deploy) | Open a PR; confirm a preview URL appears in the workflow summary and is reachable |
76+
| 9 (GH Pages continued) | Merge to `main`; confirm GitHub Pages deployment succeeds and `chinmina.github.io` reflects the change |
77+
| 11 (D2) | Confirm a page containing a D2 diagram renders correctly on `docs.chinmina.dev` |
78+
| 14–15 (DNS + HTTPS) | `curl -I https://docs.chinmina.dev`; confirm 200 and valid TLS certificate |
79+
80+
## Out of Scope
81+
82+
- Redirecting `chinmina.github.io` to `docs.chinmina.dev` (deferred to a separate workstream)
83+
- Disabling GitHub Pages
84+
- Redirecting the apex domain `chinmina.dev` to `docs.chinmina.dev`
85+
- Any changes to site content or structure
86+
87+
## Further Notes
88+
89+
The `withastro/action` action is a convenience wrapper that bundles Node.js setup, pnpm detection, build, and GitHub Pages artifact upload in one step. Replacing it with explicit steps adds a few lines to the workflow but gives full control over the build environment — necessary here because of the D2 pre-install step and the need to share the build output with a second deploy target.
90+
91+
The Cloudflare Pages project must exist before the first pipeline run. Creating it via the Cloudflare dashboard (connect to GitHub, select repo, set build command to `pnpm run build` and output dir to `dist`) is the recommended path, but with `wrangler.toml` in place, `wrangler pages project create chinmina` also works.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
"remark-parse": "^11.0.0",
3232
"remark-stringify": "^11.0.0",
3333
"unified": "^11.0.5",
34-
"vitest": "^3.0.0"
34+
"vitest": "^3.0.0",
35+
"wrangler": "^4.77.0"
3536
},
3637
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a"
3738
}

0 commit comments

Comments
 (0)