| 1 | name: Site Preview (redesign PRs) |
| 2 | |
| 3 | # Preview gate for web / look-and-feel PRs (issue #354). |
| 4 | # Builds the Hugo site the same way as deploy-site.yml, then publishes the |
| 5 | # rendered site as a downloadable artifact and posts a PR comment explaining |
| 6 | # how reviewers and the operator can inspect the look-and-feel before merge. |
| 7 | # |
| 8 | # Security notes: |
| 9 | # - Triggered by `pull_request` (NOT pull_request_target). |
| 10 | # - The build job, which executes untrusted PR templates/layouts, runs with |
| 11 | # `contents: read` only and has no access to secrets. |
| 12 | # - The comment job does NOT check out PR code; it only posts a comment with |
| 13 | # `pull-requests: write` plus `issues: write` (PR comments go through the |
| 14 | # Issues API), keeping the privileged token away from PR content. |
| 15 | # - All actions are pinned to a full commit SHA; checkout uses |
| 16 | # persist-credentials: false; no `${{ }}` interpolation inside run blocks. |
| 17 | |
| 18 | on: |
| 19 | pull_request: |
| 20 | paths: |
| 21 | - 'layouts/**' |
| 22 | - 'assets/**' |
| 23 | - 'themes/**' |
| 24 | - 'content/**' |
| 25 | - 'static/**' |
| 26 | - 'config/**' |
| 27 | - 'hugo.toml' |
| 28 | - '.github/workflows/site-preview.yml' |
| 29 | |
| 30 | # Least privilege at the top level; jobs widen only what they need. |
| 31 | permissions: |
| 32 | contents: read # only needed to read repository contents for the build |
| 33 | |
| 34 | concurrency: |
| 35 | group: site-preview-${{ github.event.pull_request.number }} |
| 36 | cancel-in-progress: true |
| 37 | |
| 38 | jobs: |
| 39 | build-preview: |
| 40 | name: Build preview site |
| 41 | runs-on: ubuntu-latest |
| 42 | permissions: |
| 43 | contents: read # checkout PR sources; no secrets exposed to PR templates |
| 44 | env: |
| 45 | HUGO_VERSION: 0.161.1 |
| 46 | # Relative root base URL so the bundle is portable; reviewers serve it |
| 47 | # from a local web server (see the PR comment for instructions). |
| 48 | PREVIEW_BASEURL: "/" |
| 49 | steps: |
| 50 | - name: Check out repository |
| 51 | uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 |
| 52 | with: |
| 53 | fetch-depth: 0 |
| 54 | submodules: recursive |
| 55 | persist-credentials: false |
| 56 | |
| 57 | - name: Set up Node.js |
| 58 | uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 |
| 59 | with: |
| 60 | node-version: '24' |
| 61 | |
| 62 | - name: Install Hugo |
| 63 | run: | |
| 64 | set -euo pipefail |
| 65 | RELEASE_URL="https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}" |
| 66 | TARBALL="hugo_extended_${HUGO_VERSION}_linux-amd64.tar.gz" |
| 67 | CHECKSUM_FILE="hugo_${HUGO_VERSION}_checksums.txt" |
| 68 | curl --fail --silent --show-error --location --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors \ |
| 69 | --output "${TARBALL}" "${RELEASE_URL}/${TARBALL}" |
| 70 | |
| 71 | # Download and verify checksums (same hardening as deploy-site.yml). |
| 72 | curl --fail --silent --show-error --location --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors \ |
| 73 | --output "${CHECKSUM_FILE}" "${RELEASE_URL}/${CHECKSUM_FILE}" |
| 74 | checksum_line="$(awk -v file="${TARBALL}" '$NF == file {print; found=1} END {if (!found) exit 1}' "${CHECKSUM_FILE}")" || { |
| 75 | echo "Error: No checksum entry for ${TARBALL} found in ${CHECKSUM_FILE}" >&2 |
| 76 | exit 1 |
| 77 | } |
| 78 | if ! printf '%s\n' "${checksum_line}" | sha256sum --check; then |
| 79 | echo "Error: Checksum verification failed for ${TARBALL}" >&2 |
| 80 | exit 1 |
| 81 | fi |
| 82 | rm "${CHECKSUM_FILE}" |
| 83 | |
| 84 | mkdir -p "${HOME}/.local/hugo" |
| 85 | tar -C "${HOME}/.local/hugo" -xf "${TARBALL}" |
| 86 | rm "${TARBALL}" |
| 87 | echo "${HOME}/.local/hugo" >> "${GITHUB_PATH}" |
| 88 | export PATH="${HOME}/.local/hugo:${PATH}" |
| 89 | hugo version |
| 90 | |
| 91 | - name: Build preview site |
| 92 | # Same minify flag as production, but with a preview baseURL and with |
| 93 | # drafts + future-dated content included so reviewers see everything. |
| 94 | run: | |
| 95 | set -euo pipefail |
| 96 | hugo --gc --minify --buildDrafts --buildFuture --baseURL "${PREVIEW_BASEURL}" |
| 97 | |
| 98 | - name: Build Pagefind search index |
| 99 | run: npx pagefind --site public/ |
| 100 | |
| 101 | - name: Upload preview site artifact |
| 102 | uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 |
| 103 | with: |
| 104 | name: site-preview |
| 105 | path: ./public |
| 106 | retention-days: 14 |
| 107 | if-no-files-found: error |
| 108 | |
| 109 | - name: Write preview summary |
| 110 | run: | |
| 111 | set -euo pipefail |
| 112 | { |
| 113 | echo "## 🔎 Site preview built" |
| 114 | echo "" |
| 115 | echo "Hugo extended **${HUGO_VERSION}** build succeeded (drafts + future content included)." |
| 116 | echo "" |
| 117 | echo "Download the **\`site-preview\`** artifact from this run, unzip it, then serve it:" |
| 118 | echo "" |
| 119 | echo '```bash' |
| 120 | echo "unzip site-preview.zip -d site-preview && cd site-preview" |
| 121 | echo "python3 -m http.server 8080 # then open http://localhost:8080/" |
| 122 | echo '```' |
| 123 | echo "" |
| 124 | echo "### Reviewer checklist" |
| 125 | echo "- [ ] Homepage renders correctly" |
| 126 | echo "- [ ] Latest weekly article renders correctly" |
| 127 | echo "- [ ] Representative archive / rollup (monthly, yearly, topics) pages render" |
| 128 | echo "- [ ] Navigation menu, search page, and footer look correct" |
| 129 | echo "- [ ] Light + dark themes both look correct" |
| 130 | echo "- [ ] Mobile widths checked: 320 / 360 / 390 / 414 / 768 px" |
| 131 | echo "- [ ] Screenshots attached to the PR for the operator / Fry / Calculon review" |
| 132 | } >> "${GITHUB_STEP_SUMMARY}" |
| 133 | |
| 134 | comment: |
| 135 | name: Post preview instructions |
| 136 | needs: build-preview |
| 137 | runs-on: ubuntu-latest |
| 138 | permissions: |
| 139 | pull-requests: write # post/update the preview instructions comment |
| 140 | issues: write # PR comments are created via the Issues API (listComments/createComment/updateComment) |
| 141 | steps: |
| 142 | - name: Post or update preview comment |
| 143 | uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 |
| 144 | env: |
| 145 | RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} |
| 146 | with: |
| 147 | script: | |
| 148 | const marker = '<!-- site-preview-bot -->'; |
| 149 | const runUrl = process.env.RUN_URL; |
| 150 | const prNumber = context.payload.pull_request.number; |
| 151 | const body = [ |
| 152 | marker, |
| 153 | '## 🔎 Site preview ready', |
| 154 | '', |
| 155 | 'A rendered preview of this PR was built with Hugo extended (drafts + future content included).', |
| 156 | '', |
| 157 | `**Download it:** open the [workflow run](${runUrl}) → **Artifacts** → \`site-preview\`.`, |
| 158 | '', |
| 159 | 'Then serve and open it locally:', |
| 160 | '', |
| 161 | '```bash', |
| 162 | 'unzip site-preview.zip -d site-preview && cd site-preview', |
| 163 | 'python3 -m http.server 8080 # then open http://localhost:8080/', |
| 164 | '```', |
| 165 | '', |
| 166 | '_(Links are root-relative, so open it via a local web server rather than `file://`.)_', |
| 167 | '', |
| 168 | '### Reviewer checklist', |
| 169 | '- [ ] Homepage renders correctly', |
| 170 | '- [ ] Latest weekly article renders correctly', |
| 171 | '- [ ] Representative archive / rollup (monthly, yearly, topics) pages render', |
| 172 | '- [ ] Navigation menu, search page, and footer look correct', |
| 173 | '- [ ] Light + dark themes both look correct', |
| 174 | '- [ ] Mobile widths checked: 320 / 360 / 390 / 414 / 768 px', |
| 175 | '- [ ] Screenshots attached for the operator / Fry / Calculon review', |
| 176 | ].join('\n'); |
| 177 | |
| 178 | const { owner, repo } = context.repo; |
| 179 | const comments = await github.paginate(github.rest.issues.listComments, { |
| 180 | owner, repo, issue_number: prNumber, per_page: 100, |
| 181 | }); |
| 182 | const existing = comments.find(c => c.body && c.body.includes(marker)); |
| 183 | if (existing) { |
| 184 | await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); |
| 185 | core.info(`Updated preview comment ${existing.id}`); |
| 186 | } else { |
| 187 | await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body }); |
| 188 | core.info('Created preview comment'); |
| 189 | } |