Repository navigation
Fork Workflow-Change Guard #149660
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Fork Workflow-Change Guard | |
| # Flags a FORK pull request that modifies CI/workflow files under `.github/**` | |
| # — the vector a fork could use to fake basic-CI results (rewrite ci.yml to pass) | |
| # or tamper with CODEOWNERS/other automation. This posts an ADVISORY check-run | |
| # signal; it is not itself a merge gate (see the enforcement note below). | |
| # | |
| # WHY DETERMINISTIC (no model): "does the diff touch .github/**" is a file-path | |
| # check — a grep on the authentic changed-file list is 100% reliable, instant, | |
| # and free. An LLM gate here would be slower, cost money, and could hallucinate. | |
| # | |
| # WHY TRUSTED CONTEXT: it runs from the DEFAULT branch (workflow_run of CI, and | |
| # pull_request_target for the override-label re-eval), so a fork cannot disable | |
| # it, and fork pull_request runs have no `checks:write` to forge its verdict. It | |
| # NEVER checks out or executes fork code — it only reads the authentic | |
| # changed-file list from GitHub's compare API and posts a check-run. | |
| # | |
| # EXEMPT: ratchet BASELINE data files (`.github/coverage-baselines/*.txt` and | |
| # `.github/*-baseline.txt`) are not guarded. They are plain text consumed by | |
| # gates that run from the trusted base workflow; a fork editing one cannot run | |
| # code or forge a check-run -- it can only loosen its own ratchet, and that is | |
| # a visible line in the diff that CODEOWNERS review already covers. A fork PR | |
| # that adds a new file, or shrinks a baselined file, MUST edit these to pass | |
| # the coverage/lint gates, so guarding them blocked every such contribution | |
| # for nothing. Workflows, scripts, prompts, CODEOWNERS and everything else | |
| # under `.github/**` stay guarded. | |
| # | |
| # OVERRIDE: a maintainer who has reviewed a legitimate workflow change applies | |
| # the `allow-fork-workflow-change` label; the guard then re-evaluates green. | |
| # ENFORCEMENT (what actually stops a malicious fork workflow change): this | |
| # guard is an advisory signal only — it is NOT listed in any branch/ruleset | |
| # `required_status_checks` and pr-readiness.yml does not consult it. The real | |
| # protection is two-fold: (1) fork workflow_run jobs require maintainer approval | |
| # before they run (repo "require approval for all external contributors" | |
| # setting), and (2) the catch-all `*` rule in CODEOWNERS forces code-owner | |
| # review, which covers `.github/**` by inheritance. Wiring this guard in as a | |
| # required, blocking check is a maintainer policy decision, not yet configured. | |
| on: | |
| workflow_run: | |
| workflows: ["CI"] | |
| types: [completed] | |
| # Label events run in the base-repo context (write token) via | |
| # pull_request_target, so the guard can re-post its check for a fork PR — a | |
| # fork `pull_request` run would be read-only and could not. No fork code is | |
| # checked out here. | |
| pull_request_target: | |
| # `synchronize`: a new push must invalidate any prior approval so an | |
| # `allow-fork-workflow-change` label cannot carry over to a later revision | |
| # (see the strip-stale-override job below). | |
| types: [labeled, unlabeled, synchronize] | |
| permissions: | |
| contents: read | |
| concurrency: | |
| # Both event ids are immutable trusted metadata. A workflow run id is retained | |
| # across attempts; a pull-request id is retained across target-event reruns. | |
| # Distinct sibling PRs therefore cannot collide even when refs differ only by | |
| # case, and unbounded fork-controlled ref text never enters the group. | |
| group: >- | |
| fork-workflow-guard-${{ github.event_name }}-${{ | |
| github.event.workflow_run.id || github.event.pull_request.id | |
| }} | |
| cancel-in-progress: true | |
| jobs: | |
| guard: | |
| name: Fork workflow-change guard | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| # `checks: write` posts this guard's own check-run. The reads are all GETs: | |
| # `pull-requests: read` for the open-PR list and the changed-file list, | |
| # `issues: read` for the label list (labels are an issues endpoint). No | |
| # write to the PR -- only strip-stale-override removes the override label. | |
| permissions: | |
| contents: read | |
| checks: write | |
| pull-requests: read | |
| issues: read | |
| # FORK ONLY, both event shapes. Same-repo PRs are unaffected. | |
| if: >- | |
| ( github.event_name == 'workflow_run' | |
| && github.event.workflow_run.event == 'pull_request' | |
| && github.event.workflow_run.head_repository.full_name != github.repository ) | |
| || ( github.event_name == 'pull_request_target' | |
| && github.event.label.name == 'allow-fork-workflow-change' | |
| && github.event.pull_request.head.repo.full_name != github.repository ) | |
| steps: | |
| - name: Evaluate workflow-change guard | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| REPO: ${{ github.repository }} | |
| WR_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} | |
| WR_HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }} | |
| WR_HEAD_REF: ${{ github.event.workflow_run.head_branch }} | |
| PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} | |
| PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} | |
| OVERRIDE_LABEL: allow-fork-workflow-change | |
| run: | | |
| set -uo pipefail | |
| head_sha="${WR_HEAD_SHA:-$PR_HEAD_SHA}" | |
| head_repo="${WR_HEAD_REPO:-$PR_HEAD_REPO}" | |
| head_ref="${WR_HEAD_REF:-$PR_HEAD_REF}" | |
| if [ -z "$head_sha" ] || [ -z "$head_repo" ] || [ -z "$head_ref" ]; then | |
| echo "::error::empty event head identity (repository/ref/SHA); failing closed" | |
| exit 1 | |
| fi | |
| # Resolve the PR by matching the open PR whose head SHA == head_sha | |
| # (workflow_run.pull_requests is empty for forks). | |
| # An empty result here has TWO causes that need OPPOSITE handling, and | |
| # collapsing them is what made this abort unfalsifiable: the same | |
| # message and the same exit 1 were emitted whether the query failed or | |
| # whether it succeeded and found nothing. | |
| # * query FAILED (5xx, rate limit, network) -> we know nothing about | |
| # this PR. Stay red and loud: skipping here would let a fork PR | |
| # that really does touch .github/** proceed with no verdict. | |
| # * query SUCCEEDED, no OPEN PR has this head -> the push was | |
| # superseded, or its PR closed, while CI was queued. There is | |
| # nothing to evaluate, and this abort is upstream of both | |
| # check-run POSTs, so nothing was published and no verdict is | |
| # withheld. Report it and exit 0. | |
| # stderr is no longer discarded, so a failing query now says why. | |
| if ! candidates="$(gh api "repos/$REPO/pulls?state=open&per_page=100" --paginate \ | |
| | jq -rs --arg sha "$head_sha" --arg repo "$head_repo" --arg ref "$head_ref" ' | |
| [.[][] | |
| | select(.state == "open" | |
| and .head.repo.full_name == $repo | |
| and .head.ref == $ref | |
| and .head.sha == $sha)] | |
| ')"; then | |
| echo "::error::could not query open PRs while resolving $head_sha - the query itself failed; see the error above" | |
| exit 1 | |
| fi | |
| candidate_count="$(printf '%s' "$candidates" | jq -r 'length')" | |
| if [ "$candidate_count" -eq 0 ]; then | |
| echo "::notice::no open PR has head $head_sha on $head_repo:$head_ref - superseded or closed while CI was queued; nothing to evaluate" | |
| exit 0 | |
| fi | |
| if [ "$candidate_count" -ne 1 ]; then | |
| echo "::error::more than one open PR matches head $head_sha on $head_repo:$head_ref; refusing to select one" | |
| exit 1 | |
| fi | |
| pr="$(printf '%s' "$candidates" | jq -r '.[0].number')" | |
| # AUTHORITATIVE, COMPLETE changed-file list from the paginated PR-files | |
| # endpoint. NOT the compare API — its `.files` caps at 300, which would | |
| # let a large fork PR hide a `.github/` edit past the cap and slip a | |
| # forged CI through. Fail CLOSED on any API error or truncation: never | |
| # post success when we could not fully determine the changed set. | |
| fail_closed() { | |
| gh api --method POST "repos/$REPO/check-runs" \ | |
| -f name="Fork workflow-change guard" -f head_sha="$head_sha" \ | |
| -f status="completed" -f conclusion="failure" \ | |
| -f "output[title]=$1" -f "output[summary]=$2" >/dev/null || true | |
| echo "::error::$1" | |
| exit 0 | |
| } | |
| if ! files="$(gh api "repos/$REPO/pulls/$pr/files" --paginate --jq '.[].filename')"; then | |
| fail_closed "could not determine changed files" \ | |
| "The changed-file list could not be retrieved from GitHub; failing closed. Re-run the guard." | |
| fi | |
| nfiles="$(printf '%s\n' "$files" | grep -c . || true)" | |
| if [ "${nfiles:-0}" -ge 3000 ]; then | |
| fail_closed "too many files to verify (>= 3000)" \ | |
| "This PR changes ${nfiles}+ files, at the API listing cap, so a hidden .github/** edit cannot be ruled out. Blocked; split the PR or a maintainer must review and apply the '$OVERRIDE_LABEL' label." | |
| fi | |
| # Baseline data files are exempt (see EXEMPT in the header). The | |
| # exemption is an exact-shape allowlist, not a directory prefix: only | |
| # `.txt` leaves directly under coverage-baselines/, or a top-level | |
| # `*-baseline.txt`, so a `.github/coverage-baselines/x/evil.yml` or a | |
| # `.github/workflows/foo-baseline.txt` is still caught. | |
| touched="$(printf '%s\n' "$files" | grep -E '^\.github/' \ | |
| | grep -vE '^\.github/(coverage-baselines/[^/]+\.txt|[^/]+-baseline\.txt)$' || true)" | |
| has_override=no | |
| if gh api "repos/$REPO/issues/$pr/labels" --paginate --jq '.[].name' 2>/dev/null \ | |
| | grep -Fxq "$OVERRIDE_LABEL"; then | |
| has_override=yes | |
| fi | |
| if [ -z "$touched" ]; then | |
| concl="success"; title="no .github/** changes" | |
| summary="This fork PR does not modify CI/workflow files." | |
| elif [ "$has_override" = "yes" ]; then | |
| concl="success"; title="workflow change accepted by maintainer" | |
| summary="This fork PR modifies .github/** but a maintainer applied the '$OVERRIDE_LABEL' label after review." | |
| else | |
| concl="failure"; title="fork PR modifies .github/** — blocked" | |
| changed_list="$(printf '%s ' $touched)" | |
| summary="This fork PR changes CI/workflow files ($changed_list). A fork could use this to fake CI results, so it is blocked. A maintainer must review the change and apply the '$OVERRIDE_LABEL' label to proceed. (Baseline data files under .github/coverage-baselines/ and .github/*-baseline.txt are exempt and never trigger this guard.)" | |
| fi | |
| gh api --method POST "repos/$REPO/check-runs" \ | |
| -f name="Fork workflow-change guard" -f head_sha="$head_sha" \ | |
| -f status="completed" -f conclusion="$concl" \ | |
| -f "output[title]=$title" -f "output[summary]=$summary" >/dev/null | |
| echo "guard verdict: $concl — $title" | |
| # A new push must not inherit a prior maintainer approval. On `synchronize`, | |
| # strip the override label so the guard re-blocks the new revision until it is | |
| # re-reviewed — closing the "approve revision A, then push malicious revision B | |
| # under the same label" hole. Runs in the trusted base context, fork-only, and | |
| # never checks out fork code. Removing the label fires `unlabeled`, which | |
| # re-runs the guard job above and re-posts the (now failing) check. | |
| strip-stale-override: | |
| name: Strip stale workflow-change override | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| if: >- | |
| github.event_name == 'pull_request_target' | |
| && github.event.action == 'synchronize' | |
| && github.event.pull_request.head.repo.full_name != github.repository | |
| permissions: | |
| pull-requests: write | |
| checks: write | |
| steps: | |
| - name: Remove override label on new revision | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| REPO: ${{ github.repository }} | |
| PR: ${{ github.event.pull_request.number }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| OVERRIDE_LABEL: allow-fork-workflow-change | |
| run: | | |
| set -uo pipefail | |
| # Fail CLOSED: a *failed* removal must never be mistaken for "no label", | |
| # or a stale approval would carry into the new revision. Only an actual | |
| # removal (2xx) or a genuine 404 (label absent) is acceptable; any other | |
| # error blocks the new head so the prior approval cannot survive. | |
| block_head() { | |
| gh api --method POST "repos/$REPO/check-runs" \ | |
| -f name="Fork workflow-change guard" -f head_sha="$HEAD_SHA" \ | |
| -f status="completed" -f conclusion="failure" \ | |
| -f "output[title]=stale override could not be cleared" \ | |
| -f "output[summary]=$1" >/dev/null 2>&1 || true | |
| } | |
| rc=0 | |
| err="$(gh api --method DELETE "repos/$REPO/issues/$PR/labels/$OVERRIDE_LABEL" 2>&1 >/dev/null)" || rc=$? | |
| if [ "$rc" -eq 0 ]; then | |
| echo "Removed '$OVERRIDE_LABEL' after new push — re-approval required for this revision." | |
| elif printf '%s' "$err" | grep -qiE 'HTTP 404|Not Found'; then | |
| echo "No '$OVERRIDE_LABEL' label present; nothing to strip." | |
| else | |
| echo "::error::could not remove '$OVERRIDE_LABEL' on new push: $err" | |
| block_head "Failed to remove '$OVERRIDE_LABEL' on a new push; blocking so a prior maintainer approval cannot carry over to this unreviewed revision." | |
| exit 1 | |
| fi | |
| # Confirm the label is really gone; otherwise block the new head. | |
| if gh api "repos/$REPO/issues/$PR/labels" --jq '.[].name' 2>/dev/null | grep -qx "$OVERRIDE_LABEL"; then | |
| echo "::error::'$OVERRIDE_LABEL' still present after strip; failing closed." | |
| block_head "The '$OVERRIDE_LABEL' label persists after a new push; blocking so a prior approval cannot carry over." | |
| exit 1 | |
| fi |