Skip to content

PR preview deploy

PR preview deploy #749

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
# opengeos/pages-preview is a single GitHub Pages site, and Pages builds one
# commit at a time per repository. Two preview deploys landing together make
# the older build report `errored` even though its push succeeded, which the
# action's wait step treats as fatal. The workflow-level group above is keyed
# per branch and so only serializes reruns of the same PR; this one holds
# every Pages deploy in this repository to one at a time. It cannot cover
# deploys from the other repositories that share the site (Actions
# concurrency is per repository), which is why the verify step below still
# has to tell a superseded build apart from a real failure.
concurrency:
group: pages-preview-deploy
cancel-in-progress: false
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 }}
# Fallback reference point for the verify step, used only when the commit
# the deploy pushed cannot be read back (see below).
- name: Note the deploy start time
id: started
run: echo "at=$(date -u +%s)" >> "$GITHUB_OUTPUT"
- name: Deploy preview to GitHub Pages
id: pages
# A superseded Pages build is reported as `errored`, which fails this
# action's wait step even though the push landed and the next build
# publishes it. Never fail the job here -- the verify step below decides,
# by checking whether the preview actually came up.
continue-on-error: true
# 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
# Decides whether the deploy really failed. A Pages build that another
# repository's deploy superseded reports `errored`, but this deploy's
# commit is already on gh-pages, so the very next build publishes it --
# historically within about a minute. So rather than trusting the action's
# outcome, wait for a Pages build that both succeeded and started after
# this deploy's commit landed (any such build necessarily contains it),
# then confirm the URL serves. Only if neither holds is the preview broken.
#
# `deployed-commit-sha` is what separates the two failure modes. The
# action sets it by reading gh-pages HEAD back *after* pushing, in a step
# that only runs once the push step itself succeeded -- so an empty value
# means nothing was ever pushed (bad or expired token, network error),
# not a superseded build. Neither the URL nor the build list can tell
# that apart on its own: a previous preview for this PR keeps serving 200,
# and this Pages site is shared and busy enough that some other
# repository's build almost always completes inside the poll window. So
# report it immediately -- waiting ten minutes would only delay a genuine
# outage.
- name: Verify the preview published
id: verify
if: needs.resolve.outputs.action == 'deploy'
env:
GH_TOKEN: ${{ secrets.PREVIEW_DEPLOY_TOKEN }}
STARTED_AT: ${{ steps.started.outputs.at }}
PAGES_OUTCOME: ${{ steps.pages.outcome }}
DEPLOYED_SHA: ${{ steps.pages.outputs.deployed-commit-sha }}
PREVIEW_URL: ${{ steps.pages.outputs.preview-url || format('https://opengeos.org/pages-preview/GeoLibre/pr-{0}/', needs.resolve.outputs.number) }}
run: |
echo "url=$PREVIEW_URL" >> "$GITHUB_OUTPUT"
if [ -z "$DEPLOYED_SHA" ] && [ "$PAGES_OUTCOME" != "success" ]; then
echo "published=false" >> "$GITHUB_OUTPUT"
echo "::error::The preview was never pushed to opengeos/pages-preview -- the deploy failed before it reached gh-pages. See the deploy step's log."
exit 1
fi
# A build only contains this deploy if it started after the deploy's
# commit landed, so prefer that commit's timestamp over the pre-push
# one recorded above; a build can start between the two and carry
# none of this deploy.
ref_at="$STARTED_AT"
if [ -n "$DEPLOYED_SHA" ] &&
commit_date=$(gh api "repos/opengeos/pages-preview/commits/${DEPLOYED_SHA}" \
--jq '.commit.committer.date' 2>/dev/null); then
commit_at=$(date -u -d "$commit_date" +%s 2>/dev/null || echo 0)
if [ "$commit_at" -gt "$ref_at" ]; then
ref_at="$commit_at"
fi
fi
echo "Verifying $PREVIEW_URL (deployed commit ${DEPLOYED_SHA:-unknown})"
# Bound the whole poll by wall clock rather than by attempt count, so
# the ten minutes reported on failure is the time actually spent
# rather than 20 sleeps plus 20 unbounded API round trips.
deadline=$(( $(date -u +%s) + 600 ))
attempt=0
while :; do
attempt=$((attempt + 1))
code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 "$PREVIEW_URL" || echo 000)
# A 200 alone could still be the previous deploy of this same PR, so
# also require a successful Pages build that started after the push.
# per_page=100 because the default page of this shared, frequently
# built site can fill with other repositories' builds; the list is
# used rather than /builds/latest because the newest build is often
# another repository's, still in flight, while the build that
# published this deploy has already finished.
# If the builds API is not readable with this token, do not block on
# it -- fall back to the URL check rather than failing every run.
if builds=$(gh api 'repos/opengeos/pages-preview/pages/builds?per_page=100' 2>/dev/null); then
built_at=$(printf '%s' "$builds" |
jq -r '[.[] | select(.status == "built")] | max_by(.created_at) | .created_at // empty')
if [ -n "$built_at" ] &&
[ "$(date -u -d "$built_at" +%s 2>/dev/null || echo 0)" -ge "$ref_at" ]; then
fresh=yes
else
fresh=no
fi
echo "Attempt $attempt: HTTP $code, latest successful build $built_at (fresh: $fresh)"
else
fresh=unknown
echo "Attempt $attempt: HTTP $code, Pages builds API unreadable -- relying on the URL"
fi
if [ "$code" = "200" ] && [ "$fresh" != "no" ]; then
echo "published=true" >> "$GITHUB_OUTPUT"
exit 0
fi
[ "$(date -u +%s)" -lt "$deadline" ] || break
sleep 30
done
echo "published=false" >> "$GITHUB_OUTPUT"
echo "::error::The preview at $PREVIEW_URL did not publish within 10 minutes."
exit 1
# `continue-on-error` on the deploy step also swallows a failed *removal*,
# which the verify step does not cover -- there is no URL to poll, since a
# removal is meant to take the preview away. Surface that here instead.
- name: Fail if the preview removal failed
if: needs.resolve.outputs.action == 'remove' && steps.pages.outcome == 'failure'
run: |
echo "::error::Removing the preview for PR ${{ needs.resolve.outputs.number }} failed."
exit 1
- name: Comment GitHub Pages preview status
if: always() && needs.resolve.outputs.action == 'deploy'
uses: actions/github-script@v9
env:
PAGES_URL: ${{ steps.verify.outputs.url }}
PAGES_OUTCOME: ${{ steps.verify.outputs.published == 'true' && 'success' || 'failure' }}
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 });
}