From 1814c52b043b57e31c6655c72fc9bc48eafd468a Mon Sep 17 00:00:00 2001 From: j7an Date: Fri, 5 Jun 2026 22:24:43 -0700 Subject: [PATCH 1/3] fix(safety): handle read-only fork-PR token in gate status write The non-bot status write aborted the job when GitHub downgraded the fork-PR token to read-only, surfacing a permissions boundary as a dependency-safety scan failure. Catch the known 403, emit a notice and job-summary line, stay green; other failures and the bot/final paths stay fail-loud. Refs #79 --- .github/workflows/dependency-safety.yml | 21 ++++- tests/gate-status-guard.bats | 102 ++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 tests/gate-status-guard.bats diff --git a/.github/workflows/dependency-safety.yml b/.github/workflows/dependency-safety.yml index ed3faea..add749e 100644 --- a/.github/workflows/dependency-safety.yml +++ b/.github/workflows/dependency-safety.yml @@ -44,11 +44,28 @@ jobs: PR_AUTHOR: ${{ github.event.pull_request.user.login }} run: | if [[ "$PR_AUTHOR" != "dependabot[bot]" ]]; then - gh api "repos/${GH_REPO}/statuses/${HEAD_SHA}" \ + # Non-bot PR: post a neutral success and skip the scan. The status + # write can fail on external fork PRs, where GitHub downgrades the + # token to read-only even when the caller declares statuses: write. + # Treat ONLY that known denial as expected (notice, stay green); any + # other failure still aborts. Do NOT extend this handling to the + # bot/pending or final status writes — those stay fail-loud. See + # README › Fork PRs and the required gate. + if err=$(gh api "repos/${GH_REPO}/statuses/${HEAD_SHA}" \ -f state="success" \ -f context="dependency-safety / gate" \ -f description="Non-bot PR — no cool-down required" \ - -f target_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + -f target_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" 2>&1); then + : # posted (same-repo non-bot PR) — behaviorally unchanged + elif printf '%s' "$err" | grep -q "Resource not accessible by integration" \ + && printf '%s' "$err" | grep -Eq 'HTTP 403|"status":"403"'; then + MSG="dependency-safety: cannot post the 'dependency-safety / gate' commit status — read-only token or missing statuses:write permission. On external fork PRs this is expected; see README › Fork PRs and the required gate." + echo "::notice::${MSG}" + echo "$MSG" >> "$GITHUB_STEP_SUMMARY" + else + printf '%s\n' "$err" >&2 + exit 1 + fi echo "skip=true" >> "$GITHUB_OUTPUT" else gh api "repos/${GH_REPO}/statuses/${HEAD_SHA}" \ diff --git a/tests/gate-status-guard.bats b/tests/gate-status-guard.bats new file mode 100644 index 0000000..2a095e9 --- /dev/null +++ b/tests/gate-status-guard.bats @@ -0,0 +1,102 @@ +#!/usr/bin/env bats +# gate-status-guard.bats — runtime tests for the `Set initial status` gate +# step's NON-BOT status write (issue #79). The non-bot success POST must: +# - stay green + emit a notice when the status write is denied by a +# read-only token (HTTP 403 "Resource not accessible by integration"), +# i.e. the external-fork-PR case; +# - still fail loudly on any OTHER gh error; +# - behave unchanged (skip=true, no notice) when the write succeeds. +# +# The bot/pending branch and the final status write are intentionally NOT +# guarded and are not exercised here. + +YAML=".github/workflows/dependency-safety.yml" + +setup() { + TEST_TMP=$(mktemp -d) + STUB_BIN="$TEST_TMP/bin" + mkdir -p "$STUB_BIN" + export GITHUB_OUTPUT="$TEST_TMP/out" + export GITHUB_STEP_SUMMARY="$TEST_TMP/summary" + : > "$GITHUB_OUTPUT" + : > "$GITHUB_STEP_SUMMARY" + # Env the gate block reads (normally supplied by the step's `env:` map). + export GH_REPO="octo/example" + export HEAD_SHA="deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + export GITHUB_SERVER_URL="https://github.com" + export GITHUB_REPOSITORY="octo/example" + export GITHUB_RUN_ID="123" + # Non-bot author → exercises the guarded branch. + export PR_AUTHOR="alice" +} + +teardown() { + rm -rf "$TEST_TMP" +} + +# Extract the `Set initial status` step's run-block body as plain bash. +extract_gate_block() { + awk ' + /^ - name: Set initial status$/ { in_step = 1 } + in_step && /^ run: \|$/ { in_run = 1; next } + in_run && /^ - name: / { exit } + in_run { print } + ' "$YAML" | sed -E 's/^ //' +} + +# Write a fake `gh` that prints $1 to stderr and exits $2. The message is +# stored in a file the stub reads at runtime, so quoting in the message — e.g. +# the JSON 403 body {"message":"...","status":"403"} — cannot corrupt the +# generated stub script. +write_gh_stub() { + printf '%s' "$1" > "$TEST_TMP/gh_msg" + cat > "$STUB_BIN/gh" <&2 +echo >&2 +exit $2 +EOF + chmod +x "$STUB_BIN/gh" +} + +# Run the extracted gate block under a GHA-equivalent shell with the stub on PATH. +run_gate_block() { + local f="$TEST_TMP/gate.sh" + { echo 'set -eo pipefail'; extract_gate_block; } > "$f" + PATH="$STUB_BIN:$PATH" run bash "$f" +} + +@test "non-bot fork PR: read-only 403 → notice, green job, skip=true" { + write_gh_stub "gh: Resource not accessible by integration (HTTP 403)" 1 + run_gate_block + [ "$status" -eq 0 ] + [[ "$output" == *"::notice::"* ]] + grep -q "skip=true" "$GITHUB_OUTPUT" + grep -q "cannot post the 'dependency-safety / gate' commit status" "$GITHUB_STEP_SUMMARY" +} + +@test "non-bot fork PR: JSON 403 body → notice, green job, skip=true" { + write_gh_stub '{"message":"Resource not accessible by integration","status":"403","documentation_url":"https://docs.github.com/rest"}' 1 + run_gate_block + [ "$status" -eq 0 ] + [[ "$output" == *"::notice::"* ]] + grep -q "skip=true" "$GITHUB_OUTPUT" + grep -q "cannot post the 'dependency-safety / gate' commit status" "$GITHUB_STEP_SUMMARY" +} + +@test "non-bot PR: unrelated gh error still fails loudly" { + write_gh_stub "gh: HTTP 500 Internal Server Error" 1 + run_gate_block + [ "$status" -ne 0 ] + ! grep -q "skip=true" "$GITHUB_OUTPUT" + [ ! -s "$GITHUB_STEP_SUMMARY" ] +} + +@test "non-bot same-repo PR: status write succeeds → skip=true, no notice" { + write_gh_stub "" 0 + run_gate_block + [ "$status" -eq 0 ] + grep -q "skip=true" "$GITHUB_OUTPUT" + [[ "$output" != *"::notice::"* ]] + [ ! -s "$GITHUB_STEP_SUMMARY" ] +} From 2e06f781fed5377862427bc50e846bf5856ca7db Mon Sep 17 00:00:00 2001 From: j7an Date: Fri, 5 Jun 2026 22:42:20 -0700 Subject: [PATCH 2/3] fix(safety): add shape guard for permission-aware gate write Refs #79 --- tests/guard-shape.bats | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/guard-shape.bats b/tests/guard-shape.bats index 166d2e2..668e859 100644 --- a/tests/guard-shape.bats +++ b/tests/guard-shape.bats @@ -35,3 +35,12 @@ WORKFLOWS=( [ "$count" -ge 2 ] || { echo "FAIL: $yaml has only $count classify_touched_paths references"; return 1; } done } + +@test "dependency-safety.yml: non-bot status write is permission-aware (dual matcher)" { + yaml=".github/workflows/dependency-safety.yml" + # The non-bot success POST is captured (not unconditional). + grep -q 'if err=$(gh api "repos/${GH_REPO}/statuses/${HEAD_SHA}"' "$yaml" + # The known read-only denial is matched on BOTH the permission message and a 403 marker. + grep -q "Resource not accessible by integration" "$yaml" + grep -Eq "HTTP 403|\"status\":\"403\"" "$yaml" +} From bb968ddec4b35390913154c85d4474fa9b822398 Mon Sep 17 00:00:00 2001 From: j7an Date: Fri, 5 Jun 2026 22:50:22 -0700 Subject: [PATCH 3/3] fix(safety): document fork-PR read-only token boundary and safe companion Explain that external fork PRs receive a read-only token despite statuses: write, that the workflow now logs a notice instead of a red scan failure, and add a status-only pull_request_target companion that posts the required gate without checking out or running PR code. Refs #79 --- README.md | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6efbd36..3ea3ff5 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Reusable GitHub Actions workflows for dependency safety verification and release - **Dependabot** configured for your repo (GitHub Actions and/or pip/uv ecosystems) - **Native cool-down** configured in `.github/dependabot.yml` (see Quick Start) -- **No Renovate** — this workflow only scans `dependabot[bot]` PRs; other actors are passed through with a success status +- **No Renovate** — this workflow only scans `dependabot[bot]` PRs; other actors are passed through with a success status (except external fork PRs, whose read-only token can't post the status — see [Fork PRs and the required gate](#fork-prs-and-the-required-gate)) > **Scope: version-update PRs.** Dependabot's native `cooldown:` setting applies > only to *version updates*, not [security updates][gh-cooldown-scope]. The @@ -90,6 +90,15 @@ jobs: > the last cooldown-bearing release (frozen, no further updates). Releases > in this repo are dispatched manually — see [Versioning](#versioning). +> **External fork PRs:** by default GitHub gives `GITHUB_TOKEN` a **read-only** +> token on PRs from forks even when you declare `statuses: write` — unless a +> repo admin has enabled **Send write tokens to workflows from pull requests** +> ([docs](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#changing-the-permissions-in-a-forked-repository)). +> With a read-only token the reusable workflow cannot post the +> `dependency-safety / gate` status from the fork run — it logs a notice and the +> job stays green rather than failing. If you make that status **required**, see +> [Fork PRs and the required gate](#fork-prs-and-the-required-gate). + ## Inputs | Input | Type | Default | Description | @@ -118,7 +127,7 @@ Dependabot opens the PR (target version is now ≥ cooldown days old) │ ▼ dependency-safety.yml fires on pull_request - ├── Non-dependabot PR? → status "success" (no-op) + ├── Non-dependabot PR? → status "success" (no-op; external forks can't post — see "Fork PRs and the required gate") ├── Status → "pending" ("Scanning dependencies for safety...") ├── Parses diff to extract package names + target versions │ ├── Falls back to PR body text when inline versions are absent @@ -167,6 +176,81 @@ Labels: Reconciliation is authoritative when the scan succeeds. On the `error` path, labels are preserved (not removed) since the verdict is unreliable. +## Fork PRs and the required gate + +`dependency-safety.yml` is a **Dependabot-automation** gate. It scans +`dependabot[bot]` PRs and passes every other actor through with a neutral +`success`. Human PRs from a branch **in your repo** also get that `success` +write, because their token can write commit statuses. + +**External fork PRs are different.** For a `pull_request` triggered from a fork, +GitHub gives `GITHUB_TOKEN` a read-only token on your repo by default — +`statuses` included — *even when the caller workflow declares* `statuses: write` +([GitHub docs][fork-perms]). The one exception is a repo admin enabling +**Send write tokens to workflows from pull requests** in the repository's +Actions settings; with that off (the default), the fork run cannot create the +`dependency-safety / gate` commit status. + +**What the workflow does:** on a fork PR it attempts the status write, detects +the read-only denial (`HTTP 403 Resource not accessible by integration`), logs a +`notice` and a job-summary line, and **finishes green**. It does *not* present +this as a dependency-safety scan failure. Genuine errors — and the real +Dependabot scan/status path — still fail loudly. + +**The unavoidable gap:** a green job still cannot *post* the status from a fork +run. If you require `dependency-safety / gate` as a status check, fork PRs will +sit with that check **unsatisfied** until a *trusted* path posts it. The +reusable workflow cannot close this gap from the fork run — add a companion in +your repo. + +**Recommended safe companion** — a status-only `pull_request_target` job that +posts the gate for fork PRs and **never checks out or runs PR-authored code**: + +```yaml +# .github/workflows/fork-pr-gate.yml (your repo — adapt as needed) +name: Fork PR dependency-safety gate +on: + pull_request_target: + types: [opened, synchronize, reopened] +permissions: + statuses: write # nothing else +jobs: + gate: + # cross-repo (fork) PRs only — same-repo PRs are handled by the reusable workflow + if: github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id + runs-on: ubuntu-latest + steps: + # NO checkout. This job never fetches or runs PR-authored code. + - name: Post neutral gate status + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + gh api "repos/${GH_REPO}/statuses/${HEAD_SHA}" \ + -f state="success" \ + -f context="dependency-safety / gate" \ + -f description="Fork PR: dependency-safety scan not run; human review required" \ + -f target_url="${RUN_URL}" +``` + +**Why this is safe:** `pull_request_target` runs in your repo's context with a +write token — exactly what's needed to post the status — and it is safe here +*only because* the job has no `checkout`, runs no PR code, requests +`statuses: write` and nothing else, and reads every PR-derived value through +`env:` (never interpolated with `${{ … }}` inside `run:`). This is the +constrained, status-only use of `pull_request_target` — **not** the broad +"build/test the PR with elevated permissions" pattern, which would expose your +secrets to fork-authored code. If a blanket `success` is too permissive for +your repo, post `pending` instead and require a maintainer to flip the status +after review. If you run [Zizmor](#security-analysis-zizmor) on your repo, it +will flag this file for its `pull_request_target` trigger — that finding is +expected here; the constraints above (no `checkout`, `statuses: write` only, +`env:` indirection) are exactly the safe envelope to verify. + +[fork-perms]: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#changing-the-permissions-in-a-forked-repository + ## v2 → v3 migration `v3.0.0` removed the deprecated `dependency-cooldown.yml` and