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
140 changes: 140 additions & 0 deletions .github/branch-protection/apply-ruleset.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
#
# apply-ruleset.sh — create or update a GitHub repository ruleset.
#
# Idempotent: looks up the ruleset by name. If it exists, updates it
# (PUT); otherwise creates it (POST).
#
# Prerequisites:
# - gh CLI authenticated with a token that has Administration: write
# (e.g. a GitHub App installation token)
# - jq
#
# Usage:
# ./apply-ruleset.sh <owner/repo> <ruleset.json>
#
# Example (from repository root):
# .github/branch-protection/apply-ruleset.sh stella/stella \
# .github/branch-protection/ruleset-main.json
#
# # With a GitHub App token:
# GH_TOKEN="$(mint-token)" .github/branch-protection/apply-ruleset.sh \
# stella/stella .github/branch-protection/ruleset-main.json
#
# The script also supports adding the built-in GitHub Actions app as
# a bypass actor via --github-actions-bypass. This looks up the
# installation ID automatically and injects it into the payload.

set -euo pipefail

usage() {
echo "Usage: $0 [--github-actions-bypass] <owner/repo> <ruleset.json>"
exit 1
}

GITHUB_ACTIONS_BYPASS=false

while [[ "${1:-}" == --* ]]; do
case "$1" in
--github-actions-bypass)
GITHUB_ACTIONS_BYPASS=true
shift
;;
*)
echo "Unknown option: $1"
usage
;;
esac
done

if [ $# -ne 2 ]; then
usage
fi

REPO="$1"
RULESET_FILE="$2"

if [ ! -f "${RULESET_FILE}" ]; then
echo "Error: ruleset file not found: ${RULESET_FILE}"
exit 1
fi

# Read the ruleset config.
PAYLOAD=$(cat "${RULESET_FILE}")
RULESET_NAME=$(echo "${PAYLOAD}" | jq -r '.name')

if [ -z "${RULESET_NAME}" ] || [ "${RULESET_NAME}" = "null" ]; then
echo "Error: ruleset JSON must have a 'name' field."
exit 1
fi

echo "Ruleset: ${RULESET_NAME}"
echo "Target repo: ${REPO}"

# Optionally inject the GitHub Actions bypass actor.
if [ "${GITHUB_ACTIONS_BYPASS}" = true ]; then
echo "Looking up GitHub Actions app installation ID..."

# The built-in GitHub Actions app slug is "github-actions".
# We need the installation ID for the target repo.
INSTALLATION_ID=$(
gh api "repos/${REPO}/installations" \
--paginate \
--jq '.[] | select(.app_slug == "github-actions") | .id' \
2>/dev/null || true
)
Comment thread
jan-kubica marked this conversation as resolved.
Comment on lines +80 to +85
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Wrong jq filter for repos/{owner}/{repo}/installations API response structure

The --github-actions-bypass flag silently never works because the jq filter assumes the API returns a bare array, but GET /repos/{owner}/{repo}/installations returns an object {"total_count": N, "installations": [...]}. The filter .[] | select(.app_slug == "github-actions") | .id iterates over the top-level object values (a number and an array), causing a jq error when it tries to index a number with .app_slug. Since 2>/dev/null || true suppresses the error, INSTALLATION_ID is always empty, and the script prints the warning and skips adding the bypass actor.

Root Cause and Impact

At apply-ruleset.sh:83, the jq filter is:

.[] | select(.app_slug == "github-actions") | .id

But the API response shape is:

{"total_count": 1, "installations": [{"id": 123, "app_slug": "github-actions"}]}

So .[] yields 1 (the total_count number) and [{...}] (the installations array). Applying select(.app_slug == ...) on the number 1 causes a jq type error, which is swallowed by 2>/dev/null || true at line 84.

The correct filter should be .installations[] | select(.app_slug == "github-actions") | .id.

Impact: The --github-actions-bypass flag is completely non-functional. Users who rely on it (e.g., for SBOM workflows that push to main) will not get the bypass actor added, potentially blocking those workflows.

Suggested change
INSTALLATION_ID=$(
gh api "repos/${REPO}/installations" \
--paginate \
--jq '.[] | select(.app_slug == "github-actions") | .id' \
2>/dev/null || true
)
INSTALLATION_ID=$(
gh api "repos/${REPO}/installations" \
--paginate \
--jq '.installations[] | select(.app_slug == "github-actions") | .id' \
2>/dev/null || true
)
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


if [ -z "${INSTALLATION_ID}" ]; then
echo "Warning: could not find GitHub Actions installation ID."
echo "The github-actions[bot] bypass will NOT be added."
echo "You may need to add it manually in the GitHub UI."
else
echo "GitHub Actions installation ID: ${INSTALLATION_ID}"
PAYLOAD=$(
echo "${PAYLOAD}" | jq \
--argjson id "${INSTALLATION_ID}" \
'.bypass_actors += [{
"actor_id": $id,
"actor_type": "Integration",
"bypass_mode": "always"
}]'
)
fi
fi

# Check if the ruleset already exists.
echo "Checking for existing ruleset..."
EXISTING=$(
gh api "repos/${REPO}/rulesets" \
--paginate |
jq -r --arg name "${RULESET_NAME}" \
'.[] | select(.name == $name) | .id'
)
Comment thread
jan-kubica marked this conversation as resolved.
Comment thread
jan-kubica marked this conversation as resolved.

# Guard against multiple rulesets with the same name.
if [ "$(echo "${EXISTING}" | grep -c .)" -gt 1 ]; then
echo "Error: found multiple rulesets named '${RULESET_NAME}'."
echo "Resolve duplicates manually before running this script."
exit 1
fi

if [ -n "${EXISTING}" ]; then
RULESET_ID="${EXISTING}"
echo "Found existing ruleset (ID: ${RULESET_ID}). Updating..."
Comment thread
jan-kubica marked this conversation as resolved.

gh api "repos/${REPO}/rulesets/${RULESET_ID}" \
--method PUT \
--input - <<< "${PAYLOAD}" \
--jq '{ id, name, enforcement, updated_at: .updated_at }'

echo "Ruleset updated."
else
echo "No existing ruleset found. Creating..."

gh api "repos/${REPO}/rulesets" \
--method POST \
--input - <<< "${PAYLOAD}" \
--jq '{ id, name, enforcement, created_at: .created_at }'

echo "Ruleset created."
fi
150 changes: 150 additions & 0 deletions .github/workflows/audit-branch-protection.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Reusable workflow: audit branch protection rulesets.
#
# Compares live GitHub rulesets against a checked-in expected
# config and uploads the result as compliance evidence.
#
# Called by individual repos via a thin caller workflow.

name: Audit Branch Protection

on:
workflow_call:
inputs:
expected-config-path:
description: >
Path to the expected ruleset JSON in the calling repo.
type: string
default: .github/branch-protection/ruleset-main.json
secrets:
BRANCH_PROTECTION_APP_ID:
description: GitHub App ID for branch protection
required: true
BRANCH_PROTECTION_APP_KEY:
description: GitHub App private key (PEM)
required: true

jobs:
audit:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read

steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- name: Create GitHub App token
id: app-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2
with:
app-id: ${{ secrets.BRANCH_PROTECTION_APP_ID }}
private-key: ${{ secrets.BRANCH_PROTECTION_APP_KEY }}

- name: Fetch live rulesets
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
REPO: ${{ github.repository }}
run: |
gh api "repos/${REPO}/rulesets" \
--paginate \
--slurp \
--jq 'add // []' > live-rulesets.json

echo "::group::Live rulesets"
cat live-rulesets.json | jq .
echo "::endgroup::"

- name: Drift detection
env:
EXPECTED_PATH: ${{ inputs.expected-config-path }}
run: |
if [ ! -f "${EXPECTED_PATH}" ]; then
echo "::error::Expected config not found: ${EXPECTED_PATH}"
exit 1
fi

EXPECTED_NAME=$(jq -r '.name' "${EXPECTED_PATH}")
echo "Looking for ruleset: ${EXPECTED_NAME}"

# Extract the matching ruleset from the live config.
# The live API returns an array; find by name.
jq --arg name "${EXPECTED_NAME}" \
'.[] | select(.name == $name)' \
live-rulesets.json > live-matched.json

if [ ! -s live-matched.json ]; then
echo "::error::Ruleset '${EXPECTED_NAME}' not found on GitHub."
echo "DRIFT_DETECTED=true" >> "$GITHUB_ENV"
exit 0
fi

# Normalize both configs for comparison.
# Strip server-managed fields from the live config so we
# compare only the fields we control.
LIVE_FIELDS=(
name target enforcement conditions
bypass_actors rules
)

LIVE_JQ_FILTER=$(printf ', %s' "${LIVE_FIELDS[@]}")
LIVE_JQ_FILTER="{ ${LIVE_JQ_FILTER:2} }"

jq "${LIVE_JQ_FILTER}" live-matched.json \
> live-normalized.json
jq -S '.' "${EXPECTED_PATH}" > expected-sorted.json
jq -S '.' live-normalized.json > live-sorted.json

if diff expected-sorted.json live-sorted.json > drift.diff; then
echo "No drift detected."
echo "DRIFT_DETECTED=false" >> "$GITHUB_ENV"
else
echo "::warning::Drift detected in ruleset '${EXPECTED_NAME}'."
echo "::group::Diff (expected vs live)"
cat drift.diff
echo "::endgroup::"
echo "DRIFT_DETECTED=true" >> "$GITHUB_ENV"
fi

- name: Prepare evidence
run: |
mkdir -p evidence
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
REPO="${{ github.repository }}"

jq -n \
--arg repo "${REPO}" \
--arg ts "${TIMESTAMP}" \
--arg sha "${{ github.sha }}" \
--arg run "${{ github.run_id }}" \
--arg drift "${DRIFT_DETECTED}" \
--slurpfile live live-rulesets.json \
'{
repository: $repo,
timestamp: $ts,
commit_sha: $sha,
workflow_run_id: $run,
drift_detected: ($drift == "true"),
live_rulesets: $live[0]
}' > evidence/branch-protection-audit.json

if [ -f drift.diff ]; then
cp drift.diff evidence/
fi

echo "::group::Evidence"
cat evidence/branch-protection-audit.json | jq .
echo "::endgroup::"

- name: Upload evidence
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: branch-protection-evidence-${{ github.run_id }}
path: evidence/
retention-days: 365

- name: Fail on drift
if: env.DRIFT_DETECTED == 'true'
run: |
echo "::error::Branch protection drift detected. Review the evidence artifact."
exit 1
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Organization-wide GitHub configurations, reusable workflows, and templates.
|----------|-------------|
| `pr-lint.yml` | PR linting: conventional commits, labels, auto-assign |
| `base-checks.yml` | Composite workflow calling pr-lint |
| `audit-branch-protection.yml` | Drift detection for GitHub rulesets (compliance evidence) |

### Composite Actions

Expand Down Expand Up @@ -75,6 +76,62 @@ jobs:
secrets: inherit
```

### Branch Protection Scripts

| Script | Description |
|--------|-------------|
| `apply-ruleset.sh` | Idempotent create/update of a GitHub ruleset from JSON |

---

### Audit Branch Protection

Compares live GitHub rulesets against a checked-in expected config
and uploads the result as a compliance artifact (365-day retention).
Detects drift and fails if the live config diverges from the
expected state.

```yaml
# .github/workflows/audit-branch-protection.yml
name: Audit Branch Protection

on:
schedule:
- cron: "0 8 * * 1" # Monday 08:00 UTC
workflow_dispatch:

jobs:
audit:
uses: stella/.github/.github/workflows/audit-branch-protection.yml@main
with:
expected-config-path: .github/branch-protection/ruleset-main.json
secrets:
BRANCH_PROTECTION_APP_ID: ${{ secrets.BRANCH_PROTECTION_APP_ID }}
BRANCH_PROTECTION_APP_KEY: ${{ secrets.BRANCH_PROTECTION_APP_KEY }}
```

**Secrets required:**
- `BRANCH_PROTECTION_APP_ID` — GitHub App ID with Administration: Read and write
- `BRANCH_PROTECTION_APP_KEY` — GitHub App private key (PEM)

**Inputs:**
- `expected-config-path` — path to the expected ruleset JSON (default: `.github/branch-protection/ruleset-main.json`)

### Apply Ruleset

Create or update a GitHub ruleset from a JSON file. Idempotent:
looks up by name, creates if missing, updates if found.

```bash
# Basic usage (from repository root)
.github/branch-protection/apply-ruleset.sh stella/stella \
.github/branch-protection/ruleset-main.json

# With github-actions[bot] bypass (for SBOM workflow etc.)
.github/branch-protection/apply-ruleset.sh --github-actions-bypass \
stella/stella .github/branch-protection/ruleset-main.json
```

---

## Composite Actions
Expand Down