-
-
Notifications
You must be signed in to change notification settings - Fork 653
266 lines (252 loc) · 12.5 KB
/
Copy pathpr-preview-deploy.yml
File metadata and controls
266 lines (252 loc) · 12.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
name: PR preview deploy
# Stage 2 of 2: PUBLISH ONLY. Holds the deploy secrets; runs no contributor code.
#
# Triggered by `workflow_run` on .github/workflows/pr-preview.yml, which builds
# the site from the PR's tree with no secrets in scope. Because `workflow_run`
# always executes the workflow definition from the DEFAULT BRANCH, a pull
# request cannot alter what this file does, and repository secrets are
# available even when the PR came from a fork -- which is the entire reason the
# preview is split in two.
#
# The invariant that makes that safe: this job must never execute anything from
# the pull request. It downloads an artifact and copies files. Specifically:
#
# * The GitHub Pages job checks out the default branch. It must never be
# given `ref: ...head.sha` or anything else derived from the PR.
# * The Cloudflare job does not check out any repository.
# * No `npm ci`, no build, no `npm run`, no running a script out of `site/`.
# The artifact is inert data here -- static files pushed to a host.
# * Nothing from the artifact may be interpolated into a `run:` block.
#
# GitHub Pages and Cloudflare deploy in separate jobs. They share only the PR
# resolution job, so a failure in either host cannot block the other.
#
# The PR number is NOT taken from the artifact, which a fork controls and could
# use to overwrite an unrelated PR's preview and comment on it. It is looked up
# from the API using the head repo and branch recorded in the trusted
# `workflow_run` payload, so a run can only ever address its own PR.
#
# Required repository secrets:
# CLOUDFLARE_API_TOKEN - needs the "Cloudflare Pages: Edit" permission
# CLOUDFLARE_ACCOUNT_ID
# PREVIEW_DEPLOY_TOKEN - fine-grained PAT scoped to opengeos/pages-preview
# only, with Contents: Read and write + Pages: Read.
# Pages read is required by wait-for-pages-deployment
# below; without it the poll never sees a build and
# the job times out with a misleading error.
on:
workflow_run:
workflows: ["PR preview"]
types: [completed]
permissions:
contents: read
# Download the build artifact from the triggering run.
actions: read
# Update the sticky preview comments. The deploy to opengeos/pages-preview
# uses PREVIEW_DEPLOY_TOKEN, which has no access to this repository.
pull-requests: write
concurrency:
group: pr-preview-deploy-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
# The GitHub Pages job pushes commits to opengeos/pages-preview. Queue
# overlapping runs instead of killing one mid-push.
cancel-in-progress: false
jobs:
resolve:
name: Resolve pull request
runs-on: ubuntu-latest
# Only for builds that came from a pull request and actually succeeded. A
# `workflow_dispatch` build of pr-preview.yml is a build smoke test and
# publishes nothing, matching the behaviour before the split.
if: >-
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
outputs:
found: ${{ steps.pr.outputs.found }}
number: ${{ steps.pr.outputs.number }}
action: ${{ steps.pr.outputs.action }}
steps:
# Resolve the PR from trusted event data only. `workflow_run.pull_requests`
# is empty for fork PRs, so query by head instead: the (fork, branch) pair
# is recorded by GitHub, not by the build, and an attacker cannot push to
# someone else's fork -- so this can only resolve to the PR that triggered
# the run.
- name: Resolve the pull request
id: pr
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
HEAD_OWNER: ${{ github.event.workflow_run.head_repository.owner.login }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
run: |
set -euo pipefail
# Highest number wins if a branch has been reused across PRs; that is
# the current one.
# No --paginate: the head filter cannot return anywhere near 100 PRs,
# and per-page --jq would mangle the max_by below.
#
# `// empty` matters: max_by on an empty array yields null, and piping
# null into the object constructor would produce {"number":null} --
# a truthy string that sails past the emptiness check below.
pr=$(gh api \
"repos/${REPO}/pulls?state=all&per_page=100&head=${HEAD_OWNER}:${HEAD_BRANCH}" \
--jq 'max_by(.number) // empty | {number, state}' || true)
if [ -z "$pr" ]; then
echo "::notice::No pull request found for ${HEAD_OWNER}:${HEAD_BRANCH} -- nothing to publish."
echo "found=false" >> "$GITHUB_OUTPUT"
exit 0
fi
number=$(echo "$pr" | jq -r .number)
state=$(echo "$pr" | jq -r .state)
case "$number" in
''|*[!0-9]*) echo "::error::Unexpected PR number: $number"; exit 1 ;;
esac
echo "PR #${number} is ${state}"
echo "found=true" >> "$GITHUB_OUTPUT"
echo "number=${number}" >> "$GITHUB_OUTPUT"
# Drive deploy-vs-remove off the PR's live state rather than off the
# build job, so a preview is never published for a closed PR.
if [ "$state" = "closed" ]; then
echo "action=remove" >> "$GITHUB_OUTPUT"
else
echo "action=deploy" >> "$GITHUB_OUTPUT"
fi
cloudflare:
name: Deploy Cloudflare preview
needs: resolve
if: >-
needs.resolve.outputs.found == 'true' &&
needs.resolve.outputs.action == 'deploy'
runs-on: ubuntu-latest
steps:
- name: Download the built site
uses: actions/download-artifact@v8
with:
name: pr-preview-site
path: site
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Drop the CDN-redirected DuckDB WASM
# Cloudflare Pages rejects any single file > 25 MiB. Stage 1 already
# wrote site/_redirects pointing every such file at jsDelivr and failed
# the build on any it could not map, so deleting by size alone here is
# safe: anything left over 25 MiB is already redirected.
run: |
set -euo pipefail
find site -type f -size +26214400c -printf 'dropping %p (%s bytes)\n' -delete
echo "----- site/_redirects -----"
cat site/_redirects || echo "(none)"
- name: Create a scratch directory for wrangler
run: mkdir -p .wrangler-deploy
- name: Deploy to Cloudflare Pages
id: deploy
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
# wrangler-action installs wrangler with npm into its working
# directory. Point it at an empty scratch dir so it does not resolve
# against this monorepo's package.json / workspaces, and give the
# payload an absolute path.
workingDirectory: .wrangler-deploy
# A PR-number branch gives every PR an isolated preview and avoids
# collisions when forks use the same source branch name.
command: >-
pages deploy ${{ github.workspace }}/site
--project-name=geolibre-preview
--branch="pr-${{ needs.resolve.outputs.number }}"
--commit-hash="${{ github.event.workflow_run.head_sha }}"
--commit-dirty=true
- name: Comment preview URL on PR
uses: actions/github-script@v9
env:
DEPLOY_URL: ${{ steps.deploy.outputs.deployment-url }}
PR_NUMBER: ${{ needs.resolve.outputs.number }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
with:
script: |
const url = process.env.DEPLOY_URL;
if (!url) return;
const marker = '<!-- cloudflare-preview -->';
const body = `${marker}\n### 🔍 Cloudflare PR preview\n\n| Item | Value |\n| --- | --- |\n| Site | ${url} |\n| Demo app | ${url}/demo/ |\n| Commit | \`${process.env.HEAD_SHA.slice(0, 7)}\` |`;
const { owner, repo } = context.repo;
const issue_number = Number(process.env.PR_NUMBER);
const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number, per_page: 100 });
const existing = comments.find((c) => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number, body });
}
github-pages:
name: Deploy GitHub Pages preview
needs: resolve
if: needs.resolve.outputs.found == 'true'
runs-on: ubuntu-latest
steps:
# The Pages deploy action needs a git repository in the workspace. This
# is the DEFAULT BRANCH -- never the PR head. Do not add a `ref:` here.
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Download the built site
if: needs.resolve.outputs.action == 'deploy'
uses: actions/download-artifact@v8
with:
name: pr-preview-site
path: site
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Deploy preview to GitHub Pages
id: pages
# Pinned rather than tracking the v1 tag: this is a third-party action
# and it receives PREVIEW_DEPLOY_TOKEN.
uses: rossjrw/pr-preview-action@ffa7509e91a3ec8dfc2e5536c4d5c1acdf7a6de9 # v1.8.1
with:
source-dir: site
deploy-repository: opengeos/pages-preview
token: ${{ secrets.PREVIEW_DEPLOY_TOKEN }}
pages-base-url: opengeos.org/pages-preview
preview-branch: gh-pages
# pages-preview is shared across opengeos repositories and the action
# composes the path as "<umbrella-dir>/pr-<number>", so this namespaces
# our previews against every other repository's.
umbrella-dir: GeoLibre
# There is no pull_request payload under `workflow_run`, so the PR
# number and the deploy/remove decision have to be passed explicitly;
# the action would otherwise read them off the event and do nothing.
pr-number: ${{ needs.resolve.outputs.number }}
action: ${{ needs.resolve.outputs.action }}
# Same reason: the default messages interpolate `github.event.number`,
# which is empty here.
deploy-commit-message: Deploy preview for PR ${{ needs.resolve.outputs.number }} 🛫
remove-commit-message: Remove preview for PR ${{ needs.resolve.outputs.number }} 🛬
wait-for-pages-deployment: true
comment: false
- name: Comment GitHub Pages preview status
if: always() && needs.resolve.outputs.action == 'deploy'
uses: actions/github-script@v9
env:
PAGES_URL: ${{ steps.pages.outputs.preview-url }}
PAGES_OUTCOME: ${{ steps.pages.outcome }}
PR_NUMBER: ${{ needs.resolve.outputs.number }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
with:
script: |
const pagesUrl = process.env.PAGES_URL;
const succeeded = process.env.PAGES_OUTCOME === 'success' && pagesUrl;
const marker = '<!-- github-pages-preview -->';
const site = succeeded ? pagesUrl : 'Deploy failed. See the job log.';
const demo = succeeded
? `${pagesUrl}${pagesUrl.endsWith('/') ? '' : '/'}demo/`
: 'Unavailable';
const body = `${marker}\n### 🔍 GitHub Pages PR preview\n\n| Item | Value |\n| --- | --- |\n| Site | ${site} |\n| Demo app | ${demo} |\n| Commit | \`${process.env.HEAD_SHA.slice(0, 7)}\` |`;
const { owner, repo } = context.repo;
const issue_number = Number(process.env.PR_NUMBER);
const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number, per_page: 100 });
const existing = comments.find((c) => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number, body });
}