Skip to content

Website Preview Post-Build #51

Website Preview Post-Build

Website Preview Post-Build #51

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,
});
}