Skip to content

Commit 9689169

Browse files
authored
ci: add Cloudflare Pages deployment (#33)
* ci: add Cloudflare Pages deployment to workflow Adds wrangler.toml at repo root declaring project identity (name=chinmina, pages_build_output_dir=dist) and a deploy-cloudflare job that downloads the shared dist artifact and deploys via wrangler-action on all branches. Production deploys on main; PRs get preview URLs. Deployment URL written to workflow summary. * ci: update download-artifact to v8 to match upload-artifact v7 * ci: add cloudflare-pages environment to deploy-cloudflare job * ci: replace wrangler-action with direct wrangler CLI and deploy script Use the package.json wrangler CLI instead of cloudflare/wrangler-action. The deploy job now checks out the repo, installs deps, then runs pnpm wrangler pages deploy directly. Post-deploy GitHub integration (PR comment, deployment record, step summary) is handled by .github/scripts/cloudflare-deploy.sh. * chore: add wrangler dep, deploy script, example workflow, and PRD * ci: use cloudflare environment for secrets; remove example workflow * ci: remove duplicate deployment; surface URL via environment block The job environment: block auto-creates a GitHub Deployment named 'cloudflare', so cloudflare-deploy.sh deployment was redundant and producing a second record named 'preview'. Replace it with an inline URL extraction step that sets environment.url and writes the step summary. PR comments via cloudflare-deploy.sh comment are unchanged. * ci: pass --branch to wrangler pages deploy for correct preview alias * ci: drop redundant dist and --project-name from wrangler deploy (set in wrangler.toml) * ci: pass branch via env var to avoid command injection * ci: use <a target="_blank"> in PR comment for new-window links * ci: fix PR comment detection to match updated heading * ci: use HTML comment marker for robust PR comment detection * ci: skip Cloudflare deploy for forked PRs and Dependabot
1 parent 563e92b commit 9689169

6 files changed

Lines changed: 636 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+
local url
95+
local alias_url
96+
local body
97+
98+
entry=$(read_deploy_output)
99+
url=$(extract_url "${entry}")
100+
[[ -z "${url}" ]] && die "could not extract deployment URL from wrangler output"
101+
102+
alias_url=$(jq -r '.alias // empty' <<< "${entry}" 2>/dev/null)
103+
104+
body="<!-- cf-branch-preview -->
105+
### Branch preview
106+
107+
🔗 [${alias_url}](${alias_url}) ([direct commit link](${url}))
108+
"
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 | contains("<!-- cf-branch-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 "$@"

.github/workflows/deploy.yaml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ permissions:
1111
contents: read
1212
pages: write
1313
id-token: write
14+
deployments: write
15+
pull-requests: write
1416

1517
jobs:
1618
build:
@@ -41,6 +43,62 @@ jobs:
4143
name: dist
4244
path: dist/
4345

46+
deploy-cloudflare:
47+
needs: build
48+
if: github.event_name != 'pull_request' || (github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]')
49+
runs-on: ubuntu-latest
50+
environment:
51+
name: cloudflare
52+
url: ${{ steps.cf-url.outputs.value }}
53+
steps:
54+
- name: Checkout repository
55+
uses: actions/checkout@v6
56+
57+
- name: Set up Node.js
58+
uses: actions/setup-node@v6
59+
with:
60+
node-version-file: .tool-versions
61+
62+
- name: Enable corepack
63+
run: corepack enable
64+
65+
- name: Install dependencies
66+
run: pnpm install --frozen-lockfile
67+
68+
- name: Download dist artifact
69+
uses: actions/download-artifact@v8
70+
with:
71+
name: dist
72+
path: dist/
73+
74+
- name: Deploy to Cloudflare Pages
75+
id: deploy
76+
env:
77+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
78+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
79+
WRANGLER_OUTPUT_FILE_DIRECTORY: .wrangler-output
80+
CF_BRANCH: ${{ github.head_ref || github.ref_name }}
81+
run: pnpm wrangler pages deploy --branch="$CF_BRANCH"
82+
83+
- name: Extract deployment URL
84+
id: cf-url
85+
if: always() && steps.deploy.outcome == 'success'
86+
run: |
87+
url=$(cat .wrangler-output/wrangler-output-*.json 2>/dev/null \
88+
| jq -r 'select(.type == "pages-deploy-detailed") | .url // empty' \
89+
| head -1)
90+
echo "value=${url}" >> "$GITHUB_OUTPUT"
91+
echo "### Cloudflare Pages" >> "$GITHUB_STEP_SUMMARY"
92+
echo "**URL:** ${url}" >> "$GITHUB_STEP_SUMMARY"
93+
94+
- name: Post PR comment
95+
if: github.event_name == 'pull_request'
96+
env:
97+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
98+
WRANGLER_OUTPUT_FILE_DIRECTORY: .wrangler-output
99+
PR_NUMBER: ${{ github.event.pull_request.number }}
100+
run: .github/scripts/cloudflare-deploy.sh comment
101+
44102
deploy:
45103
needs: build
46104
if: github.event_name == 'push' && github.ref == 'refs/heads/main'

0 commit comments

Comments
 (0)