Skip to content

feat(admins): soft-retire, durable issuer names, and bulk actions (#1509) #135

feat(admins): soft-retire, durable issuer names, and bulk actions (#1509)

feat(admins): soft-retire, durable issuer names, and bulk actions (#1509) #135

# docs-screenshots-capture — boot the dev stack, capture installer +
# panel screenshots, and commit the diff back to the PR branch.
#
# Capture geometry (driven by docs/scripts/capture.mjs):
#
# - Viewport: 1920×1080 (Full HD), with `fullPage: true` so the
# entire scrollable surface lands in the PNG.
# - Panel routes are captured TWICE — once in light mode and once
# in dark — and emit per-theme PNGs (e.g.
# `panel-02-dashboard-light.png`, `panel-02-dashboard-dark.png`).
# The dark pass seeds `localStorage['sbpp-theme']` via Playwright's
# `addInitScript` so the inline FOUC bootloader in
# `core/header.tpl` lands `<html class="dark">` on the first
# paint (no white flash). See AGENTS.md "Anti-FOUC theme
# bootloader" for the contract.
# - Install routes are captured ONCE in the :root light default.
# The wizard's `_chrome.tpl` doesn't load `theme.js` or the FOUC
# bootloader (the wizard has no theme toggle by design; see
# AGENTS.md "Install wizard").
#
# SECURITY MODEL — read this before changing the workflow.
#
# `pull_request_target` runs with the upstream repo's GITHUB_TOKEN
# regardless of where the PR comes from. A naive checkout of the PR
# head followed by `npm ci` would let any contributor exfil that token
# through a postinstall script or a tweaked capture.mjs. The defenses
# below — split-checkout, label gate, fork rejection, label-strip on
# push — are load-bearing; do not relax them without a paired threat
# model in the PR description.
#
# 1. The trusted code surface (the capture script itself, its
# package-lock.json, and the Playwright dep tree) is checked out
# from the PR's BASE branch (effectively `main`) into `trusted/`.
# `npm ci` runs there — postinstall scripts come from main, never
# from the PR diff.
#
# 2. The PR head is checked out into a separate `pr-head/` directory.
# We use it for two and only two things: (a) `docker compose up`
# so the panel under test reflects the PR's UI changes, and (b)
# as the destination for the captured PNG diff (the auto-commit
# action pushes from there). NO node code from this checkout is
# executed on the runner.
#
# 3. The capture script reads `CAPTURE_OUT_OVERRIDE` and writes its
# PNG outputs into `pr-head/docs/src/assets/auto/`. The auto-
# commit action then pushes from that working directory. Because
# the *script* is the trusted-from-main copy, a malicious PR
# can't redirect the writes elsewhere or run a different binary.
#
# 4. The `safe-to-screenshot` label is the maintainer ack. The
# `unlabel-on-synchronize` job below removes it on every new
# push so a maintainer must re-apply after reviewing each new
# commit. Bare opens (no label) and fork PRs short-circuit out.
#
# 5. Fork PRs are rejected via the
# `head.repo.full_name == github.repository` check. Auto-commit
# back into a fork PR via `pull_request_target` is the canonical
# "compromise the upstream maintainer's token" shape; if the
# contribution genuinely needs screenshot regeneration, push the
# branch into the upstream repo first and the workflow will run.
#
# Required repo configuration BEFORE this workflow can succeed:
#
# - The `safe-to-screenshot` label must exist (one-time setup).
# Maintainers apply it after reviewing the PR diff; the
# `unlabel-on-synchronize` job auto-removes it on every push so
# re-application is explicit per commit set.
#
# Pair with: .github/workflows/docs-screenshots-build.yml (the
# untrusted-side parse / install verification that runs on every PR).
name: docs-screenshots-capture
on:
pull_request_target:
types: [labeled, synchronize, reopened]
workflow_dispatch:
# Serialize per-PR so two pushes in quick succession don't race on the
# same set of PNGs.
concurrency:
group: docs-screenshots-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
jobs:
# Strip the `safe-to-screenshot` label whenever a new commit lands so
# a maintainer must re-apply after reviewing the new code. Without
# this, a contributor could land a benign-looking PR, get the label
# applied, and then push a malicious follow-up commit that runs with
# the originally-granted ack.
unlabel-on-synchronize:
name: Strip safe-to-screenshot on push
if: github.event_name == 'pull_request_target' && github.event.action == 'synchronize'
runs-on: ubuntu-24.04
permissions:
pull-requests: write
steps:
- name: Remove safe-to-screenshot label
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api -X DELETE \
"repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels/safe-to-screenshot" \
|| echo "label was not present; nothing to remove"
capture:
name: Capture installer + panel screenshots
# Three gates, all required:
# 1. The PR carries `safe-to-screenshot` (maintainer ack), OR
# the workflow was triggered manually via workflow_dispatch.
# 2. The PR is from the same repo (no forks — see security note
# above).
# 3. (Implicit) The unlabel-on-synchronize job ran first when
# this is a `synchronize` event; the label gate then catches
# the now-stripped state.
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request_target' &&
contains(github.event.pull_request.labels.*.name, 'safe-to-screenshot') &&
github.event.pull_request.head.repo.full_name == github.repository)
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: write
pull-requests: read
steps:
# TRUSTED checkout — the capture script + lockfile come from the
# PR's base branch (effectively `main`). All `npm ci` postinstall
# hooks run against this tree, NEVER against the PR head.
- name: Checkout trusted code from base branch
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.sha || github.sha }}
path: trusted
# We run scripts from this tree but never push from it; no
# need to persist the checkout token.
persist-credentials: false
# PR HEAD checkout — we need this for `docker compose up` (the
# panel under test) and as the destination for captured PNGs
# (the auto-commit action pushes from here). No JS/PHP code from
# this tree is executed on the runner; the PHP runs inside the
# docker `web` container.
#
# Canonical fork-PR checkout shape (per the auto-commit action's
# docs): explicit `repository` + `ref: head_ref` + persist creds.
# The `if` gate above already rejects fork PRs, so in practice
# `head.repo.full_name == github.repository`, but keeping the
# canonical shape means a future change to the gate (e.g. opt-in
# fork support) doesn't silently break the push.
- name: Checkout PR head (docker stack + screenshot output dir)
uses: actions/checkout@v4
with:
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
ref: ${{ github.head_ref || github.ref_name }}
path: pr-head
persist-credentials: true
- name: Setup Node
uses: actions/setup-node@v4
with:
# Astro 6 requires Node >=22.12; mirrors the bump in
# docs-build.yml that landed with the docs astro+starlight
# group bump (#1346).
node-version: '22'
cache: 'npm'
cache-dependency-path: trusted/docs/package-lock.json
# Install deps + the chromium browser from the TRUSTED tree.
# Postinstall scripts in any package come from the trusted
# lockfile — no PR-side override possible.
- name: Install docs deps + Playwright chromium (trusted tree)
working-directory: trusted/docs
run: |
npm ci --no-audit --no-fund
npx playwright install --with-deps chromium
# Boot the stack from the PR head — docker-compose / Dockerfile
# changes need to flow through here. The runner's privileged
# token is NOT mounted into the container; the PHP code runs
# sandboxed by docker.
- name: Bring up the dev stack (PR-head docker-compose)
working-directory: pr-head
run: docker compose up -d --wait
- name: Wait for the panel
run: |
for i in {1..30}; do
code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8080/index.php?p=login || true)
if [[ "$code" == "200" ]]; then
echo "panel ready after attempt $i"
exit 0
fi
echo "attempt $i: HTTP $code, retrying"
sleep 3
done
echo "panel never came up" >&2
(cd pr-head && docker compose logs --tail=200 web db)
exit 1
# Populate the dev DB with realistic synthetic data so the
# post-install panel routes (Servers, Banlist, Dashboard)
# screenshot the populated state, not first-run empty
# placeholders. The seeder runs INSIDE the docker `web`
# container (sbpp.sh shells in via `docker compose exec`),
# so it's sandboxed from the runner's filesystem.
- name: Seed dev DB
working-directory: pr-head
run: ./sbpp.sh db-seed
# Run the TRUSTED capture script. Outputs land in the PR head's
# docs/src/assets/auto/ via CAPTURE_OUT_OVERRIDE so the auto-
# commit step picks them up below.
#
# Two passes — one per route group — because the install wizard
# and the post-install panel need opposite states of
# `web/config.php`:
#
# - The panel needs `config.php` to bootstrap (without it
# `web/init.php` redirects every panel URL to `/install/`).
# - The install wizard's #1335 C2 guard refuses to start over
# a panel where `config.php` exists (it renders a static
# 409 "already installed" page instead).
#
# The dev stack's entrypoint (`docker/php/web-entrypoint.sh`)
# creates `config.php` on first boot, so we land in the
# "panel works, wizard refuses" state by default. The dance
# below moves the file aside on the host (the bind-mounted
# `./web` means the container sees the rename immediately) for
# the duration of the install captures, then restores it.
#
# The panel pass loops both `light` and `dark` themes inside
# `capture.mjs` (one browser context per theme; localStorage
# seeded before navigation so the FOUC bootloader picks dark
# up on the first paint). See the workflow header comment
# for the per-theme output naming.
- name: Capture panel screenshots (light + dark, 1920×1080 full-page)
working-directory: trusted/docs
env:
STEAM_API_KEY: '00000000000000000000000000000000'
PANEL_URL: 'http://localhost:8080'
CAPTURE_OUT_OVERRIDE: ${{ github.workspace }}/pr-head/docs/src/assets/auto
CAPTURE_ROUTES: panel
run: npm run capture
# Stash to a sibling path inside `pr-head/` (alongside `web/`),
# NOT to `/tmp`. The dev container's entrypoint runs as root, so
# `web/config.php` lands on the bind-mounted host filesystem
# owned `root:root`. On GHA ubuntu-24.04 runners `/tmp` is on
# the same filesystem as the workspace, so `mv web/config.php
# /tmp/...` is a same-fs atomic `rename(2)` that PRESERVES
# ownership — and `/tmp` carries the sticky bit, so the runner
# (UID 1001) cannot move the still-root-owned file back later
# (per `rename(2)`: "EPERM: the directory containing oldpath
# has the sticky bit set and the process's effective UID is
# neither the UID of the file to be deleted nor that of the
# directory containing it"). PR 1430 was the first run after
# #1427 to actually exercise the stash/restore pair and hit
# this trap (`mv: cannot move '/tmp/sbpp-config-stash.php' to
# 'web/config.php': Operation not permitted`).
#
# `pr-head/.sbpp-config-stash.php` is a runner-owned, sticky-
# bit-free directory on the same filesystem as `pr-head/web/`,
# so the rename is atomic both ways regardless of which UID
# the docker container created the file as. The auto-commit
# step further down constrains the commit to `docs/src/assets/auto/**`,
# so a stale stash file (if the restore somehow no-ops) never
# leaks into the PR diff.
- name: Stash config.php to expose the install wizard
working-directory: pr-head
run: |
if [[ -f web/config.php ]]; then
mv web/config.php .sbpp-config-stash.php
echo "stashed web/config.php → pr-head/.sbpp-config-stash.php"
else
echo "web/config.php was already absent; nothing to stash"
fi
# Install pass is light-only — the wizard's `_chrome.tpl`
# doesn't load `theme.js` or the FOUC bootloader, so a "dark
# install" capture would just be the same light render with a
# different filename (see the workflow header comment).
- name: Capture install screenshots (light only, 1920×1080 full-page)
working-directory: trusted/docs
env:
STEAM_API_KEY: '00000000000000000000000000000000'
PANEL_URL: 'http://localhost:8080'
CAPTURE_OUT_OVERRIDE: ${{ github.workspace }}/pr-head/docs/src/assets/auto
CAPTURE_ROUTES: install
run: npm run capture
# Restore is `if: always()` so a failed install capture (timeout,
# unexpected wizard render, etc.) still leaves the panel in a
# bootable state for the tear-down step. The matching stash
# above only renames when the source file exists, so the pair
# is idempotent.
- name: Restore config.php
if: always()
working-directory: pr-head
run: |
if [[ -f .sbpp-config-stash.php ]]; then
mv .sbpp-config-stash.php web/config.php
echo "restored pr-head/.sbpp-config-stash.php → web/config.php"
else
echo "no stash to restore; nothing to do"
fi
- name: Tear down stack
if: always()
working-directory: pr-head
run: docker compose down -v
# Commit the captured PNG diff back to the PR branch from the
# PR-head working directory. The action no-ops when nothing
# changed under the file_pattern.
- name: Commit captured screenshots back to PR branch
if: github.event_name != 'workflow_dispatch'
uses: stefanzweifel/git-auto-commit-action@v5
with:
repository: pr-head
commit_message: |
docs: refresh auto-captured screenshots (#1333)
Generated by .github/workflows/docs-screenshots-capture.yml
after a maintainer applied the `safe-to-screenshot` label.
file_pattern: 'docs/src/assets/auto/**'
commit_user_name: 'sbpp-docs-bot'
commit_user_email: 'sbpp-docs-bot@users.noreply.github.com'