Zum Inhalt

Branch-Protection-Audit

Vergleicht die Branch-Protection, die ein Repository in .github/settings.yml deklariert, mit dem, was GitHub tatsächlich durchsetzt, und scheitert, wenn beides auseinanderfällt.

Der Fehler, den das Audit sucht, ist von Natur aus unsichtbar. Die Deklaration landet in der Datei, die Probot Settings App meldet nichts, und die Protection greift nie. Auffallen würde es erst bei einem Merge, der hätte blockiert werden müssen.


Warum ein Skript und nicht eine Settings-Prüfung

Die Durchsetzung stammt aus zwei unabhängigen Mechanismen, und die Vereinigung beider entscheidet über einen Merge:

Mechanismus Endpunkt Geschrieben von
klassische Branch-Protection /branches/{branch}/protection der Probot Settings App, aus .github/settings.yml
Repository- und Organisations-Rulesets /rules/branches/{branch} nicht von der Settings App

Wer nur den ersten liest, meldet einen Branch als ungeschützt, während ein Ruleset ihn durchsetzt. Das Audit liest beide.


Verwendung

.github/workflows/portfolio-branch-protection-audit.yml
on:
  schedule:
    - cron: "0 6 * * 1"
  workflow_dispatch:

permissions:
  contents: read

jobs:
  audit:
    permissions:
      contents: read
    uses: nolte/gh-plumbing/.github/workflows/reusable-branch-protection-audit.yaml@<tag-oder-commit-sha>
    with:
      repos: ""
      branch: develop
      app-id: ${{ vars.PORTFOLIO_APP_ID }}
    secrets:
      token: ${{ secrets.GITHUB_TOKEN }}
      app-private-key: ${{ secrets.PORTFOLIO_APP_PRIVATE_KEY }}
Input Default Wirkung
repos "" Leerzeichengetrennte owner/name-Liste. Leer erfasst jedes nicht archivierte Repository des Owners, das kein Fork ist.
branch develop Der Branch, dessen Protection verglichen wird.
app-id "" Numerische App-ID. Für einen portfolio-weiten Lauf nötig, den ein einfacher GITHUB_TOKEN nicht leisten kann.

Zeitgesteuert laufen lassen, nicht am Pull Request

Der Fehler ist Drift über die Zeit. Protection, die einmal griff, kann aufhören zu greifen, und kein Commit markiert den Moment. Wöchentlich genügt; ein täglicher Lauf erzieht die Leserschaft dazu, ihn zu übersehen.


Das Verdikt lesen

Status Bedeutung
ok Alles Deklarierte wird durchgesetzt, von einem der beiden Mechanismen.
drift Ein deklarierter Kontext wird von keinem durchgesetzt. Genau dafür gibt es das Audit.
unprotected Kontexte sind deklariert, und nichts setzt irgendetwas durch.
unreadable Mindestens ein Endpunkt hat den Lesezugriff verweigert, ein Teil der Antwort bleibt also unbekannt.
no-settings Das Repository hat keine .github/settings.yml.

unreadable ist nicht unprotected. Ein verweigerter Lesezugriff sagt nichts über den Branch aus, und ihn als ungeschützt zu melden schickt jemanden los, um ein korrekt konfiguriertes Repository zu „reparieren".

Der App-Token braucht administration: read

Das Lesen klassischer Branch-Protection erfordert diese Berechtigung, der Ruleset-Endpunkt nicht. Eine App, die nur Letzteres hat, meldet unreadable, zählt die Ruleset-Kontexte mit und lässt die klassische Hälfte unbekannt — korrekt, aber nur die halbe Antwort. Siehe Issue #387.


Zentrale Konfiguration

.github/workflows/reusable-branch-protection-audit.yaml
name: Reusable — Branch Protection Audit

# Compares branch protection declared in `.github/settings.yml` against what
# GitHub actually enforces, and fails when they differ.
#
# This exists because that gap is invisible: no error, no log, no failed check.
# The declaration looks correct, the Probot Settings App reports nothing, and
# the protection is absent. Nobody notices until a merge that should have been
# blocked goes through. See issue #387 and ADR-002.
#
# The audit is read-only. It never repairs protection -- deciding what the
# commons should declare is a separate question that this data is meant to
# inform, not pre-empt.

on:
  workflow_call:
    inputs:
      repos:
        description: |
          Space-separated `owner/name` list to survey. Empty (the default)
          surveys every non-archived, non-fork repository of `owner`.
        required: false
        type: string
        default: ""
      owner:
        description: Account whose repositories are surveyed when `repos` is empty.
        required: false
        type: string
        default: nolte
      branch:
        description: Branch whose protection is compared.
        required: false
        type: string
        default: develop
      app-id:
        description: |
          Numeric GitHub App ID. When set, the audit runs under a short-lived
          installation token, which is the only way to read protection across
          repositories the workflow's own `GITHUB_TOKEN` cannot reach. When
          empty, it falls through to `secrets.token` -- fine for a single-repo
          audit, insufficient for a portfolio-wide one.
        required: false
        type: string
        default: ""
    secrets:
      token:
        required: true
      app-private-key:
        description: PEM key for `inputs.app-id`. Required when it is set.
        required: false

permissions:
  contents: read

concurrency:
  group: branch-protection-audit-${{ github.ref }}
  cancel-in-progress: false

jobs:
  audit:
    name: Branch Protection Audit
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Mint App installation token
        id: app-token
        if: ${{ inputs.app-id != '' }}
        # Not continue-on-error, unlike the release workflows: there the
        # fallback still does useful work, whereas here a degraded token makes
        # every repository look unreadable or unprotected. A wrong answer is
        # worse than no answer for an audit.
        uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
        with:
          app-id: ${{ inputs.app-id }}
          private-key: ${{ secrets.app-private-key }}
          owner: ${{ inputs.owner }}

      - name: Checkout sources
        uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0

      - name: Set up Python
        uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
        with:
          python-version: "3.14"

      - name: Install PyYAML
        run: pip install --require-hashes --no-deps -r scripts/requirements-audit.txt

      - name: Compare declared against enforced
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token || secrets.token }}
          REPOS: ${{ inputs.repos }}
          OWNER: ${{ inputs.owner }}
          BRANCH: ${{ inputs.branch }}
        run: |
          set -euo pipefail
          # shellcheck disable=SC2086  # REPOS is a deliberate word-split list
          python3 scripts/branch_protection_audit.py \
            --owner "${OWNER}" --branch "${BRANCH}" ${REPOS}