Repository navigation
Website Preview Post-Build #51
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: Website Preview Post-Build | |
| on: | |
| workflow_run: | |
| workflows: ["Build PR Preview"] | |
| types: [completed] | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| actions: read | |
| concurrency: | |
| group: preview-post-build-${{ github.event.workflow_run.head_branch }} | |
| cancel-in-progress: false | |
| env: | |
| PREVIEW_RETENTION_LIMIT: 3 | |
| jobs: | |
| read-metadata: | |
| if: github.event.workflow_run.conclusion == 'success' | |
| runs-on: ubuntu-24.04 | |
| outputs: | |
| pr-number: ${{ steps.meta.outputs.pr_number }} | |
| action: ${{ steps.meta.outputs.action }} | |
| steps: | |
| - name: Harden Runner | |
| uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 | |
| with: | |
| egress-policy: audit | |
| - name: Download build artifact | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: website-preview-build | |
| path: website-preview-build | |
| run-id: ${{ github.event.workflow_run.id }} | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Retrieve PR metadata | |
| id: meta | |
| run: | | |
| ACTION=$(head -n1 website-preview-build/pr/action 2>/dev/null | tr -d '[:space:]') | |
| case "$ACTION" in | |
| opened|reopened|synchronize|closed) ;; | |
| *) echo "::error::invalid or missing action: '$ACTION'"; exit 1 ;; | |
| esac | |
| PR_NUM=$(head -n1 website-preview-build/pr/number 2>/dev/null | tr -d '[:space:]') | |
| case "$PR_NUM" in | |
| ''|*[!0-9]*) echo "::error::invalid or missing PR number: '$PR_NUM'"; exit 1 ;; | |
| esac | |
| SHA=$(head -n1 website-preview-build/pr/sha 2>/dev/null | tr -d '[:space:]') | |
| case "$SHA" in | |
| ''|*[!a-f0-9]*) echo "::error::invalid or missing commit SHA: '$SHA'"; exit 1 ;; | |
| esac | |
| echo "action=$ACTION" >> "$GITHUB_OUTPUT" | |
| echo "pr_number=$PR_NUM" >> "$GITHUB_OUTPUT" | |
| echo "sha=$SHA" >> "$GITHUB_OUTPUT" | |
| deploy: | |
| needs: read-metadata | |
| runs-on: ubuntu-24.04 | |
| steps: | |
| - name: Harden Runner | |
| uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 | |
| with: | |
| egress-policy: audit | |
| - name: Checkout PR | |
| uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| persist-credentials: true | |
| - name: Download build artifact | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: website-preview-build | |
| path: website-preview-build | |
| run-id: ${{ github.event.workflow_run.id }} | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Deploy PR preview | |
| id: preview | |
| uses: rossjrw/pr-preview-action@ffa7509e91a3ec8dfc2e5536c4d5c1acdf7a6de9 #1.8.1 | |
| with: | |
| source-dir: ./website-preview-build/public | |
| # previews are being served at the default `pages-base-url` | |
| # if a cname is added, then the value of said custom domain must be given to `pages-base-url` | |
| umbrella-dir: preview-badges | |
| preview-branch: site | |
| qr-code: false | |
| comment: false | |
| action: ${{ needs.read-metadata.outputs.action == 'closed' && 'remove' || 'deploy' }} | |
| pr-number: ${{ needs.read-metadata.outputs.pr-number }} | |
| wait-for-pages-deployment: true | |
| - name: Comment preview URL | |
| uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2 | |
| with: | |
| header: preview-badges | |
| number: ${{ needs.read-metadata.outputs.pr-number }} | |
| message: | | |
| ${{ needs.read-metadata.outputs.action == 'closed' | |
| && 'Preview removed because the pull request was closed.' | |
| || format('Pull request preview: {0}', steps.preview.outputs.preview-url) }} | |
| prune: | |
| needs: [read-metadata, deploy] | |
| runs-on: ubuntu-24.04 | |
| if: needs.read-metadata.outputs.action != 'closed' | |
| outputs: | |
| removed_prs: ${{ steps.prune-previews.outputs.removed_prs }} | |
| removed_prs_json: ${{ steps.prune-previews.outputs.removed_prs_json }} | |
| steps: | |
| - name: Checkout site for preview retention | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: site | |
| fetch-depth: 0 | |
| sparse-checkout: | | |
| preview-badges | |
| path: site-maintenance | |
| - name: Prune old PR previews | |
| id: prune-previews | |
| # Pruning is best-effort housekeeping on the shared site branch. | |
| # Many docs preview runs write to site at once, so it must never be | |
| # the reason a preview is reported as failed: the push is retried against | |
| # the freshest state, and the step is continue-on-error as a backstop. | |
| continue-on-error: true | |
| run: | | |
| cd site-maintenance | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| # Write safe defaults first. $GITHUB_OUTPUT is last-write-wins, so even | |
| # if this step is interrupted, the downstream "Comment on pruned | |
| # previews" job sees a valid empty list rather than an empty string. | |
| { | |
| echo "removed_prs=" | |
| echo "removed_prs_json=[]" | |
| } >> "$GITHUB_OUTPUT" | |
| # A naive push to the shared site branch loses the race with other | |
| # concurrent preview runs ("! [rejected] (fetch first)"). Re-derive the | |
| # prune from the freshest remote state on every attempt and retry with | |
| # backoff so a lost race self-heals instead of failing the workflow. | |
| removed_prs=() | |
| outcome="" | |
| attempts=5 | |
| for attempt in $(seq 1 "$attempts"); do | |
| git fetch --quiet origin site | |
| git reset --quiet --hard FETCH_HEAD | |
| mkdir -p preview-badges | |
| mapfile -t previews < <( | |
| while IFS= read -r preview; do | |
| timestamp="$(git log -1 --format=%ct -- "preview-badges/$preview" 2>/dev/null || echo 0)" | |
| printf '%s %s\n' "$timestamp" "$preview" | |
| done < <(find preview-badges -mindepth 1 -maxdepth 1 -type d -name 'pr-*' -printf '%f\n') \ | |
| | sort -nr \ | |
| | awk '{print $2}' | |
| ) | |
| removed_prs=() | |
| if (( ${#previews[@]} > PREVIEW_RETENTION_LIMIT )); then | |
| for preview in "${previews[@]:PREVIEW_RETENTION_LIMIT}"; do | |
| rm -rf "preview-badges/$preview" | |
| removed_prs+=("${preview#pr-}") | |
| done | |
| fi | |
| # Within the retention limit, or another run already pruned: done. | |
| if git diff --quiet -- preview-badges; then | |
| outcome="noop" | |
| break | |
| fi | |
| git add preview-badges | |
| git commit --quiet -m "Prune old PR previews" | |
| if git push origin HEAD:site; then | |
| outcome="pushed" | |
| break | |
| fi | |
| echo "Prune push lost the race (attempt ${attempt}/${attempts}); re-syncing site and retrying..." | |
| sleep "$(( attempt * 5 + RANDOM % 5 ))" | |
| done | |
| if [ -z "$outcome" ]; then | |
| echo "::warning::Could not prune old PR previews after ${attempts} attempts due to concurrent site updates; a later run will retry. Not failing the preview." | |
| removed_prs=() | |
| fi | |
| if [ "${#removed_prs[@]}" -eq 0 ]; then | |
| { | |
| echo "removed_prs=" | |
| echo "removed_prs_json=[]" | |
| } >> "$GITHUB_OUTPUT" | |
| else | |
| { | |
| echo "removed_prs=$(IFS=,; echo "${removed_prs[*]}")" | |
| echo "removed_prs_json=$(printf '%s\n' "${removed_prs[@]}" | jq -R . | jq -sc .)" | |
| } >> "$GITHUB_OUTPUT" | |
| fi | |
| - name: Comment on pruned previews | |
| uses: actions/github-script@v8 | |
| env: | |
| REMOVED_PRS_JSON: ${{ steps.prune-previews.outputs.removed_prs_json }} | |
| PREVIEW_RETENTION_LIMIT: ${{ env.PREVIEW_RETENTION_LIMIT }} | |
| with: | |
| script: | | |
| const removedPrs = JSON.parse(process.env.REMOVED_PRS_JSON); | |
| const retentionLimit = process.env.PREVIEW_RETENTION_LIMIT; | |
| const header = "preview-badges"; | |
| const marker = `<!-- Sticky Pull Request Comment${header} -->`; | |
| for (const prNumber of removedPrs) { | |
| const body = | |
| `Preview deployment for PR #${prNumber} removed.\n\n` + | |
| `This PR preview was automatically pruned because we keep only the ${retentionLimit} most recently updated previews on GitHub Pages to stay within deployment size limits.\n\n` + | |
| `If needed, push a new commit to this PR to generate a fresh preview.\n` + | |
| `${marker}`; | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: Number(prNumber), | |
| per_page: 100, | |
| }); | |
| const existingComment = [...comments].reverse().find((comment) => | |
| comment.user?.login === "github-actions[bot]" && | |
| comment.body?.includes(marker) | |
| ); | |
| if (existingComment) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: existingComment.id, | |
| body, | |
| }); | |
| continue; | |
| } | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: Number(prNumber), | |
| body, | |
| }); | |
| } |