Add site preview gate for redesign PRs (#354)

Operator-approved; review comments resolved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jun 11, 2026 at 01:16 UTC 264b68954f76e9ca238d4e3f0d9f567af6cce88e
1 file changed +189
.github/workflows/site-preview.yml new
+189
@@ -0,0 +1,189 @@
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
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@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
59 + with:
60 + node-version: '20'
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@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
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 + }