Skip to content

Commit 78f1b0f

Browse files
committed
Add static PR previews on GitHub Pages
Reviewing a docs PR currently means running the contributor's markdown locally with archbee dev, which executes untrusted content in the reviewer's browser (#340). This adds an automated preview instead: * tools/preview/snapshot.mjs renders every page of the archbee dev server in headless Chrome and saves a static, script-free copy. There is no static-export command in the Archbee CLI and fetching pages plainly returns an empty SPA shell, so a real browser does the rendering. All script tags and inline handlers are stripped and all root-absolute URLs are rewritten relative, so the snapshot can be served from any subpath and carries no executable JS from the PR. * docs-preview-build.yml (pull_request) builds that snapshot with a read-only token and uploads it as an artifact. It never sees secrets, which is what makes it safe to run on fork PRs. * docs-preview-deploy.yml (workflow_run) runs trusted code only, validates the artifact's PR number against the API, publishes the snapshot to gh-pages under pr/<n>/, and maintains a sticky PR comment with the preview link. * docs-preview-cleanup.yml (pull_request_target, closed) deletes pr/<n>/ from gh-pages. It never checks out PR code, which is the one condition under which pull_request_target is safe. Maintainers need to enable Pages once: deploy from branch, gh-pages, root. The gh-pages branch is created on first deploy.
1 parent ef305d1 commit 78f1b0f

7 files changed

Lines changed: 1704 additions & 0 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# PR preview, stage 1 of 2: build (untrusted).
2+
#
3+
# Runs on `pull_request`, so for fork PRs it executes the contributor's
4+
# content with a read-only GITHUB_TOKEN and no access to secrets — the only
5+
# safe place to run untrusted markdown through the toolchain. It renders the
6+
# docs into a static, script-free HTML snapshot and uploads it as an
7+
# artifact. It deliberately CANNOT deploy or comment: that needs write
8+
# permissions, which fork PRs must never get (this is also why none of this
9+
# uses `pull_request_target` with a checkout of PR code — that combination
10+
# hands the contributor a write token).
11+
#
12+
# Stage 2 (docs-preview-deploy.yml) runs in the trusted base-repo context on
13+
# `workflow_run`, downloads the artifact, and publishes it to GitHub Pages.
14+
15+
name: Docs preview build
16+
17+
on:
18+
pull_request:
19+
paths:
20+
- "docs/**"
21+
- "archbee.json"
22+
- "tools/preview/**"
23+
- ".github/workflows/docs-preview-build.yml"
24+
25+
permissions:
26+
contents: read
27+
28+
# A new push to the same PR supersedes the previous build.
29+
concurrency:
30+
group: docs-preview-build-${{ github.event.pull_request.number }}
31+
cancel-in-progress: true
32+
33+
jobs:
34+
build:
35+
runs-on: ubuntu-latest
36+
steps:
37+
# Default checkout for pull_request is the merge commit: the preview
38+
# shows the PR as it would look merged into public-release.
39+
- name: Checkout repository
40+
uses: actions/checkout@v6
41+
42+
- name: Set up Node.js
43+
uses: actions/setup-node@v5
44+
with:
45+
node-version: 22
46+
cache: npm
47+
cache-dependency-path: tools/preview/package-lock.json
48+
49+
- name: Install Archbee CLI
50+
run: npm install --global @archbee/cli
51+
52+
- name: Install snapshot tool dependencies
53+
run: npm ci
54+
working-directory: tools/preview
55+
56+
# Renders every page of the Archbee dev server in headless Chrome
57+
# (preinstalled on ubuntu runners) and saves static HTML. All <script>
58+
# tags and inline handlers are stripped, so the artifact contains no
59+
# executable JS from the PR. See tools/preview/snapshot.mjs.
60+
- name: Build static preview
61+
env:
62+
PR_NUMBER: ${{ github.event.pull_request.number }}
63+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
64+
run: |
65+
node tools/preview/snapshot.mjs \
66+
--out preview-out/site \
67+
--banner "Docs preview: PR #${PR_NUMBER} @ ${HEAD_SHA:0:7}"
68+
69+
# The deploy workflow needs to know which PR this artifact belongs to.
70+
# It treats this file as untrusted input and cross-checks it against
71+
# the GitHub API before publishing.
72+
- name: Record PR number
73+
env:
74+
PR_NUMBER: ${{ github.event.pull_request.number }}
75+
run: printf '%s\n' "$PR_NUMBER" > preview-out/pr-number.txt
76+
77+
- name: Upload preview artifact
78+
uses: actions/upload-artifact@v4
79+
with:
80+
name: docs-preview
81+
path: preview-out
82+
retention-days: 7
83+
if-no-files-found: error
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# PR preview: remove the published preview when a PR is closed.
2+
#
3+
# Uses `pull_request_target` because fork PRs need it: the plain
4+
# `pull_request` closed event runs with a read-only GITHUB_TOKEN for forks
5+
# and could not delete from gh-pages. `pull_request_target` is safe HERE —
6+
# and only here — because this workflow never checks out or executes
7+
# anything from the PR; it only runs base-repo code against the gh-pages
8+
# branch. Do not add a checkout of the PR head to this file.
9+
10+
name: Docs preview cleanup
11+
12+
on:
13+
pull_request_target:
14+
types: [closed]
15+
16+
permissions:
17+
contents: write
18+
pull-requests: write
19+
20+
jobs:
21+
cleanup:
22+
if: github.repository == 'flipperdevices/flipperone-docs'
23+
runs-on: ubuntu-latest
24+
steps:
25+
- name: Checkout repository
26+
uses: actions/checkout@v6
27+
28+
- name: Remove pr/<n> from gh-pages
29+
id: rm
30+
env:
31+
PR_NUMBER: ${{ github.event.pull_request.number }}
32+
run: |
33+
set -euo pipefail
34+
if ! git fetch --depth 1 origin gh-pages; then
35+
echo "No gh-pages branch — nothing to clean up."
36+
exit 0
37+
fi
38+
git switch -c gh-pages FETCH_HEAD
39+
if [[ ! -e "pr/${PR_NUMBER}" ]]; then
40+
echo "No preview for PR #${PR_NUMBER} — nothing to clean up."
41+
exit 0
42+
fi
43+
git config user.name "github-actions[bot]"
44+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
45+
git rm -r --quiet "pr/${PR_NUMBER}"
46+
git commit -m "Remove preview for PR #${PR_NUMBER}"
47+
# Shallow history: on a push race, redo the removal on the new tip
48+
# instead of rebasing.
49+
for _ in 1 2 3; do
50+
git push origin HEAD:gh-pages && { echo "removed=true" >> "$GITHUB_OUTPUT"; exit 0; }
51+
git fetch --depth 1 origin gh-pages
52+
git reset --hard FETCH_HEAD
53+
if [[ ! -e "pr/${PR_NUMBER}" ]]; then
54+
echo "Preview already removed."
55+
echo "removed=true" >> "$GITHUB_OUTPUT"
56+
exit 0
57+
fi
58+
git rm -r --quiet "pr/${PR_NUMBER}"
59+
git commit -m "Remove preview for PR #${PR_NUMBER}"
60+
done
61+
echo "::error::could not push gh-pages after 3 attempts"
62+
exit 1
63+
64+
- name: Mark the preview comment as expired
65+
if: steps.rm.outputs.removed == 'true'
66+
uses: actions/github-script@v8
67+
with:
68+
script: |
69+
const { owner, repo } = context.repo;
70+
const pr = context.payload.pull_request.number;
71+
const marker = '<!-- docs-preview-comment -->';
72+
const comments = await github.paginate(github.rest.issues.listComments, {
73+
owner, repo, issue_number: pr, per_page: 100,
74+
});
75+
const existing = comments.find(c => c.body && c.body.includes(marker));
76+
if (existing) {
77+
await github.rest.issues.updateComment({
78+
owner, repo, comment_id: existing.id,
79+
body: `${marker}\n### 📖 Docs preview\n\nThe PR was closed and its preview has been removed.`,
80+
});
81+
}
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
# PR preview, stage 2 of 2: deploy (trusted).
2+
#
3+
# Triggered by the completion of "Docs preview build" via `workflow_run`.
4+
# This workflow always runs the code from public-release, never the PR's,
5+
# so it is safe to give it write permissions. The only inputs taken from
6+
# the untrusted build are (a) the rendered HTML, which is published as-is
7+
# to GitHub Pages, and (b) the PR number, which is validated and then
8+
# cross-checked against the GitHub API before anything is written.
9+
#
10+
# The published HTML is static and script-free (the build strips <script>
11+
# and inline handlers): github.io project Pages of one org share a single
12+
# browser origin, so untrusted JS there could script against other
13+
# previews. Hosting untrusted *markup* on github.io — never on
14+
# docs.flipper.net — keeps the blast radius at "an ugly page".
15+
#
16+
# Maintainers: this needs GitHub Pages enabled once, with
17+
# "Deploy from a branch" -> gh-pages / (root). The branch is created
18+
# automatically on first deploy.
19+
20+
name: Docs preview deploy
21+
22+
on:
23+
workflow_run:
24+
workflows: ["Docs preview build"]
25+
types: [completed]
26+
27+
permissions:
28+
contents: write # push to gh-pages
29+
pull-requests: write # sticky preview comment
30+
pages: read # resolve the Pages URL for the comment
31+
32+
jobs:
33+
deploy:
34+
# Guard against running in forks of this repo (their gh-pages would get
35+
# spammed) and only deploy successful PR builds.
36+
if: >-
37+
github.repository == 'flipperdevices/flipperone-docs' &&
38+
github.event.workflow_run.event == 'pull_request' &&
39+
github.event.workflow_run.conclusion == 'success'
40+
runs-on: ubuntu-latest
41+
steps:
42+
- name: Download preview artifact
43+
uses: actions/download-artifact@v4
44+
with:
45+
name: docs-preview
46+
path: preview-out
47+
run-id: ${{ github.event.workflow_run.id }}
48+
github-token: ${{ github.token }}
49+
50+
# The artifact was produced by untrusted code, so the PR number in it
51+
# could be anything. Accept it only if it is a plain number and the
52+
# API confirms that PR's head SHA matches the commit this workflow_run
53+
# was triggered for — an attacker cannot point their artifact at
54+
# somebody else's PR.
55+
- name: Validate PR number against the API
56+
id: pr
57+
env:
58+
GH_TOKEN: ${{ github.token }}
59+
RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
60+
run: |
61+
set -euo pipefail
62+
pr="$(tr -cd '0-9' < preview-out/pr-number.txt)"
63+
[[ -n "$pr" ]] || { echo "::error::artifact carries no PR number"; exit 1; }
64+
api="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr}" --jq '{sha: .head.sha, state: .state}')"
65+
head_sha="$(jq -r .sha <<< "$api")"
66+
state="$(jq -r .state <<< "$api")"
67+
if [[ "$head_sha" != "$RUN_HEAD_SHA" ]]; then
68+
echo "::error::PR #${pr} head ${head_sha} does not match run head ${RUN_HEAD_SHA}"
69+
exit 1
70+
fi
71+
if [[ "$state" != "open" ]]; then
72+
echo "PR #${pr} is ${state}; skipping deploy."
73+
echo "skip=true" >> "$GITHUB_OUTPUT"
74+
fi
75+
echo "number=${pr}" >> "$GITHUB_OUTPUT"
76+
77+
- name: Checkout gh-pages
78+
if: steps.pr.outputs.skip != 'true'
79+
uses: actions/checkout@v6
80+
with:
81+
path: gh-pages
82+
# The branch may not exist yet; fetch the default branch then and
83+
# switch to an orphan gh-pages below.
84+
ref: ${{ github.event.repository.default_branch }}
85+
86+
- name: Publish to gh-pages/pr/<n>
87+
if: steps.pr.outputs.skip != 'true'
88+
working-directory: gh-pages
89+
env:
90+
PR_NUMBER: ${{ steps.pr.outputs.number }}
91+
run: |
92+
set -euo pipefail
93+
git config user.name "github-actions[bot]"
94+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
95+
if git fetch --depth 1 origin gh-pages; then
96+
git switch -c gh-pages FETCH_HEAD
97+
else
98+
# First deploy ever: start an empty branch. switch --orphan keeps
99+
# the old working tree around as untracked files — drop them so
100+
# the docs sources don't get committed into gh-pages.
101+
git switch --orphan gh-pages
102+
git clean -fdx
103+
fi
104+
stage_preview() {
105+
rm -rf "pr/${PR_NUMBER}"
106+
mkdir -p "pr/${PR_NUMBER}"
107+
cp -R ../preview-out/site/. "pr/${PR_NUMBER}/"
108+
# Pages must serve _next/ CSS paths verbatim — disable Jekyll.
109+
touch .nojekyll
110+
git add -A
111+
}
112+
stage_preview
113+
if git diff --cached --quiet; then
114+
echo "Preview unchanged — nothing to push."
115+
exit 0
116+
fi
117+
git commit -m "Preview for PR #${PR_NUMBER}"
118+
# Another preview may push between our fetch and push. The history
119+
# is shallow, so instead of rebasing, redo the staging on top of
120+
# the new tip and try again.
121+
for _ in 1 2 3; do
122+
git push origin HEAD:gh-pages && exit 0
123+
git fetch --depth 1 origin gh-pages
124+
git reset --hard FETCH_HEAD
125+
stage_preview
126+
if git diff --cached --quiet; then
127+
echo "Preview already up to date."
128+
exit 0
129+
fi
130+
git commit -m "Preview for PR #${PR_NUMBER}"
131+
done
132+
echo "::error::could not push gh-pages after 3 attempts"
133+
exit 1
134+
135+
- name: Comment preview link on the PR
136+
if: steps.pr.outputs.skip != 'true'
137+
uses: actions/github-script@v8
138+
env:
139+
PR_NUMBER: ${{ steps.pr.outputs.number }}
140+
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
141+
with:
142+
script: |
143+
const pr = Number(process.env.PR_NUMBER);
144+
const sha = process.env.HEAD_SHA;
145+
const { owner, repo } = context.repo;
146+
147+
// Prefer the real Pages URL (handles custom domains); fall back
148+
// to the default github.io address if Pages is not enabled yet.
149+
let base = `https://${owner}.github.io/${repo}/`;
150+
let pagesNote = '';
151+
try {
152+
const pages = await github.request('GET /repos/{owner}/{repo}/pages', { owner, repo });
153+
base = pages.data.html_url;
154+
} catch {
155+
pagesNote = '\n\n> [!NOTE]\n> GitHub Pages is not enabled for this repository yet, so the link ' +
156+
'above will 404. A maintainer can enable it under Settings → Pages → ' +
157+
'Deploy from a branch → `gh-pages` / `(root)`.';
158+
}
159+
const url = `${base.replace(/\/?$/, '/')}pr/${pr}/`;
160+
161+
const marker = '<!-- docs-preview-comment -->';
162+
const body = [
163+
marker,
164+
`### 📖 Docs preview`,
165+
'',
166+
`**${url}**`,
167+
'',
168+
`Built from ${sha} — updated on every push. The preview is a static, script-free`,
169+
`snapshot: search and other interactive features are disabled, and images that live`,
170+
`on Archbee's CDN are loaded from production.`,
171+
pagesNote,
172+
].join('\n');
173+
174+
const comments = await github.paginate(github.rest.issues.listComments, {
175+
owner, repo, issue_number: pr, per_page: 100,
176+
});
177+
const existing = comments.find(c => c.body && c.body.includes(marker));
178+
if (existing) {
179+
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
180+
} else {
181+
await github.rest.issues.createComment({ owner, repo, issue_number: pr, body });
182+
}

tools/preview/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules/

0 commit comments

Comments
 (0)