Repository automation is where API work stops being read-only and starts changing things — which means it is where the safety practices matter.
The endpoints are straightforward. The discipline around them is what separates a script you can run against a hundred repositories from one that damages them.
Creating repositories
Section titled “Creating repositories”# Under your own accountgh api --method POST user/repos \ -f name=new-service -f description="Service scaffold" \ -F private=true -F auto_init=true
# Under an organisationgh api --method POST orgs/ORG/repos \ -f name=new-service -F private=true -F has_wiki=false
# From a templategh api --method POST repos/ORG/service-template/generate \ -f owner=ORG -f name=new-service -F private=trueTwo different endpoints for personal and organisation repositories — a common source of 404s, which here means “that endpoint does not exist for this owner type” rather than “the organisation is missing”.
Note -F for booleans. -f private=true sends the string "true", which some endpoints reject.
Reading configuration
Section titled “Reading configuration”gh api "orgs/ORG/repos?per_page=100&type=sources" --paginate \ --jq '.[] | [.full_name, .private, .default_branch, .delete_branch_on_merge, .archived] | @tsv'What it doesFetches the settings that matter for governance and hygiene across every repository in an organisation.
Why we run itThis is the audit query. Running it before any sweep tells you which repositories actually need changing, which keeps the sweep idempotent and small.
Expected resultOne line per repository with its current settings.
type=sources excludes forks, which usually should not be modified by organisation-wide automation.
Updating settings
Section titled “Updating settings”gh api --method PATCH repos/OWNER/REPO \ -f description="Updated description" \ -F delete_branch_on_merge=true \ -F has_wiki=false \ -F allow_squash_merge=true \ -F allow_merge_commit=falseUnlike branch protection, this endpoint merges — fields you omit are left alone. That makes it safe for incremental changes, and it is worth knowing which endpoints behave which way rather than assuming.
Branches and protection
Section titled “Branches and protection”gh api "repos/OWNER/REPO/branches?per_page=100" --paginate --jq '.[].name'gh api repos/OWNER/REPO/branches/main --jq '{name, protected}'gh api repos/OWNER/REPO/branches/main/protectionLabels
Section titled “Labels”Labels are a good first automation target: low risk, genuinely useful, and naturally idempotent if written carefully.
gh api "repos/OWNER/REPO/labels?per_page=100" --paginate --jq '.[] | [.name, .color] | @tsv'
gh api --method POST repos/OWNER/REPO/labels \ -f name=needs-repro -f color=FBCA04 -f description="Cannot reproduce as written"
gh api --method PATCH repos/OWNER/REPO/labels/needs-repro -f color=D93F0BCreating a label that exists returns 422. Making it idempotent means checking first, or treating that specific 422 as success:
ensure_label() { local repo="$1" name="$2" color="$3" desc="$4" if gh api "repos/$repo/labels/$name" --silent >/dev/null 2>&1; then gh api --method PATCH "repos/$repo/labels/$name" -f color="$color" -f description="$desc" --silent else gh api --method POST "repos/$repo/labels" -f name="$name" -f color="$color" -f description="$desc" --silent fi}That “ensure” shape — describe the desired state, converge to it — is the right pattern for all configuration automation.
Collaborators and teams
Section titled “Collaborators and teams”gh api "repos/OWNER/REPO/collaborators?per_page=100" --paginate \ --jq '.[] | [.login, .role_name] | @tsv'
gh api --method PUT repos/OWNER/REPO/collaborators/USERNAME -f permission=pushgh api --method PUT orgs/ORG/teams/TEAM_SLUG/repos/OWNER/REPO -f permission=maintaingh api --method DELETE repos/OWNER/REPO/collaborators/USERNAMEPrefer granting access to teams rather than individuals in an organisation. Team membership is managed in one place; per-repository individual grants accumulate invisibly and are what access reviews find years later.
Releases
Section titled “Releases”gh api "repos/OWNER/REPO/releases?per_page=100" --paginate \ --jq '.[] | select(.prerelease | not) | [.tag_name, .published_at] | @tsv'
gh api repos/OWNER/REPO/releases/latest --jq '.tag_name'
gh api --method POST repos/OWNER/REPO/releases \ -f tag_name=v1.4.0 -f name="v1.4.0" -f body="Release notes here." \ -F draft=false -F prerelease=falseAsset upload uses a different host — the upload_url returned when the release is created — which is
why gh release upload is usually easier than doing it by hand.
Safety
Section titled “Safety”A sweep with those properties:
#!/usr/bin/env bashset -euo pipefail
ORG="${1:?usage: sweep.sh ORG}"DRY_RUN="${DRY_RUN:-true}"
gh api "orgs/$ORG/repos?per_page=100&type=sources" --paginate \ --jq '.[] | select(.archived | not) | [.full_name, .delete_branch_on_merge] | @tsv' \| while IFS=$'\t' read -r repo current; do [ "$current" = "true" ] && continue # already correct
if [ "$DRY_RUN" = "true" ]; then printf 'would update %s\n' "$repo" else gh api --method PATCH "repos/$repo" -F delete_branch_on_merge=true --silent printf 'updated %s\n' "$repo" fi doneRate limits and pagination
Section titled “Rate limits and pagination”Both apply to everything here. A sweep over two hundred repositories making two calls each is four hundred requests against a 5,000-per-hour budget — fine once, and a problem in a loop across several organisations.
Check before starting:
gh api rate_limit --jq '.resources.core.remaining'And use --paginate on every list. A sweep that silently processes the first hundred of three
hundred repositories is worse than one that fails, because it looks like it worked.
Scaffolding a repository from scratch
Section titled “Scaffolding a repository from scratch”Creating a repository is one call; making it ready for a team is a sequence. Encoding that sequence is where repository automation pays for itself.
#!/usr/bin/env bashset -euo pipefail
ORG="${ORG:?}" NAME="${NAME:?}" TEAM="${TEAM:?}"DRY_RUN="${DRY_RUN:-true}"
run() { if [ "$DRY_RUN" = "true" ]; then printf 'would: %s\n' "$*"; else "$@"; fi}
# 1. Create from a template so the skeleton is consistent.run gh api --method POST "repos/$ORG/service-template/generate" \ -f owner="$ORG" -f name="$NAME" -F private=true
# 2. Settings.run gh api --method PATCH "repos/$ORG/$NAME" \ -F delete_branch_on_merge=true \ -F allow_merge_commit=false \ -F allow_rebase_merge=false \ -F allow_squash_merge=true \ -F has_wiki=false \ -F has_projects=false
# 3. Team access, not individual grants.run gh api --method PUT "orgs/$ORG/teams/$TEAM/repos/$ORG/$NAME" -f permission=push
# 4. Baseline protection on the default branch.run gh api --method PUT "repos/$ORG/$NAME/branches/main/protection" --input - <<'JSON'{ "required_status_checks": null, "enforce_admins": true, "required_pull_request_reviews": { "required_approving_review_count": 1, "dismiss_stale_reviews": true, "require_last_push_approval": true }, "restrictions": null, "allow_force_pushes": false, "allow_deletions": false}JSON
# 5. Label scheme, cloned from a repository that already has a good one.run gh label clone "$ORG/service-template" --repo "$ORG/$NAME"Two design points. Step 1 uses a template so the file skeleton is not this script’s problem — keeping
files in a template repository and configuration in a script is a much cleaner division than
generating files here. And every step goes through run, so the whole thing is dry-runnable.
Detecting drift
Section titled “Detecting drift”Repositories drift from whatever standard you set, because people change settings and new repositories are created outside the process. Detecting that is a read-only sweep:
#!/usr/bin/env bashset -euo pipefail
ORG="${1:?usage: drift.sh ORG}"
printf 'repository\tsquash_only\tdelete_branch\tprotected\n'
gh api "orgs/$ORG/repos?per_page=100&type=sources" --paginate \ --jq '.[] | select(.archived | not) | [.full_name, .default_branch, .delete_branch_on_merge, (.allow_merge_commit | not) and (.allow_rebase_merge | not)] | @tsv' \| while IFS=$'\t' read -r repo branch delete_branch squash_only; do if gh api "repos/$repo/branches/$branch/protection" --silent >/dev/null 2>&1; then protected=true else protected=false fi printf '%s\t%s\t%s\t%s\n' "$repo" "$squash_only" "$delete_branch" "$protected" donePiping that into a spreadsheet or a dashboard makes the exceptions visible. The value is not the report itself but that it turns “we should standardise our repositories” into a specific list of eleven that differ and how.
Note the cost: one extra request per repository for the protection check. On three hundred repositories that is six hundred requests, so this is a scheduled job rather than something to run casually.
Deploy keys, secrets and variables
Section titled “Deploy keys, secrets and variables”Three related endpoints that come up in provisioning.
# Deploy keys — repository-scoped SSH access, better than a user's key on a servergh api "repos/OWNER/REPO/keys" --jq '.[] | [.id, .title, .read_only] | @tsv'gh api --method POST "repos/OWNER/REPO/keys" \ -f title="deploy-prod" -f key="$(cat deploy_key.pub)" -F read_only=true
# Actions variables — non-secret configurationgh variable set DEPLOY_REGION --body "eu-west-1" --repo OWNER/REPOgh variable list --repo OWNER/REPO
# Actions secrets — encrypted, write-onlygh secret set DEPLOY_TOKEN --repo OWNER/REPO < token.txtgh secret list --repo OWNER/REPOgh secret set handles the encryption for you — the REST endpoint requires you to encrypt the value
with the repository’s public key before sending it, which is several steps of libsodium work that the
CLI does transparently.
Deploy keys deserve a mention as the better answer to “the server needs to clone this repository”. A read-only deploy key is scoped to one repository and revocable independently, where a personal SSH key on a server grants everything its owner can reach.
Common mistakes
Section titled “Common mistakes”Wrong creation endpoint for the owner type. user/repos versus orgs/ORG/repos.
-f for booleans. Sends strings.
Treating the protection endpoint as a merge. It replaces, silently dropping omitted settings.
Creating labels without handling 422. Fails on the second run.
Granting repository access to individuals in an organisation. Accumulates invisibly.
No dry-run. No undo.
Forgetting --paginate. Silently partial sweeps.
Managing rulesets programmatically
Section titled “Managing rulesets programmatically”Rulesets are JSON, which makes them the part of repository configuration most amenable to being kept in version control and applied as code.
# What existsgh api "repos/OWNER/REPO/rulesets" --jq '.[] | {id, name, target, enforcement}'
# Export one, for storing in a repositorygh api "repos/OWNER/REPO/rulesets/RULESET_ID" > rulesets/protect-main.json
# Apply it somewhere elsegh api --method POST "repos/OWNER/OTHER/rulesets" --input rulesets/protect-main.jsonThe exported form contains fields that cannot be posted back — id, created_at, _links — so a
round trip needs filtering:
jq '{name, target, enforcement, conditions, rules, bypass_actors}' \ rulesets/protect-main.json \| gh api --method POST "repos/OWNER/OTHER/rulesets" --input -Storing rulesets as files in a repository gives you review, history and a reason for each rule in the commit message — which is a meaningful improvement over configuration that exists only as settings somebody changed once.
Applying a standard set across an organisation is then the familiar loop:
for repo in $(gh repo list ORG --limit 200 --source --json nameWithOwner --jq '.[].nameWithOwner'); do for rs in rulesets/*.json; do name=$(jq -r '.name' "$rs") if gh api "repos/$repo/rulesets" --jq ".[] | select(.name == \"$name\") | .id" | grep -q .; then echo "$repo: $name already present" else echo "$repo: applying $name" [ "${DRY_RUN:-true}" = "true" ] || \ jq '{name, target, enforcement, conditions, rules, bypass_actors}' "$rs" \ | gh api --method POST "repos/$repo/rulesets" --input - --silent fi donedoneThe name check makes it idempotent, which matters because rulesets do not deduplicate — running this twice without the check produces two identical rulesets, both enforcing.
Archiving at scale
Section titled “Archiving at scale”Archiving unmaintained repositories is one of the higher-value sweeps available, and one people avoid because doing it by hand is tedious.
#!/usr/bin/env bashset -euo pipefail
ORG="${1:?usage: archive-stale.sh ORG}"MONTHS="${MONTHS:-24}"DRY_RUN="${DRY_RUN:-true}"
cutoff=$(date -u -d "$MONTHS months ago" +%Y-%m-%dT%H:%M:%SZ)
gh api "orgs/$ORG/repos?per_page=100&type=sources" --paginate \ --jq --arg cutoff "$cutoff" \ '.[] | select(.archived | not) | select(.pushed_at < $cutoff) | [.full_name, .pushed_at[0:10], .open_issues_count] | @tsv' \| while IFS=$'\t' read -r repo pushed issues; do printf '%s\tlast push %s\t%s open\n' "$repo" "$pushed" "$issues" if [ "$DRY_RUN" != "true" ]; then gh api --method PATCH "repos/$repo" -F archived=true --silent fi doneArchiving is reversible and non-destructive: the repository becomes read-only, everything remains readable, and unarchiving restores write access. That makes it a much safer default than deletion for anything you are unsure about.
Reading repository content without cloning
Section titled “Reading repository content without cloning”For inspection across many repositories, reading files through the API avoids cloning entirely:
# Does every repository have a CI workflow?for repo in $(gh repo list ORG --limit 200 --source --json nameWithOwner --jq '.[].nameWithOwner'); do if gh api "repos/$repo/contents/.github/workflows" --silent >/dev/null 2>&1; then count=$(gh api "repos/$repo/contents/.github/workflows" --jq 'length') printf '%-45s %s workflow(s)\n' "$repo" "$count" else printf '%-45s NONE\n' "$repo" fidoneTwo hundred repositories is four hundred requests here, against clones that would be gigabytes. For questions about whether a file exists or what one file contains, the API is dramatically cheaper than cloning — and it is the right tool for compliance-style sweeps where you need an answer per repository rather than the code itself.
Exercise
Section titled “Exercise”Use a disposable repository and, if possible, a test organisation.
- Create a repository through the API with
auto_initand private visibility. - Read its settings and note
delete_branch_on_merge. - Enable it with a
PATCHand confirm the other settings were untouched. - Write
ensure_labeland run it twice, confirming the second run is harmless. - Read the branch protection of a protected branch, and write it back unchanged — carefully, on a disposable repository.
- Run the sweep script in dry-run against an organisation you own, then inspect what it would have done.
Step 3 demonstrates that PATCH merges; step 5 is where you learn — safely — that the protection
endpoint does not.
Webhooks as configuration
Section titled “Webhooks as configuration”Repository webhooks are configurable through the API, which matters when provisioning:
gh api "repos/OWNER/REPO/hooks" --jq '.[] | {id, url: .config.url, events, active}'
gh api --method POST "repos/OWNER/REPO/hooks" --input - <<'JSON'{ "name": "web", "active": true, "events": ["pull_request", "push"], "config": { "url": "https://example.com/webhook", "content_type": "json", "insecure_ssl": "0" }}JSONThe secret field goes in config and is never returned by a read — the API will not echo it back,
which is correct and means you cannot audit whether a webhook’s secret matches your handler’s. Keep
the authoritative value in a secret store.
insecure_ssl: "0" is the default and should stay there. Setting it to "1" disables certificate
verification on delivery, which makes the signature the only protection left.
For anything beyond a single repository, a GitHub App is better — one webhook configuration covering every installation, with new repositories included automatically.
Auditing collaborator access
Section titled “Auditing collaborator access”Access accumulates. Individual grants made for a specific reason years ago outlive the reason, and nothing surfaces them.
#!/usr/bin/env bashset -euo pipefail
ORG="${1:?usage: audit-access.sh ORG}"
gh api "orgs/$ORG/repos?per_page=100&type=sources" --paginate --jq '.[].full_name' \| while read -r repo; do gh api "repos/$repo/collaborators?affiliation=direct&per_page=100" --paginate \ --jq --arg r "$repo" '.[] | [$r, .login, .role_name] | @tsv' 2>/dev/null || true doneaffiliation=direct is the important parameter. It excludes access inherited from organisation
membership or a team, leaving exactly the individual grants — which is the list worth reviewing,
because team-based access is managed elsewhere and individual access is not managed at all.
Anything on that list should either be justified or converted to team membership. Individual collaborator grants are how someone retains write access to one repository three years after moving to a different part of the company.
Idempotency, restated
Section titled “Idempotency, restated”Every write in this lesson should be safe to run twice, because scheduled automation will run twice.
The pattern is consistent: describe the desired state, check the current state, act only on the difference.
ensure_setting() { local repo="$1" field="$2" want="$3" local have have=$(gh api "repos/$repo" --jq ".$field") if [ "$have" = "$want" ]; then return 0 # already correct fi if [ "${DRY_RUN:-true}" = "true" ]; then printf 'would set %s.%s = %s (currently %s)\n' "$repo" "$field" "$want" "$have" else gh api --method PATCH "repos/$repo" -F "$field=$want" --silent fi}
ensure_setting "OWNER/REPO" delete_branch_on_merge trueThree benefits beyond safety: it is faster, because most repositories need no change; it produces a meaningful log, because only actual changes are reported; and it uses far fewer requests, which matters at organisation scale.
What you learned
Section titled “What you learned”- Personal and organisation repository creation use different endpoints.
PATCH /repos/...merges;PUT .../protectionreplaces everything.- The “ensure” pattern — read desired state, converge — makes configuration automation idempotent.
- Grant repository access to teams, not individuals, in organisations.
- Dry-run by default, verify before acting, skip what is already correct.
- Pagination and rate limits apply to every sweep, and a partial sweep looks like success.
The short version
Section titled “The short version”Repository automation is straightforward at the endpoint level and demanding at the discipline level. The endpoints are ordinary REST; what separates a safe sweep from a damaging one is four habits.
Dry-run by default, so the destructive mode requires an explicit opt-in and an accidental run prints rather than acts.
Read before writing, both to verify the object is what you expect and because PUT .../protection replaces the entire configuration while PATCH /repos/... merges.
Skip what is already correct, which makes the script idempotent and cuts its request count substantially.
Paginate everything, because a sweep that silently processes the first hundred of three hundred repositories looks exactly like one that worked.
Archive rather than delete unless deletion is genuinely required. Archiving is reversible and preserves everything GitHub stored; deletion is neither.
Custom properties
Section titled “Custom properties”Organisations can define custom properties on repositories — typed metadata such as an owning team, a criticality tier, or a compliance scope. Unlike topics, they are defined centrally with a schema, and rulesets can target repositories by their values.
# What the organisation definesgh api "orgs/ORG/properties/schema" --jq '.[] | {property_name, value_type, required}'
# Read a repository's valuesgh api "repos/ORG/REPO/properties/values" --jq '.[] | [.property_name, .value] | @tsv'
# Set themgh api --method PATCH "repos/ORG/REPO/properties/values" --input - <<'JSON'{ "properties": [ { "property_name": "tier", "value": "critical" }, { "property_name": "owning_team", "value": "payments" } ]}JSONThe reason this matters more than topics: an organisation ruleset can select its targets by property
value. That turns governance from a list of repository names into a rule — every repository with
tier: critical requires two approvals — which stays correct as repositories are created and
reclassified.
# Inventory by propertygh api "orgs/ORG/properties/values?per_page=100" --paginate \ --jq '.[] | [.repository_full_name, ([.properties[] | select(.property_name == "tier") | .value] | first // "unset")] | @tsv'Anything returning unset is a repository outside your classification, which is usually a more useful
audit finding than a settings difference — it means nobody has decided what the repository is.