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
46 changes: 46 additions & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,52 @@ This directory hosts reusable workflows under `j7an/shared-workflows`. Consumers

> **Note:** `@v3` and `@v2` continue to work at their last-released revisions, but receive no further updates. See the root README's "v3 → v4 migration" section.

## `dependency-safety-non-bot-gate.yml`

Posts the required `dependency-safety / gate` commit status for pull requests
whose author is not `dependabot[bot]`. This is a status-only companion for
repos whose real `dependency-safety.yml` scanner caller is gated to
Dependabot-only.

The reusable workflow is `workflow_call`-only. The consumer keeps the trusted
`pull_request_target` trigger in a tiny local wrapper:

```yaml
name: Dependency Safety Non-Bot Gate

on:
pull_request_target: # zizmor: ignore[dangerous-triggers] status-only path; never checks out or runs PR code
types: [opened, synchronize, reopened]
branches: [main] # include every branch where dependency-safety / gate is required

permissions: {}

concurrency:
group: dep-safety-gate-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
gate:
permissions:
statuses: write
uses: j7an/shared-workflows/.github/workflows/dependency-safety-non-bot-gate.yml@v4
```

Pair it with a scanner caller that uses the complementary condition:

```yaml
jobs:
safety:
if: github.event.pull_request.user.login == 'dependabot[bot]'
uses: j7an/shared-workflows/.github/workflows/dependency-safety.yml@v4
secrets: inherit
```

The wrapper must not check out code, pass `secrets: inherit`, install
dependencies, or run PR-authored files. The wrapper grants `statuses: write`;
the reusable workflow requests `statuses: write`; the status is posted to
`github.event.pull_request.head.sha` with context `dependency-safety / gate`.

## `tag-release.yml`

Computes the next semver tag from Conventional Commits since the last tag, optionally bumps version files, and pushes the new tag (which typically triggers a downstream release workflow).
Expand Down
35 changes: 35 additions & 0 deletions .github/workflows/dependency-safety-non-bot-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Dependency Safety Non-Bot Gate

on:
workflow_call:

permissions: {}

jobs:
gate:
name: Post dependency-safety gate
# Complements a scanner caller gated to Dependabot only. Use the PR author
# field from the pull_request payload; github.actor can change on
# synchronize events and must not drive this partition.
if: github.event.pull_request.user.login != 'dependabot[bot]'
runs-on: ubuntu-latest
permissions:
# Required to create the dependency-safety / gate commit status.
statuses: write
steps:
- name: Post dependency-safety gate status
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if [ -z "${HEAD_SHA:-}" ]; then
echo "::error::dependency-safety non-bot gate requires a pull_request_target caller"
exit 1
fi

gh api "repos/${GH_REPO}/statuses/${HEAD_SHA}" \
-f state=success \
-f context="dependency-safety / gate" \
-f description="Non-bot PR: dependency-safety scan not required" \
-f target_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
140 changes: 94 additions & 46 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,55 +172,106 @@ Reconciliation is authoritative when the scan succeeds. On the `error` path, lab

## 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**:
`dependency-safety.yml` is a **Dependabot-automation** gate. Repos that make
`dependency-safety / gate` required need exactly one trusted writer for every
pull request head SHA. The correct companion depends on how your scanner caller
is gated.

**Recommended matched pair: Dependabot-gated scanner plus non-bot gate.** This
is the cleanest shape for repos that require `dependency-safety / gate` on all
PRs: Dependabot PRs run the real scanner, and every other PR gets a
status-only gate.

```yaml
# .github/workflows/dependency-safety.yml
name: Dependency Safety

on:
pull_request:
types: [opened, synchronize, reopened]

permissions:
contents: write
pull-requests: write
statuses: write
issues: write

concurrency:
group: dependency-safety-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
safety:
# Real scan for Dependabot only; non-Dependabot PRs are handled by
# dependency-safety-non-bot-gate.yml. Keep this field paired with the
# non-bot gate's complementary condition.
if: github.event.pull_request.user.login == 'dependabot[bot]'
uses: j7an/shared-workflows/.github/workflows/dependency-safety.yml@v4
secrets: inherit
```

```yaml
# .github/workflows/dependency-safety-non-bot-gate.yml
name: Dependency Safety Non-Bot Gate

on:
pull_request_target: # zizmor: ignore[dangerous-triggers] status-only path; never checks out or runs PR code
types: [opened, synchronize, reopened]
branches: [main] # include every branch where dependency-safety / gate is required

permissions: {}

concurrency:
group: dep-safety-gate-${{ github.event.pull_request.number }}
cancel-in-progress: true

jobs:
gate:
permissions:
statuses: write
uses: j7an/shared-workflows/.github/workflows/dependency-safety-non-bot-gate.yml@v4
```

This non-bot gate is the companion to a Dependabot-gated scanner caller. If
your scanner caller runs ungated, use the fork-only pattern below instead. Do
not mix a Dependabot-gated scanner with a fork-only gate: same-repo human PRs
would have no writer for the required status. The non-bot wrapper's `branches:`
filter must cover every branch where a ruleset or branch protection rule
requires `dependency-safety / gate`.

**Why the wrapper is safe:** `pull_request_target` runs in your repo's context
with a write token, which is exactly what is needed to post the status, and it
is safe here only because the wrapper delegates to a status-only reusable
workflow. The reusable workflow performs no checkout, uses no third-party
actions, runs no dependency install/build/test, executes no PR-authored files,
requests only `statuses: write`, uses only the automatic `github.token`, and
passes PR-derived values into shell through `env:`. Do not add
`secrets: inherit` to the non-bot gate wrapper.

**Alternative: ungated scanner plus fork-only gate.** If your scanner caller
runs on every `pull_request`, same-repo non-bot PRs are handled by the scanner's
own pass-through branch. In that architecture, add only a fork companion:

```yaml
# .github/workflows/fork-pr-gate.yml (your repo — adapt as needed)
# .github/workflows/fork-pr-gate.yml
name: Fork PR dependency-safety gate
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
statuses: write # nothing else
statuses: write
jobs:
gate:
# cross-repo (fork) PRs onlysame-repo PRs are handled by the reusable workflow
# cross-repo fork PRs only; same-repo PRs are handled by the scanner 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 }}
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_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh api "repos/${GH_REPO}/statuses/${HEAD_SHA}" \
-f state="success" \
Expand All @@ -229,19 +280,16 @@ jobs:
-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.
External fork PRs get a read-only `GITHUB_TOKEN` under `pull_request` by
default, even when the caller declares `statuses: write`, unless a repo admin
enables **Send write tokens to workflows from pull requests** ([docs][fork-perms]).
The trusted `pull_request_target` wrapper closes that required-status gap
without running untrusted PR code.

If you run [Zizmor](#security-analysis-zizmor), it will flag the wrapper's
`pull_request_target` trigger. That finding is expected for this constrained,
status-only pattern; verify the no-checkout, no-PR-code, `statuses: write`-only
envelope instead of suppressing the architectural review.

[fork-perms]: https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#changing-the-permissions-in-a-forked-repository

Expand Down
86 changes: 86 additions & 0 deletions tests/non-bot-gate-contract.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env bats
# non-bot-gate-contract.bats - static contract tests for the reusable
# dependency-safety non-bot gate workflow.

YAML=".github/workflows/dependency-safety-non-bot-gate.yml"

extract_on_block() {
awk '
/^on:$/ { flag=1; print; next }
flag && /^[^[:space:]][^:]*:/ { exit }
flag { print }
' "$YAML"
}

extract_gate_job_block() {
awk '
/^ gate:$/ { flag=1; print; next }
flag && /^ [A-Za-z0-9_-]+:/ { exit }
flag { print }
' "$YAML"
}

extract_job_permissions_block() {
extract_gate_job_block | awk '
/^ permissions:$/ { flag=1; next }
flag && /^ [A-Za-z0-9_-]+:/ { exit }
flag { print }
'
}

extract_run_block() {
awk '
/^ - name: Post dependency-safety gate status$/ { in_step = 1 }
in_step && /^ run: \|$/ { in_run = 1; next }
in_run && /^ - name: / { exit }
in_run { print }
' "$YAML" | sed -E 's/^ //'
}

non_comment_content() {
sed '/^[[:space:]]*#/d' "$YAML"
}

@test "workflow is reusable only: workflow_call trigger and no pull_request_target trigger" {
block=$(extract_on_block)
[[ "$block" == *"workflow_call:"* ]]
[[ "$block" != *"pull_request_target:"* ]]
}

@test "non-comment workflow logic does not use github.actor" {
if non_comment_content | grep -q "github.actor"; then
echo "github.actor must not be used as workflow logic"
return 1
fi
}

@test "gate job uses pull_request.user.login complement of scanner caller" {
grep -qF "if: github.event.pull_request.user.login != 'dependabot[bot]'" "$YAML"
}

@test "gate job requests exactly statuses: write" {
[ "$(extract_job_permissions_block | sed '/^[[:space:]]*#/d')" = " statuses: write" ]
}

@test "status post uses canonical context, description, and caller run target_url" {
grep -qF -- '-f context="dependency-safety / gate"' "$YAML"
grep -qF -- '-f description="Non-bot PR: dependency-safety scan not required"' "$YAML"
grep -qF -- '-f target_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"' "$YAML"
}

@test "workflow has no checkout and no third-party action steps" {
if grep -qF "actions/checkout" "$YAML"; then
echo "checkout is forbidden in the status-only gate"
return 1
fi

if grep -Eq '^[[:space:]]+-[[:space:]]+uses:' "$YAML"; then
echo "third-party action steps are forbidden in the status-only gate"
return 1
fi
}

@test "run block does not interpolate PR event context directly" {
block=$(extract_run_block)
[[ "$block" != *'${{ github.event.pull_request'* ]]
}
Loading
Loading