Repository navigation
[pull] main from TryGhost:main #1098
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PR Preview | ||
|
Check warning on line 1 in .github/workflows/pr-preview.yml
|
||
| 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" | ||
| } | ||