Skip to content

Commit 78c5f44

Browse files
ci: run PR workflows without pull_request_target (#2409)
The github org blocks pull_request_target, so heartbeat, impact, pr-readiness and repo-health have failed at startup since the transfer. GitHub also blocks it by default in public repositories from 2026-12-02. Each workflow now does untrusted work under pull_request (read-only token, no secrets) and writes back from a workflow_run job running default-branch code. Writers bind the claim to the run (head SHA, head repository, base repository, open PR targeting dev) before posting. Repo Health decides whether a PR changed its own workflow from blobs pinned to the run's head SHA and the merge base. Closes #2407 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 89504b2 commit 78c5f44

19 files changed

Lines changed: 2607 additions & 343 deletions

‎.github/REPO_HEALTH_RUNBOOK.md‎

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,42 @@
11
# Repo Health workflow verification
22

3-
`Repo Health` is intentionally base-controlled. Its `pull_request_target` trigger supplies the
4-
workflow definition from the target branch, and every checkout keeps the trusted base revision.
5-
The PR head is fetched only as Git data. Never check out or execute PR-head code in that workflow.
3+
The github org blocks `pull_request_target`, so Repo Health is split in two:
4+
5+
- `Repo Health` (`squad-repo-health.yml`) runs on `pull_request` with a read-only token and no
6+
secrets. Every job checks out the PR's base commit and fetches the PR head only as Git data.
7+
Never check out or execute PR-head code there. It uploads one result file per scanner
8+
(`repo-health-{leakage,architectural,security}`).
9+
- **Base-controlled only when the PR leaves `squad-repo-health.yml` unchanged.** A `pull_request`
10+
run uses the workflow file from the PR merge commit, so a PR that edits that file controls every
11+
job of its own run, including the Bootstrap Protection check (it could make that job `exit 0`).
12+
The compensating control is code-owner review: `.github/CODEOWNERS` line 4 requests review from
13+
the code owners on every PR. As of 2026-11-07, `dev`'s rules require neither code-owner review
14+
nor any approval, and Bootstrap Protection is not a required check, so this control depends on
15+
reviewers reading workflow changes. Requiring code-owner review on `dev` would enforce it.
16+
- `Repo Health Report` (`squad-repo-health-report.yml`) runs on `workflow_run` from the default
17+
branch and holds the write token. It downloads each scanner's artifact by its exact name and
18+
treats the results as untrusted data: exact schema, size limit, one PR and head SHA across all
19+
results, bound to the `workflow_run` payload and the live PR, which must target `dev`
20+
(`scripts/repo-health-report.mjs`, `scripts/workflow-run-pr-binding.mjs`). It posts only when
21+
the run's head commit holds the same `squad-repo-health.yml` blob as its merge base with `dev`.
22+
Both blobs are read by commit SHA, so a push after binding can't change the answer. Otherwise it
23+
logs a notice, posts no findings, and overwrites its own existing marker comments with a fixed
24+
notice naming the refused commit. Every posted comment names the head commit it describes.
25+
26+
Fork PRs from external contributors need a maintainer to approve their workflow runs first
27+
(`approval_policy: all_external_contributors`); nothing is posted before that.
628

729
## Evidence levels
830

931
- **Shadow / non-authoritative:** `Repo Health Shadow (Non-Authoritative)` runs proposed scanners,
1032
reporter script bodies, and the reporter helper from the PR head under `pull_request`, a
1133
read-only token, no persisted checkout credentials, and fake Octokit. It can prove that proposed
1234
code compiles, preserves hostile text, and propagates API failures, but it cannot prove the
13-
elevated production workflow has picked up the merged implementation.
35+
default-branch reporter has picked up the merged implementation.
1436
- **Post-merge / authoritative:** only a disposable PR opened after the change reaches `dev` and
15-
observed through `Repo Health` proves the base-controlled writer now creates and cleans up its
16-
marker comment.
37+
observed through `Repo Health Report` proves the default-branch writer now creates and cleans up
38+
its marker comment. A PR that changes `squad-repo-health.yml` itself never gets posted findings,
39+
so this canary is the only authoritative evidence for such a change.
1740

1841
Do not weaken a scanner, add an exclusion, or change a finding merely to make the base-controlled
1942
check on the implementation PR green.
@@ -46,18 +69,18 @@ PR_URL=$(gh pr create --base dev --title 'test: repo-health post-merge canary' \
4669
PR_NUMBER=${PR_URL##*/}
4770
```
4871

49-
Wait for `Repo Health / Squad File Leakage`, then verify exactly one authoritative marker and the
50-
exact hostile filename:
72+
Wait for `Repo Health` and then `Repo Health Report` to complete, then verify exactly one
73+
authoritative marker, the exact hostile filename, and the head commit it describes:
5174

5275
```bash
5376
EXPECTED=$(node --input-type=module -e \
5477
'process.stdout.write(".squad/canary-`paired`-${-apostrophe-'\''-\"double quote\".md")')
5578

56-
gh api --paginate --slurp "repos/bradygaster/squad/issues/$PR_NUMBER/comments" \
79+
gh api --paginate --slurp "repos/github/squad/issues/$PR_NUMBER/comments" \
5780
> canary-comments.json
58-
node --input-type=module - "$EXPECTED" <<'NODE'
81+
node --input-type=module - "$EXPECTED" "$(git rev-parse HEAD)" <<'NODE'
5982
import { readFileSync } from 'node:fs';
60-
const expected = process.argv[2];
83+
const [expected, head] = process.argv.slice(2);
6184
const comments = JSON.parse(readFileSync('canary-comments.json', 'utf8')).flat();
6285
const marked = comments.filter(({ body = '' }) =>
6386
body.includes('<!-- squad-repo-health-leakage -->'));
@@ -69,6 +92,9 @@ if (listed.length !== 1 || listed[0] !== `- \`${expected}\``) {
6992
if (!marked[0].body.includes('Authoritative evidence')) {
7093
throw new Error('comment was not labeled authoritative');
7194
}
95+
if (!marked[0].body.includes(`Results for head commit \`${head}\``)) {
96+
throw new Error(`comment does not name head commit ${head}`);
97+
}
7298
NODE
7399
rm canary-comments.json
74100
```
@@ -80,8 +106,8 @@ git rm -- "$CANARY_PATH"
80106
git commit -m 'test: clean up repo-health post-merge canary'
81107
git push
82108

83-
# After Repo Health / Squad File Leakage completes:
84-
gh api --paginate --slurp "repos/bradygaster/squad/issues/$PR_NUMBER/comments" \
109+
# After Repo Health and Repo Health Report complete:
110+
gh api --paginate --slurp "repos/github/squad/issues/$PR_NUMBER/comments" \
85111
> canary-comments.json
86112
node --input-type=module <<'NODE'
87113
import { readFileSync } from 'node:fs';
@@ -95,6 +121,7 @@ gh pr close "$PR_NUMBER" --delete-branch
95121
git switch dev
96122
```
97123

98-
Record the PR URL and both successful `Repo Health` run URLs in the implementing PR or issue.
99-
This post-merge canary is the only authoritative evidence for a repo-health workflow/reporter
100-
change; the pre-merge shadow remains intentionally non-authoritative.
124+
Record the PR URL and the successful `Repo Health` and `Repo Health Report` run URLs (both
125+
pushes) in the implementing PR or issue. This post-merge canary is the only authoritative evidence
126+
for a repo-health workflow/reporter change; the pre-merge shadow remains intentionally
127+
non-authoritative.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: Squad Heartbeat PR Closed
2+
3+
# Unprivileged PR-close signal for Squad Heartbeat (Ralph).
4+
#
5+
# The github org blocks pull_request_target, and a `pull_request` run from a
6+
# fork or Dependabot gets a read-only token and no secrets. This run does not
7+
# check out or execute anything and uploads nothing: its completion is the whole
8+
# signal. squad-heartbeat.yml listens for it through workflow_run and does the
9+
# privileged work from the default branch, without reading anything from here.
10+
on:
11+
pull_request:
12+
types: [closed]
13+
14+
permissions: {}
15+
16+
concurrency:
17+
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
18+
cancel-in-progress: true
19+
20+
jobs:
21+
heartbeat:
22+
runs-on: ubuntu-latest
23+
timeout-minutes: 2
24+
steps:
25+
- name: Signal PR close (no checkout, no PR code)
26+
run: echo "PR closed; Squad Heartbeat (Ralph) runs from the default branch via workflow_run."

‎.github/workflows/squad-heartbeat.yml‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,16 @@ on:
1616
# React to completed work or new squad work
1717
issues:
1818
types: [closed, labeled]
19-
# pull_request_target (not pull_request): GITHUB_TOKEN is forced read-only
20-
# for `pull_request` events triggered by a fork, so write steps below
21-
# (addLabels/createComment) 403 on fork PR closes (#2080-class bug).
22-
# Safe here because the checkout step below has no `ref:` override, so it
23-
# always checks out the BASE ref — no fork code or fork-controlled input
24-
# is ever read or executed by this job.
25-
pull_request_target:
26-
types: [closed]
19+
# PR closes (same-repo, fork and Dependabot). The github org blocks
20+
# pull_request_target, and a fork `pull_request` run only gets a read-only
21+
# token, so write steps below would 403 there (#2080-class bug). The
22+
# unprivileged squad-heartbeat-pr-closed.yml run only signals the close;
23+
# workflow_run then runs THIS default-branch file with the write token. The
24+
# checkout below has no `ref:`, so it is the default branch, and this job
25+
# reads no artifact, output or field from the PR run.
26+
workflow_run:
27+
workflows: ["Squad Heartbeat PR Closed"]
28+
types: [completed]
2729

2830
# Manual trigger
2931
workflow_dispatch:
@@ -42,6 +44,11 @@ concurrency:
4244
jobs:
4345
heartbeat:
4446
runs-on: ubuntu-latest
47+
if: >-
48+
github.event_name != 'workflow_run'
49+
|| (github.event.workflow_run.event == 'pull_request'
50+
&& github.event.workflow_run.conclusion == 'success'
51+
&& github.event.workflow_run.path == '.github/workflows/squad-heartbeat-pr-closed.yml')
4552
steps:
4653
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7
4754

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: Squad Impact Analysis Request
2+
3+
# Unprivileged half of Squad Impact Analysis (squad-impact.yml).
4+
#
5+
# The github org blocks pull_request_target. This `pull_request` run has a
6+
# read-only token and no secrets, checks out nothing, and runs no PR code. It
7+
# only uploads a claim: the PR number and head SHA it ran for. The privileged
8+
# half treats that claim as untrusted, binds it to the workflow_run payload and
9+
# the live PR, and computes the report itself from the GitHub API.
10+
on:
11+
pull_request:
12+
branches: [dev]
13+
types: [opened, synchronize, reopened]
14+
15+
permissions: {}
16+
17+
concurrency:
18+
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
19+
cancel-in-progress: true
20+
21+
jobs:
22+
impact:
23+
runs-on: ubuntu-latest
24+
timeout-minutes: 2
25+
if: github.actor != 'dependabot[bot]'
26+
steps:
27+
- name: Write PR claim (no checkout, no PR code)
28+
env:
29+
PR_NUMBER: ${{ github.event.pull_request.number }}
30+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
31+
run: |
32+
mkdir -p "$RUNNER_TEMP/claim"
33+
jq -n --argjson pr_number "$PR_NUMBER" --arg head_sha "$HEAD_SHA" \
34+
'{schema: "squad-pr-claim/v1", pr_number: $pr_number, head_sha: $head_sha}' \
35+
> "$RUNNER_TEMP/claim/claim.json"
36+
37+
- name: Upload PR claim
38+
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 #v7
39+
with:
40+
name: squad-impact-request
41+
path: ${{ runner.temp }}/claim/claim.json
42+
if-no-files-found: error
43+
retention-days: 1
44+
overwrite: true

‎.github/workflows/squad-impact.yml‎

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,97 @@
11
name: Squad Impact Analysis
22

3+
# Privileged half of the impact report. The github org blocks
4+
# pull_request_target, so squad-impact-request.yml runs on `pull_request`
5+
# (read-only token, no secrets, no checkout) and uploads only a claim: the PR
6+
# number and head SHA it ran for. workflow_run runs THIS file from the default
7+
# branch with a write token.
8+
#
9+
# Trust boundary:
10+
# - The claim is untrusted data: a fork controls the request workflow. It is
11+
# parsed strictly and bound to the workflow_run payload and the live PR (same
12+
# head SHA, same head repository, this base repository, still open) before
13+
# anything is written. See scripts/workflow-run-pr-binding.mjs.
14+
# - Only default-branch scripts run. PR code is never checked out or executed;
15+
# the report is computed from the GitHub API file list.
316
on:
4-
pull_request_target:
5-
branches: [dev]
6-
types: [opened, synchronize, reopened]
17+
workflow_run:
18+
workflows: ["Squad Impact Analysis Request"]
19+
types: [completed]
720

8-
# Security: Using pull_request_target so we get a write token for fork PRs.
9-
# Scripts are checked out from the BASE branch (trusted), not the PR head.
10-
# PR data is fetched read-only via gh CLI — no untrusted code is executed.
1121
permissions:
22+
actions: read
1223
contents: read
1324
pull-requests: write
14-
issues: write
1525

1626
concurrency:
17-
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
27+
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
1828
cancel-in-progress: true
1929

2030
jobs:
2131
impact:
2232
runs-on: ubuntu-latest
2333
timeout-minutes: 5
24-
if: github.actor != 'dependabot[bot]'
34+
if: >-
35+
github.event.workflow_run.event == 'pull_request'
36+
&& github.event.workflow_run.conclusion == 'success'
37+
&& github.event.workflow_run.path == '.github/workflows/squad-impact-request.yml'
38+
&& github.event.workflow_run.actor.login != 'dependabot[bot]'
2539
steps:
26-
- name: Checkout scripts (base branch only)
40+
- name: Checkout scripts (default branch only)
2741
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7
2842
with:
43+
persist-credentials: false
2944
sparse-checkout: |
3045
scripts/analyze-impact.mjs
3146
scripts/impact-utils/parse-diff.mjs
3247
scripts/impact-utils/risk-scorer.mjs
3348
scripts/impact-utils/report-generator.mjs
49+
scripts/workflow-run-pr-binding.mjs
3450
sparse-checkout-cone-mode: false
3551

52+
- name: Download PR claim (untrusted data)
53+
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 #v8
54+
with:
55+
name: squad-impact-request
56+
path: ${{ runner.temp }}/impact-request
57+
run-id: ${{ github.event.workflow_run.id }}
58+
github-token: ${{ github.token }}
59+
60+
- name: Bind claim to the workflow run and live PR
61+
id: bind
62+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9
63+
env:
64+
CLAIM_DIR: ${{ runner.temp }}/impact-request
65+
with:
66+
script: |
67+
const { bindWorkflowRun } = await import(`${process.env.GITHUB_WORKSPACE}/scripts/workflow-run-pr-binding.mjs`);
68+
await bindWorkflowRun({
69+
github,
70+
context,
71+
core,
72+
claimDir: process.env.CLAIM_DIR,
73+
claimWorkflowPath: '.github/workflows/squad-impact-request.yml',
74+
});
75+
3676
- name: Setup Node.js
77+
if: steps.bind.outputs.bound == 'true'
3778
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 #v6
3879
with:
3980
node-version: 22
4081

4182
- name: Run impact analysis
83+
if: steps.bind.outputs.bound == 'true'
4284
env:
4385
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4486
GITHUB_REPOSITORY: ${{ github.repository }}
45-
run: node scripts/analyze-impact.mjs ${{ github.event.pull_request.number }}
87+
PR_NUMBER: ${{ steps.bind.outputs.pr_number }}
88+
run: node scripts/analyze-impact.mjs "$PR_NUMBER"
4689

4790
- name: Post impact report
91+
if: steps.bind.outputs.bound == 'true'
4892
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9
4993
env:
50-
PR_NUMBER: ${{ github.event.pull_request.number }}
94+
PR_NUMBER: ${{ steps.bind.outputs.pr_number }}
5195
with:
5296
script: |
5397
const fs = require('fs');
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: Squad PR Readiness Request
2+
3+
# Unprivileged half of Squad PR Readiness (squad-pr-readiness.yml).
4+
#
5+
# The github org blocks pull_request_target. This `pull_request` run has a
6+
# read-only token and no secrets, checks out nothing, and runs no PR code. It
7+
# only uploads a claim: the PR number and head SHA it ran for. The privileged
8+
# half treats that claim as untrusted, binds it to the workflow_run payload and
9+
# the live PR, and computes every check itself from the GitHub API.
10+
#
11+
# The job keeps the name `readiness` so scripts/pr-readiness.mjs still excludes
12+
# this workflow's own check run from the "CI passing" check (SELF_CHECK_NAMES).
13+
on:
14+
pull_request:
15+
branches: [dev, main, insider]
16+
types: [opened, synchronize, reopened, edited, ready_for_review]
17+
18+
permissions: {}
19+
20+
concurrency:
21+
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
22+
cancel-in-progress: true
23+
24+
jobs:
25+
readiness:
26+
runs-on: ubuntu-latest
27+
timeout-minutes: 2
28+
if: github.actor != 'dependabot[bot]'
29+
steps:
30+
- name: Write PR claim (no checkout, no PR code)
31+
env:
32+
PR_NUMBER: ${{ github.event.pull_request.number }}
33+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
34+
run: |
35+
mkdir -p "$RUNNER_TEMP/claim"
36+
jq -n --argjson pr_number "$PR_NUMBER" --arg head_sha "$HEAD_SHA" \
37+
'{schema: "squad-pr-claim/v1", pr_number: $pr_number, head_sha: $head_sha}' \
38+
> "$RUNNER_TEMP/claim/claim.json"
39+
40+
- name: Upload PR claim
41+
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 #v7
42+
with:
43+
name: squad-pr-readiness-request
44+
path: ${{ runner.temp }}/claim/claim.json
45+
if-no-files-found: error
46+
retention-days: 1
47+
overwrite: true

0 commit comments

Comments
 (0)