diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml deleted file mode 100644 index abeb2a23..00000000 --- a/.github/workflows/deploy-preview.yml +++ /dev/null @@ -1,212 +0,0 @@ -# Workflow for building and deploying Hugo PR previews to GitHub Pages -name: preview-deploy - -on: - pull_request_target: - branches: [ "master" ] - types: [opened, synchronize, reopened, closed] - - workflow_dispatch: - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages and comments -permissions: - contents: write - pages: write - pull-requests: write - -concurrency: - group: "pages-${{ github.event.pull_request.number || github.run_id }}" - cancel-in-progress: true - -# Default to bash -defaults: - run: - shell: bash - -jobs: - build-and-deploy: - runs-on: ubuntu-latest - env: - HUGO_VERSION: 0.158.0 - PREVIEW_RETENTION_LIMIT: 6 - - steps: - - name: Install Hugo CLI - if: github.event.action != 'closed' - run: | - wget -O ${{ runner.temp }}/hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb \ - && sudo dpkg -i ${{ runner.temp }}/hugo.deb - - - name: Install Dart Sass - if: github.event.action != 'closed' - run: sudo snap install dart-sass - - - name: Checkout - if: github.event.action != 'closed' - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.sha }} - submodules: recursive - fetch-depth: 0 - - - name: Checkout for cleanup - if: github.event.action == 'closed' - uses: actions/checkout@v6 - with: - ref: gh-pages - fetch-depth: 0 - - - name: Install Node.js dependencies - if: github.event.action != 'closed' - run: "[[ -f package-lock.json || -f npm-shrinkwrap.json ]] && npm ci || true" - - - name: Build with Hugo - if: github.event.action != 'closed' - env: - HUGO_ENVIRONMENT: production - HUGO_ENV: production - run: | - REPO_NAME=$(echo "${{ github.repository }}" | cut -d'/' -f2) - ORG_NAME=$(echo "${{ github.repository }}" | cut -d'/' -f1) - - PR_BASE_URL="https://${ORG_NAME}.github.io/${REPO_NAME}/pr-preview/pr-${{ github.event.pull_request.number }}/" - - echo "Building for BaseURL: ${PR_BASE_URL}" - - hugo \ - --gc \ - --minify \ - --buildDrafts \ - --buildFuture \ - --baseURL "${PR_BASE_URL}" - - - name: Deploy PR Preview - if: github.event.action != 'closed' - id: deploy-preview - uses: rossjrw/pr-preview-action@v1.6.3 - with: - source-dir: ./public - preview-branch: gh-pages - umbrella-dir: pr-preview - action: auto - comment: false - - - name: Checkout gh-pages for preview retention - if: github.event.action != 'closed' - uses: actions/checkout@v6 - with: - ref: gh-pages - fetch-depth: 0 - filter: blob:none - sparse-checkout: | - pr-preview - path: gh-pages-maintenance - - - name: Prune old PR previews - id: prune-previews - if: github.event.action != 'closed' - run: | - cd gh-pages-maintenance - mkdir -p pr-preview - removed_prs=() - - mapfile -t previews < <( - while IFS= read -r preview; do - timestamp="$(git log -1 --format=%ct -- "pr-preview/$preview" 2>/dev/null || echo 0)" - printf '%s %s\n' "$timestamp" "$preview" - done < <(find pr-preview -mindepth 1 -maxdepth 1 -type d -name 'pr-*' -printf '%f\n') \ - | sort -nr \ - | awk '{print $2}' - ) - - if (( ${#previews[@]} <= PREVIEW_RETENTION_LIMIT )); then - echo "removed_prs_json=[]" >> "$GITHUB_OUTPUT" - exit 0 - fi - - for preview in "${previews[@]:PREVIEW_RETENTION_LIMIT}"; do - rm -rf "pr-preview/$preview" - removed_prs+=("${preview#pr-}") - done - - if git diff --quiet -- pr-preview; then - echo "removed_prs=" >> "$GITHUB_OUTPUT" - echo "removed_prs_json=[]" >> "$GITHUB_OUTPUT" - exit 0 - fi - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add pr-preview - git commit -m "Prune old PR previews" - git push - - echo "removed_prs=$(IFS=,; echo "${removed_prs[*]}")" >> "$GITHUB_OUTPUT" - echo "removed_prs_json=$(printf '%s\n' "${removed_prs[@]}" | jq -R . | jq -sc .)" >> "$GITHUB_OUTPUT" - - - name: Comment PR with Preview URL - if: github.event.action != 'closed' - uses: marocchino/sticky-pull-request-comment@v2 - with: - header: pr-preview - message: | - ๐Ÿš€ Preview deployment: ${{ steps.deploy-preview.outputs.preview-url }} - > *Note: Preview may take a moment (GitHub Pages deployment in progress). Please wait and refresh. Track deployment [here](https://github.com/${{ github.repository }}/actions/workflows/pages/pages-build-deployment)* - - - name: Comment on pruned previews - if: github.event.action != 'closed' && steps.prune-previews.outputs.removed_prs_json != '[]' - uses: actions/github-script@v7 - 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 = "pr-preview"; - const marker = ``; - - 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, - }); - } - - - name: Cleanup PR Preview on Close - if: github.event.action == 'closed' - uses: rossjrw/pr-preview-action@v1.6.3 - with: - preview-branch: gh-pages - umbrella-dir: pr-preview - action: remove diff --git a/.github/workflows/preview-build-pr.yml b/.github/workflows/preview-build-pr.yml new file mode 100644 index 00000000..f1f2e211 --- /dev/null +++ b/.github/workflows/preview-build-pr.yml @@ -0,0 +1,22 @@ +name: preview-build-pr + +on: + pull_request: + branches: [master] + types: [opened, synchronize, reopened] + +permissions: {} + +concurrency: + group: preview-build-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + build: + permissions: + contents: read + # Local reference for now; point at the central repo once published: + # uses: //.github/workflows/preview-build.yml@ + uses: ./.github/workflows/preview-build.yml + with: + deployment-url: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }} diff --git a/.github/workflows/preview-build.yml b/.github/workflows/preview-build.yml new file mode 100644 index 00000000..3e6593f8 --- /dev/null +++ b/.github/workflows/preview-build.yml @@ -0,0 +1,70 @@ +name: preview-build + +on: + workflow_call: + inputs: + deployment-url: + description: Base URL of the calling repo's GitHub Pages site, no trailing slash (e.g. https://owner.github.io/repo). + required: true + type: string + +permissions: {} + +defaults: + run: + shell: bash + +jobs: + build: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Checkout PR code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + cache: npm + + - name: Setup Hugo + uses: peaceiris/actions-hugo@2752ce1d29631191ea3f27c23495fa06139a5b78 # v3.2.1 + with: + hugo-version: '0.158.0' + extended: true + + - name: Install dependencies + run: npm ci + + - name: Build preview + env: + BASE_URL: ${{ inputs.deployment-url }}/pr-preview/pr-${{ github.event.pull_request.number }}/ + run: | + set -euo pipefail + hugo \ + --cleanDestinationDir \ + --environment dev \ + --buildDrafts \ + --buildFuture \ + --buildExpired \ + --minify \ + --baseURL "$BASE_URL" + + - name: Upload preview artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: preview-site + path: public + retention-days: 1 + if-no-files-found: error diff --git a/.github/workflows/preview-clean.yml b/.github/workflows/preview-clean.yml new file mode 100644 index 00000000..89349c36 --- /dev/null +++ b/.github/workflows/preview-clean.yml @@ -0,0 +1,153 @@ +name: preview-clean + +on: + workflow_call: + inputs: + retention_limit: + description: Maximum number of PR previews to keep on gh-pages. + required: false + type: number + default: 6 + +permissions: {} + +defaults: + run: + shell: bash + +jobs: + clean: + if: contains(fromJSON('["pull_request_target", "schedule", "workflow_dispatch"]'), github.event_name) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write # Push removals to gh-pages. + pull-requests: write # Notify PRs whose preview was removed. + steps: + - name: Checkout gh-pages + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: gh-pages + fetch-depth: 0 + sparse-checkout: pr-preview + + - name: Reconcile previews + id: reconcile + env: + GH_TOKEN: ${{ github.token }} + GH_REPO_FULL: ${{ github.repository }} + RETENTION_LIMIT: ${{ inputs.retention_limit }} + run: | + set -euo pipefail + shopt -s nullglob + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + reconcile() { + git fetch origin gh-pages + git reset --hard origin/gh-pages + : > "${RUNNER_TEMP}/closed.txt" + : > "${RUNNER_TEMP}/pruned.txt" + + for dir in pr-preview/*/; do + name="$(basename "$dir")" + if [[ ! "$name" =~ ^pr-([0-9]+)$ ]]; then + echo "Removing unexpected entry: $dir" + rm -rf "$dir" + continue + fi + n="${BASH_REMATCH[1]}" + state="$(gh api "repos/${GH_REPO_FULL}/pulls/${n}" --jq .state 2>/dev/null || echo missing)" + if [[ "$state" != "open" ]]; then + echo "Removing preview for PR #${n} (state: ${state})" + rm -rf "$dir" + echo "$n" >> "${RUNNER_TEMP}/closed.txt" + fi + done + mapfile -t remaining < <( + for dir in pr-preview/pr-*/; do + name="$(basename "$dir")" + ts="$(git log -1 --format=%ct -- "pr-preview/$name" 2>/dev/null || echo 0)" + printf '%s %s\n' "$ts" "$name" + done | sort -nr | awk '{print $2}' + ) + if (( ${#remaining[@]} > RETENTION_LIMIT )); then + for name in "${remaining[@]:RETENTION_LIMIT}"; do + echo "Pruning ${name} to stay within retention limit ${RETENTION_LIMIT}" + rm -rf "pr-preview/${name}" + echo "${name#pr-}" >> "${RUNNER_TEMP}/pruned.txt" + done + fi + + git add -A pr-preview + if git diff --cached --quiet; then + echo "Nothing to clean." + return 0 + fi + git commit -m "clean up PR previews" + git push origin gh-pages + } + + ok="" + for attempt in 1 2 3 4 5; do + if reconcile; then ok=1; break; fi + echo "Push rejected (attempt ${attempt}); retrying against new tip" + sleep "$(( RANDOM % 4 + 1 ))" + done + [[ -n "$ok" ]] || { echo "Failed to clean after 5 attempts"; exit 1; } + + to_json() { grep -v '^$' "$1" 2>/dev/null | jq -R . | jq -sc . ; } + echo "closed_prs=$(to_json "${RUNNER_TEMP}/closed.txt")" >> "$GITHUB_OUTPUT" + echo "pruned_prs=$(to_json "${RUNNER_TEMP}/pruned.txt")" >> "$GITHUB_OUTPUT" + + - name: Notify affected pull requests + if: steps.reconcile.outputs.closed_prs != '[]' || steps.reconcile.outputs.pruned_prs != '[]' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + CLOSED_PRS: ${{ steps.reconcile.outputs.closed_prs }} + PRUNED_PRS: ${{ steps.reconcile.outputs.pruned_prs }} + RETENTION_LIMIT: ${{ inputs.retention_limit }} + with: + script: | + const marker = ''; + const upsert = async (prNumber, body) => { + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100, + }); + const existing = [...comments].reverse().find( + (c) => c.user?.type === 'Bot' && c.body?.includes(marker), + ); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + } + }; + const limit = process.env.RETENTION_LIMIT; + for (const pr of JSON.parse(process.env.CLOSED_PRS || '[]')) { + await upsert( + Number(pr), + `๐Ÿงน Preview removed โ€” this pull request is closed.\n${marker}`, + ); + } + for (const pr of JSON.parse(process.env.PRUNED_PRS || '[]')) { + await upsert( + Number(pr), + `๐Ÿงน Preview removed to keep only the ${limit} most recently ` + + `updated previews on GitHub Pages.\n\n` + + `Push a new commit to regenerate it.\n${marker}`, + ); + } diff --git a/.github/workflows/preview-cleanup-pr.yml b/.github/workflows/preview-cleanup-pr.yml new file mode 100644 index 00000000..3357163f --- /dev/null +++ b/.github/workflows/preview-cleanup-pr.yml @@ -0,0 +1,24 @@ +name: preview-cleanup-pr + +on: + pull_request_target: + branches: [master] + types: [closed] + workflow_dispatch: + +permissions: {} + +concurrency: + group: preview-cleanup-pr + cancel-in-progress: true + +jobs: + clean: + permissions: + contents: write + pull-requests: write + # Local reference for now; point at the central repo once published: + # uses: //.github/workflows/preview-clean.yml@ + uses: ./.github/workflows/preview-clean.yml + with: + retention_limit: 6 diff --git a/.github/workflows/preview-deploy-pr.yml b/.github/workflows/preview-deploy-pr.yml new file mode 100644 index 00000000..1cf15563 --- /dev/null +++ b/.github/workflows/preview-deploy-pr.yml @@ -0,0 +1,27 @@ +name: preview-deploy-pr + +on: + workflow_run: + workflows: [preview-build-pr] + types: [completed] + +permissions: {} + +concurrency: + group: preview-deploy-pr-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: true + +jobs: + deploy: + if: > + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success' + permissions: + contents: write + pull-requests: write + actions: read + # Local reference for now; point at the central repo once published: + # uses: //.github/workflows/preview-deploy.yml@ + uses: ./.github/workflows/preview-deploy.yml + with: + deployment-url: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }} diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml new file mode 100644 index 00000000..ab637e90 --- /dev/null +++ b/.github/workflows/preview-deploy.yml @@ -0,0 +1,153 @@ +name: preview-deploy + +on: + workflow_call: + inputs: + deployment-url: + description: Base URL of the calling repo's GitHub Pages site, no trailing slash (e.g. https://owner.github.io/repo). + required: true + type: string + +permissions: {} + +defaults: + run: + shell: bash + +jobs: + deploy: + if: github.event_name == 'workflow_run' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write # Push the preview to gh-pages. + pull-requests: write # Sticky preview-URL comment on the PR. + actions: read # Download the artifact from the triggering run. + steps: + - name: Resolve pull request for this build + id: pr + env: + GH_TOKEN: ${{ github.token }} + GH_REPO_FULL: ${{ github.repository }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + BASE_BRANCH: master + run: | + set -euo pipefail + [[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid head SHA"; exit 1; } + matches="$(gh api --paginate "repos/${GH_REPO_FULL}/pulls?state=open&per_page=100" \ + --jq '.[] | select(.head.sha == env.HEAD_SHA and .base.ref == env.BASE_BRANCH) | .number')" + number="$(printf '%s\n' "$matches" | head -n1)" + if [[ -z "$number" ]]; then + echo "::notice::No open PR against ${BASE_BRANCH} has head ${HEAD_SHA}; build is stale or PR closed. Skipping deploy." + fi + echo "number=${number}" >> "$GITHUB_OUTPUT" + + - name: Download preview artifact + if: steps.pr.outputs.number != '' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: preview-site + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + path: ${{ runner.temp }}/preview-site + + - name: Artifact sanity checks + if: steps.pr.outputs.number != '' + env: + SITE_DIR: ${{ runner.temp }}/preview-site + MAX_SITE_MB: 200 + run: | + set -euo pipefail + test -f "${SITE_DIR}/index.html" || { echo "No index.html at artifact root"; exit 1; } + if find "${SITE_DIR}" -type l | grep -q .; then + echo "Removing symlinks smuggled into the artifact:" + find "${SITE_DIR}" -type l -print -delete + fi + size_mb="$(du -sm "${SITE_DIR}" | cut -f1)" + if (( size_mb > MAX_SITE_MB )); then + echo "Artifact too large: ${size_mb}MB (limit ${MAX_SITE_MB}MB)"; exit 1 + fi + + - name: Checkout gh-pages + if: steps.pr.outputs.number != '' + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: gh-pages + fetch-depth: 1 + sparse-checkout: pr-preview + + - name: Publish preview + if: steps.pr.outputs.number != '' + env: + PR_NUMBER: ${{ steps.pr.outputs.number }} + SITE_DIR: ${{ runner.temp }}/preview-site + run: | + set -euo pipefail + [[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || { echo "Invalid PR number"; exit 1; } + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + publish() { + git fetch origin gh-pages + git reset --hard origin/gh-pages + target="pr-preview/pr-${PR_NUMBER}" + rm -rf "$target" + mkdir -p "$target" + cp -r "${SITE_DIR}/." "$target/" + git add -A pr-preview + if git diff --cached --quiet; then + echo "Preview unchanged; nothing to push." + return 0 + fi + git commit -m "deploy preview for PR #${PR_NUMBER}" + git push origin gh-pages + } + + for attempt in 1 2 3 4 5; do + if publish; then exit 0; fi + echo "Push rejected (attempt ${attempt}); retrying against new tip" + sleep "$(( RANDOM % 4 + 1 ))" + done + echo "Failed to publish after 5 attempts"; exit 1 + + - name: Comment preview URL + if: steps.pr.outputs.number != '' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + PR_NUMBER: ${{ steps.pr.outputs.number }} + DEPLOYMENT_URL: ${{ inputs.deployment-url }} + with: + script: | + const marker = ''; + const prNumber = Number(process.env.PR_NUMBER); + const url = `${process.env.DEPLOYMENT_URL}/pr-preview/pr-${prNumber}/`; + const body = [ + `๐Ÿš€ Preview: ${url}`, + '', + '> GitHub Pages may take a moment to publish โ€” refresh if it 404s.', + marker, + ].join('\n'); + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100, + }); + const existing = [...comments].reverse().find( + (c) => c.user?.type === 'Bot' && c.body?.includes(marker), + ); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + }