How I set up GitHub for agents
Agents write more commits in a day than I used to write in a month. Reviewing all of them is a fantasy, so GitHub does the boring enforcement. This is the setup I run across my orgs, and the gotchas that cost me a weekend each.
flowchart LR push[agent pushes to main] --> verify[verify: gate + scan] pr[PR from a bot or a stranger] --> verify verify --> release[release: one payload] release --> publish[registry · GitHub Release · tap] release --> deploy[deploy that same payload]
Branches and rulesets
-
No required PRs by default. If the local gate is the same command CI runs on main, a verified fast-forward push is fine; post-push CI catches the rest and the agent watches it to the end.
-
PRs earn their place for review, outside contributors, merge queues, bots that automerge, or an owner who says so.
-
Before requiring PRs or a check, list everyone who writes to the default branch (maintainers, release bots, Renovate, content publishers, writeback jobs) and give each a working path, or you just broke your own release.
-
Squash merge and auto-delete branches. Force pushes and deletion get blocked in their own ruleset, separate from checks.
-
A required check constrains every update to the branch, API commits included, so name the ruleset for what it targets (
default-branch-checks), not "Renovate checks". -
Admins bypass in
alwaysmode;pull_requestmode blocks direct pushes. A publisher App gets a bypass on its own ruleset, not the org. -
Signed commits only when every writer can sign; contents API commits with a user token are unsigned.
-
Protect release tag families. A moving major tag like
v1is the one exception. -
Read the live settings. Running a check isn't enforcing it, and checked-in files don't prove settings.
Required checks that don't lie
-
Require one job,
verify, from a workflow that always runs. A path-filtered workflow leaves a required check pending forever, and a job skipped byif:reports success. -
When matrices or conditional lanes make job names unstable, end with one
always()result job that fails on anything but success or an expected skip. -
Non-strict checks by default; up-to-date branches or a merge queue only when integration risk pays for the extra runs. Merge queues need every required workflow to handle
merge_group, with no workflow-levelpathsfilter. -
Renaming a required check means updating the ruleset and confirming every open PR reports the new name.
CI shape and cost
Standard GitHub-hosted minutes are free for public repos. Private repos burn through the included ones fast once agents push, so every trigger, runner size and rerun is a cost decision.
-
Each repo has one
verifyjob on push to main, pull requests andworkflow_dispatch. -
Concurrency cancels superseded PR runs and nothing else, so every pushed range gets scanned:
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
-
Release and deploy jobs get their own non-cancellable group with
queue: max, check outgithub.shaand use a release tool that skips once main moved on. With one pending slot, an older release replaces a newer one and the newer commits never ship. actionlint 1.7.12 rejectsqueue; ignore that one error for those files in.github/actionlint.yaml. -
Public repos stay on GitHub-hosted runners. Private ones read one plain-string variable,
runs-on: ${{ vars.CI_RUNNER_ARM64 }}, so switching providers or riding out an outage is a variable change, not a PR to every repo. There's no fallback, so every repo needs a value; private repos on GitHub Free can't read org variables and set their own. -
Self-hosted runners never take fork PRs.
-
Linux for anything portable. macOS bills about ten times Linux; gate it to the jobs that need it.
-
Expensive jobs (simulators, e2e, cross-compiles) sit behind
dorny/paths-filterlanes. The PR files API caps at 3,000 files, so add a catch-all filter, compare its count with the PR'schanged_files, and run everything when they differ. -
Write setup and install seconds to
$GITHUB_STEP_SUMMARY. Caches, batching and shards stay only when those numbers say so. Sharding can't beat one setup plus the biggest shard. -
Jitter cron minutes off the top of the hour. Every schedule names the threat it covers and its weekly minutes.
Security baseline
-
Public repos: GitHub secret scanning and push protection, both free.
-
Private repos: Gitleaks as the last step of
verify, from one composite action: pushed range on push, full history on dispatch, pass-through on PRs. -
actionlint and zizmor when
.github/changes. zizmor 1.28.0 or newer; 1.27.0 logsGH_TOKENunder verbose output. -
SHA pinning required in the org's Actions policy. Write only SHAs you read from the source, and a named placeholder otherwise.
-
Pin your own shared actions to a release commit with the tag as the comment (
@<sha> # v1.2.0). A# maincomment fails zizmor once main moves.
Org security configurations
Advanced Security bills per active committer, and it's easy to switch on by accident.
-
One enforced configuration per visibility and update bot, locked against repo owners and the default for new repos of its visibility. Public ones add secret scanning and push protection.
-
Paid Secret Protection, Code Security and CodeQL default setup off; dependency graph and Dependabot alerts on.
-
CodeQL default setup runs on PRs and on a schedule and can block merges even when it isn't required. Add it per repo when someone accepts the cost.
-
Making a public repo private can turn free features into billed ones. Read back license usage before and after.
Workflows
-
zizmor enforces the usual hardening: no
pull_request_targetfor PR code, read-only permissions widened per job, no persisted credentials before the publish step, no PR-written caches in privileged jobs. -
YAML stays orchestration-thin. Parsing, ref policy and provider branching go in a tested typed module or local action, not a bigger shell blob.
-
Workflow files carry only pin comments and scanner suppressions. Rationale lives in
CONTRIBUTING.md.
Dependency updates
One bot per repo; two means duplicate PRs. Renovate when there are pins in mise.toml or annotated version variables, which Dependabot can't see; otherwise match whatever the org already runs. Under Renovate, Dependabot security updates stay off, since Renovate raises its own vulnerability PRs.
-
The preset lives in the owner's public
.githubrepo asrenovate-config.json, and repos extendgithub><owner>/.github:renovate-config. The hosted app can't read a private preset, so every consumer opens a "Cannot find preset's package" issue. -
Mine runs daily, waits three days on new releases (one for Actions and Docker), groups patch and minor, and holds majors for approval.
-
Set commit types with
:semanticCommitTypeAll(deps)afterconfig:recommended,cifor Actions in a package rule, andsemanticCommitScope: null. Never a top-levelsemanticCommitType:config:recommendedcommits production bumps asfix:and semantic-release ships a release for each one. -
renovate-config-validatorchecks option names only. A misspelled preset passes locally, then blocks every PR behind an "Action Required" issue. -
Migrating from Dependabot is one commit: add
renovate.json, delete.github/dependabot.yml, drop zizmor'sdependabot-cooldown, close the Dependabot PRs. Turn on "Require config file" in Mend so unmigrated repos get nothing. -
Native automerge (
platformAutomerge: true,automergeType: "pr") only where required checks exist, since GitHub waits only for those. Don't add mandatory review just to make automerge work. -
Vulnerability PRs skip release age and approval, so the first run on a neglected repo can open majors. Read those by hand.
Collaboration files
-
The PR template and
SECURITY.mdlive in the owner's public.githubrepo. Delete local copies unless a repo really differs. -
CONTRIBUTING.mdand the license stay in each repo. A code of conduct only when someone actually enforces it. -
Shared issue templates are fragile: any local issue config disables the whole shared set.
-
SECURITY.mdpoints at a private route that works. For a public repo that's private vulnerability reporting, turned on and read back. -
Agents fill templates literally, so the HTML comments are the only verbosity control that lands. My PR template:
## Problem
<!-- as the requester stated it, not the mechanism -->
## Solution
<!-- a short problem-lead sentence, then labeled bullets; never a
paragraph wall. Name a risk only when there is a real one. -->
## Proof
<!-- only what CI cannot show: a screenshot, before/after numbers.
Upload media with `gh pr create --attach ./file.png` or
`gh pr comment <n> --attach ./file.mp4`; never commit it to the branch.
Delete this section when CI covers everything. -->
Releases
One verified payload goes through build, test, publish and deploy, with no rebuild after verification. Actions artifacts are same-run scratch space, not a release.
flowchart LR build[build] --> verify[verify payload] verify --> draft[draft Release] draft --> assets[attach every asset] assets --> check[checksums · provenance] check --> publish[publish once]
-
Immutable releases lock tags and assets, so assemble the whole thing as a draft and publish once.
-
One version owner and one Release owner per artifact. Two tools racing for the same tag is how you get dangling tags.
-
Release jobs run only from main or a protected tag, after verification.
-
An App token doesn't sign a local
git commit. Use the release tool's API writeback so GitHub signs it. -
Done means a real release:
gh release verifyandgh release verify-assetpass, the tag peels to the right commit, and the registry, tap or moving tag points at the same version. A dry run proves none of that. -
After a partial failure, read the durable state (tag, Release, registry, tap) and backfill the missing piece from the existing tag instead of cutting a new version to make a workflow green.
| Target | What I do |
|---|---|
| npm | Trusted publishing, which only works on GitHub-hosted runners: id-token: write, no NPM_TOKEN. Provenance only comes from public repos, so private ones drop --provenance. |
| Go | One tool owns the tag; GoReleaser uploads into a draft, then it publishes once. |
| Rust | release-plz for crates.io, cargo-dist for binaries. Both: release-plz with its GitHub Release turned off. |
| Homebrew | The tap is its own write. An org App token scoped to the tap, a signed commit, then brew audit. |
| GitHub Action | Commit the bundle in the PR. vX.Y.Z tags are immutable; move vX with a compare-and-swap. |
Deploys
-
One GitHub Environment per blast radius. Production secrets live on the Environment, not the repo.
-
OIDC first, bound to repo, Environment and role. Static tokens only when the provider can't federate, with the reason written down.
-
Manual dispatch of a release or deploy is promotion, not a new build: validate the ref in a secretless job, resolve it to one SHA, prove the payload exists, then load credentials.
-
Deploys serialize per environment and lane, non-cancellable, shared by push and manual paths.
-
A deploy downstream of conditional lanes needs
!cancelled() && needs.<job>.result == 'success'for every job it needs, or a skipped lane quietly suppresses it. -
Monitoring and notification jobs get no deploy credentials. A curl in CI isn't production evidence.
If you're an agent reading this
Your user pointed you here to audit their GitHub. Inspect and propose a plan; change things once they agree.
-
List the repos in scope with
gh repo list <owner> --limit 1000 --json name,visibility,isArchived,isFork. Skip archived repos and forks unless asked. -
For each repo, read the live state: rulesets and branch protection, merge methods, Actions permissions and SHA pinning, variables, security configuration, private vulnerability reporting. Then the files: workflows,
renovate.jsonor.github/dependabot.yml, templates,SECURITY.md. -
If settings are managed as code (OpenTofu, Terraform, a settings app), audit and change that source instead. A live edit is drift the next apply reverts.
-
Report one table: repo, gap, target state from this page, proposed change, risk. Group repeated gaps into one fleet change and put cost next to anything that adds minutes or a paid feature.
-
Once your user agrees, apply to two or three repos first, read back every setting you changed, then roll out. Unreadable state is unknown, not passing.
Leave alone unless your user asks: existing approval rules and writers' paths, release pipelines that work, intentional exceptions, repository visibility, paid security features, and secrets. Prove a workflow edit with its checks, not a release or deploy.
Links
uinaf/.github, actionlint, zizmor, Gitleaks, Renovate, dorny/paths-filter, GitHub Actions billing, npm trusted publishing, How I work with agents.