Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions .github/workflows/dependency-safety.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}" \
Expand Down
88 changes: 86 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
102 changes: 102 additions & 0 deletions tests/gate-status-guard.bats
Original file line number Diff line number Diff line change
@@ -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" <<EOF
#!/usr/bin/env bash
cat "$TEST_TMP/gh_msg" >&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" ]
}
9 changes: 9 additions & 0 deletions tests/guard-shape.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}