- name
- harden-github-repo-security
- description
- Apply GitHub repository security protections tier by tier — rulesets, read-only Actions token, secret scanning + push protection, Dependabot, and (gated) required status checks / required PR with a GitHub App bypass for a CI auto-commit bot. Mutating and confirmation-gated: always assess first, apply the no-regret baseline, then decide required checks separately. Use when hardening a public user-owned repo after an audit, when a repo has no branch protection, when adding required checks without breaking a bot that pushes to the default branch, or when provisioning a GitHub App bypass actor for trusted automation.
- license
- MIT
- allowed-tools
- Read Write Edit Bash
- metadata
- {"author":"Philipp Thoss","version":"1.0","domain":"git","complexity":"advanced","language":"multi","tags":"github, security, rulesets, branch-protection, github-actions, hardening","locale":"es","source_locale":"en","source_commit":"5addd67a4","fence_basis_commit":"5addd67a4","translator":"(untranslated stub)","translation_date":"2026-07-16"}
# Harden GitHub Repository Security
Apply GitHub protections in order of blast radius: assess, then a no-regret
baseline that breaks no CI, then a gated decision on required checks / PR.
No-regret means every item is either a control you will not want to undo or a
decision you will not want to have skipped — not that the tier is decision-free.
Every mutating step is confirmation-gated. Running example: a **public,
user-owned** repo whose CI auto-commits to the default branch via
`stefanzweifel/git-auto-commit-action` with the default `GITHUB_TOKEN`.
Throughout, `R` = `OWNER/REPO` (e.g. `pjt222/agent-almanac`). Set it once:
`R=OWNER/REPO`. All `gh api` writes require repo admin.
## When to Use
- Hardening a public user-owned repo after `assess-github-repo-security`
- A repo has no ruleset / branch protection and you want a safe baseline
- Adding required status checks or required PR **without** breaking a bot that
pushes to the default branch
- Provisioning a GitHub App (or deploy key) bypass actor for trusted automation
## Inputs
- **Required**: `OWNER/REPO` and admin access (`gh auth status` shows admin)
- **Required**: whether a CI bot pushes to the default branch (and which action/identity)
- **Optional**: assessment report from `assess-github-repo-security`
- **Optional**: exact CI check `context` name(s) to require (must run on `push`)
- **Optional**: GitHub App id + private key if going to required checks/PR
## Procedure
### Step 1: Assess First and Confirm Scope
Never mutate blind. Run the read-only assessment and confirm the plan with the user.
```bash
# 1. Run the companion read-only skill first (or its core probes):
gh api /repos/$R --jq '{visibility,default_branch,is_org:.organization!=null}'
gh api /repos/$R/rulesets --jq '.[] | {id,name,enforcement}'
gh api /repos/$R/actions/permissions/workflow
# 2. Confirm the two decisive facts before any write:
# - Is the repo PUBLIC and USER-OWNED? (rulesets are free here)
# - Does a bot push to the default branch? (gates Step 3 entirely)
```
Confirm with the user: baseline (Step 2) breaks no CI, but it is not
decision-free — its fork-PR approval item is a deliberate choice, not a default
to apply blind. Step 3 (required checks / PR) is a separate opt-in that **will**
block a default-branch bot unless a bypass is provisioned first.
**Expected:** You know visibility, owner type, current rulesets, current token
default, and whether a bot pushes to the default branch. The user has approved
applying at least the baseline.
**On failure:** If the repo is **private and user-owned**, rulesets need GitHub
Pro — stop and surface that. If not admin, stop (writes will 403). If a bot
pushes to the default branch, flag that Step 3 is blocked until Step 3a runs.
### Step 2: Apply the No-Regret Baseline (breaks no CI auto-commit)
This tier hardens the repo without breaking a direct-push bot. Apply after
confirmation. Force-push/deletion protection, a read-only token default, the
free security features, and policy files.
```bash
# 2a. Default-branch ruleset: force-push + deletion protection ONLY.
# These do NOT block the bot's direct push.
gh api --method POST /repos/$R/rulesets --input - <<'JSON'
{
"name": "protect-default",
"target": "branch",
"enforcement": "active",
"conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } },
"rules": [ { "type": "deletion" }, { "type": "non_fast_forward" } ]
}
JSON
# 2b. Actions token: read-only default + no token PR approval.
# A DEFAULT (not a cap) for same-repo push events, so the bot still works
# once its job declares contents: write.
gh api --method PUT /repos/$R/actions/permissions/workflow \
-f default_workflow_permissions=read \
-F can_approve_pull_request_reviews=false
# 2c. Free security features (all free on public repos):
gh api -X PUT repos/$R/vulnerability-alerts # alerts + dependency graph
gh api -X PUT repos/$R/automated-security-fixes # Dependabot fix PRs (separate toggle!)
gh api -X PATCH repos/$R --input - <<'JSON'
{"security_and_analysis":{"secret_scanning":{"status":"enabled"},"secret_scanning_push_protection":{"status":"enabled"}}}
JSON
gh api -X PUT repos/$R/private-vulnerability-reporting
# 2d. Fork-PR approval — READ it here; the value is a decision, not a default to
# apply blind. See the paragraph below before writing.
gh api repos/$R/actions/permissions/fork-pr-contributor-approval
# Write, once decided (loosest to strictest):
# first_time_contributors_new_to_github | first_time_contributors | all_external_contributors
# gh api -X PUT repos/$R/actions/permissions/fork-pr-contributor-approval \
# -f approval_policy=first_time_contributors_new_to_github
```
**Decide fork-PR approval deliberately.** It is the one item in this tier that is
a decision rather than a control, which is why the tier is *no-regret* and not
*zero-downside* — the term this skill used until #768, retired in #707 because a
tier containing a decision is not downside-free. The public-repo default is "Require approval for first-time
contributors", and fork `pull_request` runs already receive a read-only
`GITHUB_TOKEN` with no access to secrets — so the default is safe. What it also
does is run **nothing at all** on a first-time contributor's PR until a
maintainer clicks approve, and if nobody notices, the contributor sees a PR with
no checks and no signal. Measured on this repository's first external PR: 0
workflow runs, 0 check-runs, 0 check-suites.
The setting is readable **and writable** over the API — it is not a UI-only
toggle, and believing otherwise sends people designing around a constraint that
does not exist.
**The predicate is fork-REACHABILITY, not "does this repo have secrets."** That
test is wrong in both directions: a repo whose deploy runs only on push-to-main
is not endangered by loosening this, because a fork PR cannot trigger it and the
platform withholds secrets from fork runs regardless; a repo whose
`pull_request` validators run on **self-hosted runners** should stay strict with
no secret anywhere, because arbitrary code execution on your own hardware,
resource abuse and cache-poisoning are what the gate is actually for — and none
of them is "touches secrets". Measure reachability with the four commands in
`guides/protecting-github-repositories.md` under "Decide fork-PR approval
deliberately", and read the ruler warnings beside them rather than reaching for
the obvious grep — an unanchored `pull_request` scan matches
`pull_request_target`, the one trigger that runs base-repo code with secrets, and
misses `on: [push, pull_request]` entirely. Both errors read as safe.
Then add two tracked files (commit them):
`.github/dependabot.yml` — include a `github-actions` block so action SHAs stay fresh:
```yaml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
```
`.github/SECURITY.md` — a short disclosure policy pointing reporters at the
"Report a vulnerability" button (private vulnerability reporting, enabled above).
Flipping the token default to `read` silently strips write from **every**
workflow that assumed a write default, not just the auto-commit job. Audit
**all** files in `.github/workflows/` and declare the minimal `permissions:` each
job actually needs — common scopes are `contents`, `packages`, `pull-requests`,
`pages`, and `id-token`. For the running example: top-level `permissions: {}`,
`contents: write` only on the auto-commit job, and every `uses:` pinned to a
full 40-char commit SHA with the version in a trailing comment.
**Expected:** `gh api /repos/$R/rulesets` shows `protect-default` active;
`.../actions/permissions/workflow` shows `default_workflow_permissions: read`
and `can_approve_pull_request_reviews: false`; secret scanning + push protection
+ Dependabot + private reporting are on; `.github/dependabot.yml` and
`.github/SECURITY.md` are committed. The bot's next run still pushes.
`.../actions/permissions/fork-pr-contributor-approval` has been **read**, the
guide's four reachability commands have been **run**, and their results are
quoted in the same turn as the decision — one line suffices, e.g.
`1: 12 pull_request workflows · 2: none · 3: 2 secret-bearing · 4: none`. The
policy is then left or changed, and **both branches require that quote**:
tightening on instinct is the same defect as leaving an unexamined default, and
the quote is what distinguishes either from a decision. Quote the output rather
than asserting the measurement — every other criterion in this step is
checkable against live API state, and this one is checkable only against what
you paste.
**On failure:** If 2b makes an existing bot push 403 — or any other workflow
fails after losing a write scope it silently relied on — the job is missing the
explicit `permissions:` it needs (e.g. `contents: write`, `packages: write`,
`pull-requests: write`); add the minimal scope back (the read-only default is not
a hard cap for same-repo pushes). If `automated-security-fixes` seems to do nothing, verify
`vulnerability-alerts` (2c line 1) ran first — alerts are a separate prerequisite
toggle from fixes. If push protection later fails a bot run, treat the flagged
secret as a **real finding** — the bot cannot click the interactive web bypass.
### Step 3: Decision Gate — Required Status Checks / Required PR
STOP. Required checks and required PR **block direct pushes to the ref**, not
just PR merges. If a bot pushes to the default branch, turning either on **will
break it** unless you first provision a bypass. The default `GITHUB_TOKEN` /
`github-actions[bot]` **cannot** be a bypass actor (GitHub design). Do not enable
this tier on a bot-pushing branch without a bypass in place.
Also, on a **solo** repo, `required_approving_review_count >= 1` is a self-lockout
(you cannot approve your own PR) — keep it `0`. And a required check that only
runs on `pull_request` never reports on a direct push, so it **permanently**
blocks the bot. Ensure the check runs on `push`.
**Step 3a — provision the bot bypass FIRST (if a bot pushes to this branch).**
Recommended: a GitHub App installation token. (Deploy key is a narrower
single-repo alternative: add an SSH deploy key with write access and use
`actor_type: DeployKey` in 3b.)
Create a GitHub App (personal account, Settings > Developer settings >
GitHub Apps), permission Repository > Contents: Read and write; install it
on THIS repo. Store the App id as a repo variable and the private key as an
Actions secret. In the workflow, mint a token and pass it to checkout + the
commit action:
```yaml
- uses: actions/create-github-app-token@<sha> # v2
id: app-token
with: { app-id: ${{ vars.APP_ID }}, private-key: ${{ secrets.APP_KEY }} }
- uses: actions/checkout@<sha>
with: { token: ${{ steps.app-token.outputs.token }} }
- uses: stefanzweifel/git-auto-commit-action@<sha>
with: { ... } # inherits the app token via the checkout credentials
```
The numeric App id used in Step 3b is shown on the App's own page:
Settings > Developer settings > GitHub Apps > `<your app>` > About ("App ID").
It is unrelated to the repository id and cannot be derived from /repos/$R.
**Deploy-key alternative (no App to maintain).** Register a write deploy key and
push over SSH; the checkout's `ssh-key` makes `git-auto-commit-action` push as
that identity:
```bash
# ssh-keygen -t ed25519 -N '' -f deploy_key # then register the public half:
gh api repos/$R/keys -f title="ci-bot" -f key="$(cat deploy_key.pub)" -F read_only=false
gh secret set DEPLOY_KEY < deploy_key # store the private half, then delete the local copy
# - uses: actions/checkout@<sha>
# with: { ssh-key: ${{ secrets.DEPLOY_KEY }} } # remote becomes SSH via the key
# - uses: stefanzweifel/git-auto-commit-action@<sha> # inherits the SSH remote
# In 3b use actor_type "DeployKey"; GitHub stores its actor_id as null (it matches
# ANY write deploy key on the repo), so keep the deploy-key list minimal.
```
**Higher-assurance alternative (no standing bypass actor).** If a manual merge
click is acceptable, convert the job to open a PR with the App token (e.g.
`peter-evans/create-pull-request`) and let a human merge. No bypass actor is
needed and every rule stays enforced even against the automation's identity. The
App token is still required: a PR opened with the plain `GITHUB_TOKEN` does not
trigger `pull_request`/`push` workflows, so required checks never run and it sits
`expected` forever (see Common Pitfalls).
**Step 3b — apply the hardened ruleset with the App as an `Integration` bypass
actor.** Replace `RULESET_ID` with the id from Step 2a, `<APP_ID>` with your App
id, and `build` with your real check context. `bypass_mode: always` because the
action pushes directly (use `pull_request` only if the bot opens PRs).
```bash
gh api --method PUT /repos/$R/rulesets/RULESET_ID --input - <<'JSON'
{
"name": "protect-default",
"target": "branch",
"enforcement": "active",
"conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } },
"bypass_actors": [ { "actor_id": <APP_ID>, "actor_type": "Integration", "bypass_mode": "always" } ],
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "pull_request", "parameters": { "required_approving_review_count": 0, "dismiss_stale_reviews_on_push": true, "require_code_owner_review": false, "require_last_push_approval": false, "required_review_thread_resolution": false, "allowed_merge_methods": ["merge"] } },
{ "type": "required_status_checks", "parameters": { "strict_required_status_checks_policy": true, "do_not_enforce_on_create": true, "required_status_checks": [ { "context": "build" } ] } }
]
}
JSON
# Verify the bypass actor is present:
gh api /repos/$R/rulesets/RULESET_ID --jq '.bypass_actors'
```
`do_not_enforce_on_create: true` avoids the new-branch catch-22 (CI can't have
run on a branch that does not exist yet). If there is **no** bot on this branch,
you may skip 3a and omit `bypass_actors`.
**Bypass is whole-ruleset, not per-rule.** The single ruleset above lets the
bypass actor skip `deletion`/`non_fast_forward` too — a bypass actor could
force-push or delete the branch. To keep ref protection **universal** while
exempting only the check, split into two stacked rulesets (they aggregate,
most-restrictive-wins): ruleset A = `deletion` + `non_fast_forward` with
`bypass_actors: []` (applies to everyone, including you), ruleset B =
`required_status_checks` with the bot — and, if you want to keep your own
direct-push, your own `{ "actor_type": "User", "actor_id": <your-user-id>,
"bypass_mode": "always" }` — in `bypass_actors`. A successful bypassed push
prints `remote: Bypassed rule violations … Required status check "<name>" is expected`.
Trade-off: `strict_required_status_checks_policy: true` ("branch must be
up to date") forces every open human PR to be re-updated each time the bot
auto-commits to the default branch, and `dismiss_stale_reviews_on_push: true`
compounds the churn — a chatty bot can make PRs perpetually unmergeable. On a
frequently-auto-committed branch prefer `strict_required_status_checks_policy:
false` (loose) unless up-to-date-before-merge is genuinely required.
**Expected:** The ruleset now enforces the required check and required-PR rule
for humans and forks, `.bypass_actors` lists the App (`Integration`), and the
bot's next run still lands on the default branch (via the App token). Humans
must open a PR; the check must be green.
**On failure:** If the bot 403s after 3b, the App identity is not actually the
bypass actor — confirm the workflow passes the App token to **checkout** (not
just to the commit action) and that `<APP_ID>` in `bypass_actors` matches. If
the bot's push sits `expected`/`pending` forever, the required check does not run
on `push` — make it trigger on `push` or drop it from the required list. If the
GitHub에서 보기