From 0bf2626263415294136fd8c97784eca16df63dec Mon Sep 17 00:00:00 2001 From: rumblefrog Date: Sun, 10 May 2026 22:21:22 -0400 Subject: [PATCH 1/3] ci(docs): skip dispatch when GitHub App not configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a job-level `if: vars.DOCS_DEPLOY_APP_ID != ''` guard to `docs-deploy-trigger.yml` so the workflow no-ops cleanly until the cutover steps in #1333 step 2 land (App creation + `DOCS_DEPLOY_APP_ID` var + `DOCS_DEPLOY_APP_KEY` secret). Pre-fix, every push to `docs/**` after #1339 merged ran the dispatch job, hit `actions/create-github-app-token@v1` with an empty `app-id` input, and exited red with `Input required and not supplied: app-id` (run 25646867498). Operators landing unrelated docs PRs would now see a stream of red runs that look like real CI failures but are actually the expected "cutover not done yet" state — exactly the friction the "Required repo configuration" comment block was meant to warn about. The job-level `if:` is the canonical "feature-flag a job" shape: unset repo variables resolve to empty string in expressions, so the guard is transparent once the var lands and the job runs every push as designed. Skipped runs show up as a grey "Skipped" badge in the Actions tab — the right visual signal for "intentionally inert until configured". Comment block above the workflow expanded to call this out explicitly so future readers don't try to "fix" the guard by removing it. --- .github/workflows/docs-deploy-trigger.yml | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docs-deploy-trigger.yml b/.github/workflows/docs-deploy-trigger.yml index b28b0c508..c204152ba 100644 --- a/.github/workflows/docs-deploy-trigger.yml +++ b/.github/workflows/docs-deploy-trigger.yml @@ -5,7 +5,7 @@ # Cadence: only on push to main with a docs/** path filter. PRs use # docs-build.yml to validate; this workflow is the production trigger. # -# Required repo configuration BEFORE this workflow can succeed (one-time +# Required repo configuration BEFORE this workflow does anything (one-time # cutover steps documented in #1333 cutover steps 1-2): # # - Create the `sbpp-docs-deploy` GitHub App (org-owned), scope @@ -14,9 +14,15 @@ # - Repo VARIABLE `DOCS_DEPLOY_APP_ID` = the App's numeric ID. # - Repo SECRET `DOCS_DEPLOY_APP_KEY` = the App's PEM private key. # -# Until those land, this workflow will fail on the first run with an -# auth error. That's expected; the deploy shell in sbpp.github.io -# also has a workflow_dispatch trigger as a manual fallback. +# Until `vars.DOCS_DEPLOY_APP_ID` is set, the `trigger` job below is +# skipped via the job-level `if:` guard — every push to `docs/**` shows +# up as a "Skipped" run in the Actions tab (grey icon) instead of a +# red-failing run. This stops the original anti-pattern (#1339-followup): +# the App-token action errors out with `Input required and not supplied: +# app-id`, the workflow goes red, and an operator who hasn't done the +# cutover steps yet sees a stream of confusing failures. +# The deploy shell in sbpp.github.io also has a `workflow_dispatch` +# trigger as a manual fallback while the App credentials are pending. name: docs-deploy-trigger @@ -42,6 +48,12 @@ jobs: name: Dispatch docs-changed event runs-on: ubuntu-24.04 permissions: {} + # Skip the job entirely when the GitHub App isn't registered yet + # (cutover step 2 in #1333). Unset repo variables resolve to empty + # string in expressions, so this is the canonical "feature-flag a + # job" shape. Once `DOCS_DEPLOY_APP_ID` is set, the guard becomes + # transparent and the job runs every push. + if: vars.DOCS_DEPLOY_APP_ID != '' steps: - name: Mint installation token via GitHub App From 63ce871e6c9b6256820c820242a2bdba2a4a4705 Mon Sep 17 00:00:00 2001 From: rumblefrog Date: Sun, 10 May 2026 22:47:19 -0400 Subject: [PATCH 2/3] ci(docs): switch dispatch trigger from GitHub App to fine-grained PAT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the `actions/create-github-app-token@v1` round-trip and the App-side `vars.DOCS_DEPLOY_APP_ID` + `secrets.DOCS_DEPLOY_APP_KEY` pair with a direct `secrets.DOCS_DEPLOY_PAT` (a fine-grained PAT scoped to `Actions: Read and write` on `sbpp.github.io` only). The previous fix in this PR's first commit (`0bf26262`) gated the job on `vars.DOCS_DEPLOY_APP_ID`. Renamed the gate variable to `vars.DOCS_DEPLOY_ENABLED` since "App ID" is no longer the right mental model — the variable is now a pure feature-flag the operator sets to opt in once the PAT is configured. (We can't reference `secrets.*` directly in a job-level `if:` per GitHub Actions' context-availability rules, so the paired VARIABLE is the canonical "feature-flag a job" shape.) Why PAT over App: a fine-grained PAT scoped to one repo + one permission carries the same blast radius as the org-owned App but takes ~2 minutes to provision instead of ~10. Tradeoff is the PAT is tied to a personal account and has a max one-year expiry that needs rotation; for a project of this size that's a fair deal. Operator instructions live in the workflow's top-of-file comment block + `docs/README.md`'s workflow table. The job-level `if:` guard pattern is unchanged in shape — every push to `docs/**` shows up as "Skipped" (grey badge) until the operator sets BOTH `secrets.DOCS_DEPLOY_PAT` and `vars.DOCS_DEPLOY_ENABLED`. The deploy shell in `sbpp.github.io` also has a `workflow_dispatch` trigger as a manual fallback while the PAT is pending. --- .github/workflows/docs-deploy-trigger.yml | 66 ++++++++++++----------- docs/README.md | 2 +- 2 files changed, 37 insertions(+), 31 deletions(-) diff --git a/.github/workflows/docs-deploy-trigger.yml b/.github/workflows/docs-deploy-trigger.yml index c204152ba..d5624ccd8 100644 --- a/.github/workflows/docs-deploy-trigger.yml +++ b/.github/workflows/docs-deploy-trigger.yml @@ -6,23 +6,29 @@ # docs-build.yml to validate; this workflow is the production trigger. # # Required repo configuration BEFORE this workflow does anything (one-time -# cutover steps documented in #1333 cutover steps 1-2): +# cutover step): # -# - Create the `sbpp-docs-deploy` GitHub App (org-owned), scope -# `Actions: write` on `sbpp.github.io` only, install on -# `sbpp/sbpp.github.io`. -# - Repo VARIABLE `DOCS_DEPLOY_APP_ID` = the App's numeric ID. -# - Repo SECRET `DOCS_DEPLOY_APP_KEY` = the App's PEM private key. +# - Create a fine-grained PAT scoped to `sbpp/sbpp.github.io` only, +# with the `Actions: Read and write` repository permission. (Classic +# PATs work too, but the fine-grained variant is strictly narrower +# and the right default.) Max expiry is one year — set a calendar +# reminder to rotate. +# - Repo SECRET `DOCS_DEPLOY_PAT` = the token value. +# - Repo VARIABLE `DOCS_DEPLOY_ENABLED` = any non-empty value (e.g. +# `true`). This is the feature-flag the job-level `if:` guard +# below checks. We can't reference `secrets.*` directly in a +# job-level `if:` (GitHub Actions context-availability rule), so +# the operator opts in by setting the paired VARIABLE explicitly. # -# Until `vars.DOCS_DEPLOY_APP_ID` is set, the `trigger` job below is +# Until `vars.DOCS_DEPLOY_ENABLED` is set, the `trigger` job below is # skipped via the job-level `if:` guard — every push to `docs/**` shows # up as a "Skipped" run in the Actions tab (grey icon) instead of a # red-failing run. This stops the original anti-pattern (#1339-followup): -# the App-token action errors out with `Input required and not supplied: -# app-id`, the workflow goes red, and an operator who hasn't done the -# cutover steps yet sees a stream of confusing failures. -# The deploy shell in sbpp.github.io also has a `workflow_dispatch` -# trigger as a manual fallback while the App credentials are pending. +# the dispatch action errors out on missing credentials, the workflow +# goes red, and an operator who hasn't done the cutover yet sees a +# stream of confusing failures. The deploy shell in sbpp.github.io also +# has a `workflow_dispatch` trigger as a manual fallback while the PAT +# is pending. name: docs-deploy-trigger @@ -48,30 +54,30 @@ jobs: name: Dispatch docs-changed event runs-on: ubuntu-24.04 permissions: {} - # Skip the job entirely when the GitHub App isn't registered yet - # (cutover step 2 in #1333). Unset repo variables resolve to empty - # string in expressions, so this is the canonical "feature-flag a - # job" shape. Once `DOCS_DEPLOY_APP_ID` is set, the guard becomes - # transparent and the job runs every push. - if: vars.DOCS_DEPLOY_APP_ID != '' + # Skip the job entirely when the PAT isn't configured yet. Unset + # repo variables resolve to empty string in expressions, so this is + # the canonical "feature-flag a job" shape. Once + # `vars.DOCS_DEPLOY_ENABLED` is set, the guard becomes transparent + # and the job runs every push. + # + # Why a paired VARIABLE rather than checking the SECRET directly: + # GitHub Actions doesn't expose the `secrets` context in job-level + # `if:` conditions (only `github`, `inputs`, `needs`, `vars` are + # available there). Step-level `if:` does see `secrets`, but + # putting the guard at the step level means the job still spins up + # a runner just to skip every step inside it — wasteful and noisy. + # The variable IS the operator's "I've done the cutover" signal. + if: vars.DOCS_DEPLOY_ENABLED != '' steps: - - name: Mint installation token via GitHub App - id: mint-token - uses: actions/create-github-app-token@v1 - with: - app-id: ${{ vars.DOCS_DEPLOY_APP_ID }} - private-key: ${{ secrets.DOCS_DEPLOY_APP_KEY }} - owner: sbpp - repositories: sbpp.github.io - # The dispatched workflow in sbpp.github.io listens for # `event_type: docs-changed`. The client_payload carries the - # commit SHA and ref so the deploy job can stamp it into the - # site footer / build manifest if it wants. + # commit SHA and ref so the deploy job can pin its sourcebans-pp + # checkout to the exact commit that fired the dispatch (race + # guard for back-to-back pushes). - name: Dispatch repository_dispatch into sbpp.github.io env: - GH_TOKEN: ${{ steps.mint-token.outputs.token }} + GH_TOKEN: ${{ secrets.DOCS_DEPLOY_PAT }} run: | gh api repos/sbpp/sbpp.github.io/dispatches \ --method POST \ diff --git a/docs/README.md b/docs/README.md index 0a765e080..dba3328ef 100644 --- a/docs/README.md +++ b/docs/README.md @@ -87,7 +87,7 @@ Four workflows under `.github/workflows/` cover the docs site: | Workflow | Trigger | What it does | | -------- | ------- | ------------ | | `docs-build.yml` | PRs + main pushes touching `docs/**` | Runs `npm run build`. Uploads the built `dist/` as an artifact. | -| `docs-deploy-trigger.yml` | main pushes touching `docs/**` | Fires a `repository_dispatch` (event_type=`docs-changed`) into `sbpp/sbpp.github.io`, which kicks the actual GitHub Pages deploy. Requires the `DOCS_DEPLOY_APP_ID` repo variable + `DOCS_DEPLOY_APP_KEY` repo secret to be configured (one-time cutover step). | +| `docs-deploy-trigger.yml` | main pushes touching `docs/**` | Fires a `repository_dispatch` (event_type=`docs-changed`) into `sbpp/sbpp.github.io`, which kicks the actual GitHub Pages deploy. Requires the `DOCS_DEPLOY_PAT` repo secret (fine-grained PAT, `Actions: write` on `sbpp.github.io` only) + the `DOCS_DEPLOY_ENABLED` repo variable as the opt-in flag. Until both are set, the job is skipped (grey badge in the Actions tab); the deploy shell in `sbpp.github.io` still has a `workflow_dispatch` button as a manual fallback. | | `docs-screenshots-build.yml` | PRs touching `docs/scripts/capture.mjs` or `docs/package*.json` | Sandboxed verification: `npm ci` + `node --check scripts/capture.mjs`. No secrets, no write permissions; runs the standard `pull_request` token. Catches "did the capture script still parse" on every PR. | | `docs-screenshots-capture.yml` | PRs labelled `safe-to-screenshot` (same-repo only) + `workflow_dispatch` | Boots the dev stack, seeds the DB, runs `npm run capture` from a TRUSTED-FROM-MAIN checkout, commits PNG deltas back to the PR branch. | From 37ae051c5c893c213d1ae25941ab5c7e871e1d4a Mon Sep 17 00:00:00 2001 From: rumblefrog Date: Sun, 10 May 2026 23:18:59 -0400 Subject: [PATCH 3/3] ci(docs): drop redundant DOCS_DEPLOY_ENABLED variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit on this branch added a paired `vars.DOCS_DEPLOY_ENABLED` feature flag because I'd convinced myself `secrets.*` was unavailable in any `if:` context. That's half-true: it's unavailable in JOB-level `if:` (which is what I was trying to use), but it IS available in STEP-level `if:`. Since the trigger job has only one step (the dispatch), gating that single step on `if: secrets.DOCS_DEPLOY_PAT != ''` is behaviorally equivalent to a job-level guard — the runner spins up briefly to evaluate the step, sees no credential, marks the step Skipped, and the run finishes green-with-skipped instead of red-failing. The "wasted runner spin" cost I cited as justification for the variable is real but tiny (a few seconds of CI minutes vs ~30s of job-startup overhead either way), and the green-with-skipped status is arguably MORE informative than a fully-skipped grey job — it tells you "I ran, had no work to do" rather than "I didn't run". The variable was the operator's "I've done the cutover" signal, but the secret being set IS that signal. One source of truth, one less thing to forget. Cutover is now: create PAT, paste into `secrets.DOCS_DEPLOY_PAT`, done. Comment block above the workflow + `docs/README.md` workflow table updated. --- .github/workflows/docs-deploy-trigger.yml | 47 +++++++++-------------- docs/README.md | 2 +- 2 files changed, 19 insertions(+), 30 deletions(-) diff --git a/.github/workflows/docs-deploy-trigger.yml b/.github/workflows/docs-deploy-trigger.yml index d5624ccd8..eaa58903e 100644 --- a/.github/workflows/docs-deploy-trigger.yml +++ b/.github/workflows/docs-deploy-trigger.yml @@ -13,22 +13,18 @@ # PATs work too, but the fine-grained variant is strictly narrower # and the right default.) Max expiry is one year — set a calendar # reminder to rotate. -# - Repo SECRET `DOCS_DEPLOY_PAT` = the token value. -# - Repo VARIABLE `DOCS_DEPLOY_ENABLED` = any non-empty value (e.g. -# `true`). This is the feature-flag the job-level `if:` guard -# below checks. We can't reference `secrets.*` directly in a -# job-level `if:` (GitHub Actions context-availability rule), so -# the operator opts in by setting the paired VARIABLE explicitly. +# - Repo SECRET `DOCS_DEPLOY_PAT` = the token value. # -# Until `vars.DOCS_DEPLOY_ENABLED` is set, the `trigger` job below is -# skipped via the job-level `if:` guard — every push to `docs/**` shows -# up as a "Skipped" run in the Actions tab (grey icon) instead of a -# red-failing run. This stops the original anti-pattern (#1339-followup): -# the dispatch action errors out on missing credentials, the workflow -# goes red, and an operator who hasn't done the cutover yet sees a -# stream of confusing failures. The deploy shell in sbpp.github.io also -# has a `workflow_dispatch` trigger as a manual fallback while the PAT -# is pending. +# Until `DOCS_DEPLOY_PAT` is set, the dispatch step below is skipped via +# its `if: secrets.DOCS_DEPLOY_PAT != ''` guard — every push to `docs/**` +# shows up as a green run with the dispatch step marked "Skipped", +# instead of red-failing on a missing credential. This stops the +# original anti-pattern (#1339-followup) where the dispatch hard-erred +# and an operator who hasn't done the cutover yet sees a stream of +# confusing failures. +# +# The deploy shell in sbpp.github.io also has a `workflow_dispatch` +# trigger as a manual fallback while the PAT is pending. name: docs-deploy-trigger @@ -54,20 +50,6 @@ jobs: name: Dispatch docs-changed event runs-on: ubuntu-24.04 permissions: {} - # Skip the job entirely when the PAT isn't configured yet. Unset - # repo variables resolve to empty string in expressions, so this is - # the canonical "feature-flag a job" shape. Once - # `vars.DOCS_DEPLOY_ENABLED` is set, the guard becomes transparent - # and the job runs every push. - # - # Why a paired VARIABLE rather than checking the SECRET directly: - # GitHub Actions doesn't expose the `secrets` context in job-level - # `if:` conditions (only `github`, `inputs`, `needs`, `vars` are - # available there). Step-level `if:` does see `secrets`, but - # putting the guard at the step level means the job still spins up - # a runner just to skip every step inside it — wasteful and noisy. - # The variable IS the operator's "I've done the cutover" signal. - if: vars.DOCS_DEPLOY_ENABLED != '' steps: # The dispatched workflow in sbpp.github.io listens for @@ -75,7 +57,14 @@ jobs: # commit SHA and ref so the deploy job can pin its sourcebans-pp # checkout to the exact commit that fired the dispatch (race # guard for back-to-back pushes). + # + # Step-level `if:` evaluates against `secrets.*` (job-level `if:` + # does not), so we gate the dispatch directly on the PAT being + # configured — no separate feature-flag variable needed. When + # `DOCS_DEPLOY_PAT` is unset, the step is skipped and the run is + # green-with-skipped instead of red-failing. - name: Dispatch repository_dispatch into sbpp.github.io + if: secrets.DOCS_DEPLOY_PAT != '' env: GH_TOKEN: ${{ secrets.DOCS_DEPLOY_PAT }} run: | diff --git a/docs/README.md b/docs/README.md index dba3328ef..da01fc1ee 100644 --- a/docs/README.md +++ b/docs/README.md @@ -87,7 +87,7 @@ Four workflows under `.github/workflows/` cover the docs site: | Workflow | Trigger | What it does | | -------- | ------- | ------------ | | `docs-build.yml` | PRs + main pushes touching `docs/**` | Runs `npm run build`. Uploads the built `dist/` as an artifact. | -| `docs-deploy-trigger.yml` | main pushes touching `docs/**` | Fires a `repository_dispatch` (event_type=`docs-changed`) into `sbpp/sbpp.github.io`, which kicks the actual GitHub Pages deploy. Requires the `DOCS_DEPLOY_PAT` repo secret (fine-grained PAT, `Actions: write` on `sbpp.github.io` only) + the `DOCS_DEPLOY_ENABLED` repo variable as the opt-in flag. Until both are set, the job is skipped (grey badge in the Actions tab); the deploy shell in `sbpp.github.io` still has a `workflow_dispatch` button as a manual fallback. | +| `docs-deploy-trigger.yml` | main pushes touching `docs/**` | Fires a `repository_dispatch` (event_type=`docs-changed`) into `sbpp/sbpp.github.io`, which kicks the actual GitHub Pages deploy. Requires the `DOCS_DEPLOY_PAT` repo secret (fine-grained PAT, `Actions: Read and write` on `sbpp.github.io` only). Until the secret is set, the dispatch step is skipped on every run (the run is green-with-skipped, not red-failing); the deploy shell in `sbpp.github.io` still has a `workflow_dispatch` button as a manual fallback. | | `docs-screenshots-build.yml` | PRs touching `docs/scripts/capture.mjs` or `docs/package*.json` | Sandboxed verification: `npm ci` + `node --check scripts/capture.mjs`. No secrets, no write permissions; runs the standard `pull_request` token. Catches "did the capture script still parse" on every PR. | | `docs-screenshots-capture.yml` | PRs labelled `safe-to-screenshot` (same-repo only) + `workflow_dispatch` | Boots the dev stack, seeds the DB, runs `npm run capture` from a TRUSTED-FROM-MAIN checkout, commits PNG deltas back to the PR branch. |