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
+ }