Skip to content

[pull] main from TryGhost:main #1098

[pull] main from TryGhost:main

[pull] main from TryGhost:main #1098

Workflow file for this run

name: PR Preview

Check warning on line 1 in .github/workflows/pr-preview.yml

View workflow run for this annotation

GitHub Actions / PR Preview

Workflow execution policy warning (evaluate mode)

On November 2, 2026, GitHub will restrict `pull_request_target` on public repositories by default. To continue allowing the event trigger, configure an Actions policy. Learn more: https://gh.io/securely-using-pull_request_target#default-policy-for-pull_request_target
on:
pull_request_target:
types: [labeled, unlabeled, closed]
jobs:
deploy:
name: Deploy Preview
# Runs when "preview" or a "preview:<profile>" label is added — either needs
# collaborator write access. Adding a profile label to an already-labelled PR
# has to redeploy, or the label would claim a profile the preview is not on;
# Ghost-Moya notices the change and reseeds.
#
# Removing a profile label redeploys for the same reason, in reverse: the
# labels would otherwise say "default" while the preview stayed seeded on
# whatever the removed label named. `startsWith` excludes the bare `preview`
# label, which has no colon, so removing that still only destroys.
if: >-
(github.event.action == 'labeled'
&& (github.event.label.name == 'preview'
|| startsWith(github.event.label.name, 'preview:')))
|| (github.event.action == 'unlabeled'
&& startsWith(github.event.label.name, 'preview:'))
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
env:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
steps:
- name: Wait for Docker build job
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_JOB_NAME: Build Docker Images
run: |
echo "Waiting for '${BUILD_JOB_NAME}' job to complete for $HEAD_SHA..."
TIMEOUT=1800 # 30 minutes
INTERVAL=30
START=$(date +%s)
while true; do
ELAPSED=$(( $(date +%s) - START ))
if [ "$ELAPSED" -ge "$TIMEOUT" ]; then
echo "::error::Timed out waiting for '${BUILD_JOB_NAME}' (${TIMEOUT}s)"
exit 1
fi
# Find the CI run for this SHA
RUN=$(gh api "repos/${{ github.repository }}/actions/workflows/ci.yml/runs?head_sha=${HEAD_SHA}&per_page=1" \
--jq '.workflow_runs[0] | {id, status}' 2>/dev/null || echo "")
if [ -z "$RUN" ] || [ "$RUN" = "null" ]; then
echo " No CI run found yet, waiting ${INTERVAL}s... (${ELAPSED}s elapsed)"
sleep "$INTERVAL"
continue
fi
RUN_ID=$(echo "$RUN" | jq -r '.id')
RUN_STATUS=$(echo "$RUN" | jq -r '.status')
# Look up the build job specifically (paginate — CI has 30+ jobs)
BUILD_JOB=$(gh api --paginate "repos/${{ github.repository }}/actions/runs/${RUN_ID}/jobs?per_page=100" \
--jq ".jobs[] | select(.name == \"${BUILD_JOB_NAME}\") | {status, conclusion}")
if [ -z "$BUILD_JOB" ]; then
if [ "$RUN_STATUS" = "completed" ]; then
echo "::error::CI run ${RUN_ID} completed but '${BUILD_JOB_NAME}' job was not found"
exit 1
fi
echo " '${BUILD_JOB_NAME}' job not started yet (run ${RUN_STATUS}), waiting ${INTERVAL}s... (${ELAPSED}s elapsed)"
sleep "$INTERVAL"
continue
fi
JOB_STATUS=$(echo "$BUILD_JOB" | jq -r '.status')
JOB_CONCLUSION=$(echo "$BUILD_JOB" | jq -r '.conclusion // empty')
if [ "$JOB_STATUS" = "completed" ]; then
if [ "$JOB_CONCLUSION" = "success" ]; then
echo "Docker build ready (CI run $RUN_ID)"
break
fi
echo "::error::'${BUILD_JOB_NAME}' did not succeed (conclusion: $JOB_CONCLUSION)"
exit 1
fi
echo " '${BUILD_JOB_NAME}' still ${JOB_STATUS}, waiting ${INTERVAL}s... (${ELAPSED}s elapsed)"
sleep "$INTERVAL"
done
- name: Re-check PR eligibility
id: recheck
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR=$(gh api "repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}" \
--jq '{state, labels: [.labels[].name]}')
STATE=$(echo "$PR" | jq -r '.state')
HAS_LABEL=$(echo "$PR" | jq '.labels | any(. == "preview")')
if [ "$STATE" != "open" ]; then
echo "::warning::PR is no longer open ($STATE), skipping dispatch"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# `preview` is the switch; `preview:<profile>` only says which data to
# use. A profile label on its own is not a request for a preview, and
# saying so beats the older "label was removed" warning, which is
# actively misleading when it was never added.
if [ "$HAS_LABEL" != "true" ]; then
if echo "$PR" | jq -e '.labels | any(startswith("preview:"))' > /dev/null; then
echo "::warning::A preview:<profile> label needs the 'preview' label too, skipping dispatch"
else
echo "::warning::preview label was removed, skipping dispatch"
fi
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# A `preview:<profile>` label picks how much seed data the preview starts
# with. The names are defined in TryGhost/Ghost-Moya's
# preview/profiles.json and validated there, so nothing here needs to
# know them — passing the suffix through keeps one source of truth.
# Sending nothing means Moya applies its own default.
# Counted with jq rather than by word-splitting: a label may contain a
# space, and counting words would read one such label as two profiles
# and reject it with a message about a conflict that does not exist.
COUNT=$(echo "$PR" | jq '[.labels[] | select(startswith("preview:"))] | length')
PROFILE=$(echo "$PR" | jq -r '[.labels[] | select(startswith("preview:")) | ltrimstr("preview:")] | first // ""')
if [ "$COUNT" -gt 1 ]; then
NAMES=$(echo "$PR" | jq -r '[.labels[] | select(startswith("preview:"))] | join(", ")')
echo "::error::Multiple preview profile labels ($NAMES) — remove all but one"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 1
fi
if [ "$COUNT" -eq 1 ]; then
# Shape only, not a list of names — the names stay Ghost-Moya's to
# own. This rejects a malformed label here, with a message that says
# what is wrong, rather than dispatching it to fail further away.
#
# Matched with [[ =~ ]] rather than grep. grep works a line at a
# time, so ^ and $ bound a line, not the string, and `grep -q`
# succeeds if any one line matches: a label containing a newline
# would pass on its first line and then write a second line into
# GITHUB_OUTPUT, which becomes a step output of its own. This
# workflow is pull_request_target and carries a dispatch token, so
# that is worth closing rather than noting. bash anchors to the
# whole string, so a newline cannot match at all.
if ! [[ "$PROFILE" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
echo "::error::Malformed profile label 'preview:${PROFILE}' — expected lowercase words separated by hyphens"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 1
fi
echo "Preview profile: ${PROFILE}"
echo "profile=${PROFILE}" >> "$GITHUB_OUTPUT"
else
echo "No preview:<profile> label, Ghost-Moya will use its default"
echo "profile=" >> "$GITHUB_OUTPUT"
fi
echo "PR still eligible for preview deploy"
echo "skip=false" >> "$GITHUB_OUTPUT"
- name: Resolve image digest from GHCR
id: digest
if: steps.recheck.outputs.skip != 'true'
env:
IMAGE_REPO: tryghost/ghost
run: |
# Resolve the per-commit tag (not mutable pr-N) so a push during the wait
# cannot swap the image, then pin the digest past Artifact Registry's cache.
IMAGE_TAG="sha-${HEAD_SHA:0:7}"
TOKEN=$(curl -sf "https://ghcr.io/token?service=ghcr.io&scope=repository:${IMAGE_REPO}:pull" | jq -r '.token')
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "::error::Failed to acquire anonymous GHCR token for ${IMAGE_REPO}"
exit 1
fi
DIGEST=$(curl -sfI \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/vnd.oci.image.index.v1+json" \
-H "Accept: application/vnd.docker.distribution.manifest.list.v2+json" \
-H "Accept: application/vnd.docker.distribution.manifest.v2+json" \
"https://ghcr.io/v2/${IMAGE_REPO}/manifests/${IMAGE_TAG}" \
| awk 'tolower($1) == "docker-content-digest:" {print $2}' | tr -d '\r\n')
if [[ ! "$DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]; then
echo "::error::Could not resolve digest for ghcr.io/${IMAGE_REPO}:${IMAGE_TAG} (got: '$DIGEST')"
exit 1
fi
echo "Resolved ${IMAGE_TAG} digest: $DIGEST"
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"
- name: Dispatch deploy to Ghost-Moya
if: steps.recheck.outputs.skip != 'true'
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4
with:
token: ${{ secrets.CANARY_DOCKER_BUILD }}
repository: TryGhost/Ghost-Moya
event-type: preview-deploy
client-payload: >-
{
"pr_number": "${{ github.event.pull_request.number }}",
"image_digest": "${{ steps.digest.outputs.digest }}",
"action": "deploy",
"profile": "${{ steps.recheck.outputs.profile }}"
}
destroy:
name: Destroy Preview
# Runs when "preview" label is removed, or the PR is closed/merged while labeled
if: >-
(github.event.action == 'unlabeled' && github.event.label.name == 'preview')
|| (github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'preview'))
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Dispatch destroy to Ghost-Moya
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4
with:
token: ${{ secrets.CANARY_DOCKER_BUILD }}
repository: TryGhost/Ghost-Moya
event-type: preview-destroy
client-payload: >-
{
"pr_number": "${{ github.event.pull_request.number }}",
"action": "destroy"
}