Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .github/workflows/docs-preview-build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# PR preview, stage 1 of 2: build (untrusted).
#
# Runs on `pull_request`, so for fork PRs it executes the contributor's
# content with a read-only GITHUB_TOKEN and no access to secrets — the only
# safe place to run untrusted markdown through the toolchain. It renders the
# docs into a static, script-free HTML snapshot and uploads it as an
# artifact. It deliberately CANNOT deploy or comment: that needs write
# permissions, which fork PRs must never get (this is also why none of this
# uses `pull_request_target` with a checkout of PR code — that combination
# hands the contributor a write token).
#
# Stage 2 (docs-preview-deploy.yml) runs in the trusted base-repo context on
# `workflow_run`, downloads the artifact, and publishes it to GitHub Pages.

name: Docs preview build

on:
pull_request:
paths:
- "docs/**"
- "archbee.json"
- "tools/preview/**"
- ".github/workflows/docs-preview-build.yml"

permissions:
contents: read

# A new push to the same PR supersedes the previous build.
concurrency:
group: docs-preview-build-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
steps:
# Default checkout for pull_request is the merge commit: the preview
# shows the PR as it would look merged into public-release.
- name: Checkout repository
uses: actions/checkout@v6

- name: Set up Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: npm
cache-dependency-path: tools/preview/package-lock.json

- name: Install Archbee CLI
run: npm install --global @archbee/cli

- name: Install snapshot tool dependencies
run: npm ci
working-directory: tools/preview

# Renders every page of the Archbee dev server in headless Chrome
# (preinstalled on ubuntu runners) and saves static HTML. All <script>
# tags and inline handlers are stripped, so the artifact contains no
# executable JS from the PR. See tools/preview/snapshot.mjs.
- name: Build static preview
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
node tools/preview/snapshot.mjs \
--out preview-out/site \
--banner "Docs preview: PR #${PR_NUMBER} @ ${HEAD_SHA:0:7}"

# The deploy workflow needs to know which PR this artifact belongs to.
# It treats this file as untrusted input and cross-checks it against
# the GitHub API before publishing.
- name: Record PR number
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: printf '%s\n' "$PR_NUMBER" > preview-out/pr-number.txt

- name: Upload preview artifact
uses: actions/upload-artifact@v4
with:
name: docs-preview
path: preview-out
retention-days: 7
if-no-files-found: error
81 changes: 81 additions & 0 deletions .github/workflows/docs-preview-cleanup.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# PR preview: remove the published preview when a PR is closed.
#
# Uses `pull_request_target` because fork PRs need it: the plain
# `pull_request` closed event runs with a read-only GITHUB_TOKEN for forks
# and could not delete from gh-pages. `pull_request_target` is safe HERE —
# and only here — because this workflow never checks out or executes
# anything from the PR; it only runs base-repo code against the gh-pages
# branch. Do not add a checkout of the PR head to this file.

name: Docs preview cleanup

on:
pull_request_target:
types: [closed]

permissions:
contents: write
pull-requests: write

jobs:
cleanup:
if: github.repository == 'flipperdevices/flipperone-docs'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Remove pr/<n> from gh-pages
id: rm
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
if ! git fetch --depth 1 origin gh-pages; then
echo "No gh-pages branch — nothing to clean up."
exit 0
fi
git switch -c gh-pages FETCH_HEAD
if [[ ! -e "pr/${PR_NUMBER}" ]]; then
echo "No preview for PR #${PR_NUMBER} — nothing to clean up."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git rm -r --quiet "pr/${PR_NUMBER}"
git commit -m "Remove preview for PR #${PR_NUMBER}"
# Shallow history: on a push race, redo the removal on the new tip
# instead of rebasing.
for _ in 1 2 3; do
git push origin HEAD:gh-pages && { echo "removed=true" >> "$GITHUB_OUTPUT"; exit 0; }
git fetch --depth 1 origin gh-pages
git reset --hard FETCH_HEAD
if [[ ! -e "pr/${PR_NUMBER}" ]]; then
echo "Preview already removed."
echo "removed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
git rm -r --quiet "pr/${PR_NUMBER}"
git commit -m "Remove preview for PR #${PR_NUMBER}"
done
echo "::error::could not push gh-pages after 3 attempts"
exit 1

- name: Mark the preview comment as expired
if: steps.rm.outputs.removed == 'true'
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const pr = context.payload.pull_request.number;
const marker = '<!-- docs-preview-comment -->';
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pr, per_page: 100,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner, repo, comment_id: existing.id,
body: `${marker}\n### 📖 Docs preview\n\nThe PR was closed and its preview has been removed.`,
});
}
182 changes: 182 additions & 0 deletions .github/workflows/docs-preview-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# PR preview, stage 2 of 2: deploy (trusted).
#
# Triggered by the completion of "Docs preview build" via `workflow_run`.
# This workflow always runs the code from public-release, never the PR's,
# so it is safe to give it write permissions. The only inputs taken from
# the untrusted build are (a) the rendered HTML, which is published as-is
# to GitHub Pages, and (b) the PR number, which is validated and then
# cross-checked against the GitHub API before anything is written.
#
# The published HTML is static and script-free (the build strips <script>
# and inline handlers): github.io project Pages of one org share a single
# browser origin, so untrusted JS there could script against other
# previews. Hosting untrusted *markup* on github.io — never on
# docs.flipper.net — keeps the blast radius at "an ugly page".
#
# Maintainers: this needs GitHub Pages enabled once, with
# "Deploy from a branch" -> gh-pages / (root). The branch is created
# automatically on first deploy.

name: Docs preview deploy

on:
workflow_run:
workflows: ["Docs preview build"]
types: [completed]

permissions:
contents: write # push to gh-pages
pull-requests: write # sticky preview comment
pages: read # resolve the Pages URL for the comment

jobs:
deploy:
# Guard against running in forks of this repo (their gh-pages would get
# spammed) and only deploy successful PR builds.
if: >-
github.repository == 'flipperdevices/flipperone-docs' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- name: Download preview artifact
uses: actions/download-artifact@v4
with:
name: docs-preview
path: preview-out
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}

# The artifact was produced by untrusted code, so the PR number in it
# could be anything. Accept it only if it is a plain number and the
# API confirms that PR's head SHA matches the commit this workflow_run
# was triggered for — an attacker cannot point their artifact at
# somebody else's PR.
- name: Validate PR number against the API
id: pr
env:
GH_TOKEN: ${{ github.token }}
RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
pr="$(tr -cd '0-9' < preview-out/pr-number.txt)"
[[ -n "$pr" ]] || { echo "::error::artifact carries no PR number"; exit 1; }
api="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr}" --jq '{sha: .head.sha, state: .state}')"
head_sha="$(jq -r .sha <<< "$api")"
state="$(jq -r .state <<< "$api")"
if [[ "$head_sha" != "$RUN_HEAD_SHA" ]]; then
echo "::error::PR #${pr} head ${head_sha} does not match run head ${RUN_HEAD_SHA}"
exit 1
fi
if [[ "$state" != "open" ]]; then
echo "PR #${pr} is ${state}; skipping deploy."
echo "skip=true" >> "$GITHUB_OUTPUT"
fi
echo "number=${pr}" >> "$GITHUB_OUTPUT"

- name: Checkout gh-pages
if: steps.pr.outputs.skip != 'true'
uses: actions/checkout@v6
with:
path: gh-pages
# The branch may not exist yet; fetch the default branch then and
# switch to an orphan gh-pages below.
ref: ${{ github.event.repository.default_branch }}

- name: Publish to gh-pages/pr/<n>
if: steps.pr.outputs.skip != 'true'
working-directory: gh-pages
env:
PR_NUMBER: ${{ steps.pr.outputs.number }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git fetch --depth 1 origin gh-pages; then
git switch -c gh-pages FETCH_HEAD
else
# First deploy ever: start an empty branch. switch --orphan keeps
# the old working tree around as untracked files — drop them so
# the docs sources don't get committed into gh-pages.
git switch --orphan gh-pages
git clean -fdx
fi
stage_preview() {
rm -rf "pr/${PR_NUMBER}"
mkdir -p "pr/${PR_NUMBER}"
cp -R ../preview-out/site/. "pr/${PR_NUMBER}/"
# Pages must serve _next/ CSS paths verbatim — disable Jekyll.
touch .nojekyll
git add -A
}
stage_preview
if git diff --cached --quiet; then
echo "Preview unchanged — nothing to push."
exit 0
fi
git commit -m "Preview for PR #${PR_NUMBER}"
# Another preview may push between our fetch and push. The history
# is shallow, so instead of rebasing, redo the staging on top of
# the new tip and try again.
for _ in 1 2 3; do
git push origin HEAD:gh-pages && exit 0
git fetch --depth 1 origin gh-pages
git reset --hard FETCH_HEAD
stage_preview
if git diff --cached --quiet; then
echo "Preview already up to date."
exit 0
fi
git commit -m "Preview for PR #${PR_NUMBER}"
done
echo "::error::could not push gh-pages after 3 attempts"
exit 1

- name: Comment preview link on the PR
if: steps.pr.outputs.skip != 'true'
uses: actions/github-script@v8
env:
PR_NUMBER: ${{ steps.pr.outputs.number }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
with:
script: |
const pr = Number(process.env.PR_NUMBER);
const sha = process.env.HEAD_SHA;
const { owner, repo } = context.repo;

// Prefer the real Pages URL (handles custom domains); fall back
// to the default github.io address if Pages is not enabled yet.
let base = `https://${owner}.github.io/${repo}/`;
let pagesNote = '';
try {
const pages = await github.request('GET /repos/{owner}/{repo}/pages', { owner, repo });
base = pages.data.html_url;
} catch {
pagesNote = '\n\n> [!NOTE]\n> GitHub Pages is not enabled for this repository yet, so the link ' +
'above will 404. A maintainer can enable it under Settings → Pages → ' +
'Deploy from a branch → `gh-pages` / `(root)`.';
}
const url = `${base.replace(/\/?$/, '/')}pr/${pr}/`;

const marker = '<!-- docs-preview-comment -->';
const body = [
marker,
`### 📖 Docs preview`,
'',
`**${url}**`,
'',
`Built from ${sha} — updated on every push. The preview is a static, script-free`,
`snapshot: search and other interactive features are disabled, and images that live`,
`on Archbee's CDN are loaded from production.`,
pagesNote,
].join('\n');

const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pr, per_page: 100,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: pr, body });
}
1 change: 1 addition & 0 deletions tools/preview/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules/
Loading
Loading