Skip to content

GitHub Repository Automation with the API

Lesson 7 of 10Intermediate → Advanced11 min readGitHub Engineering · GitHub APIVerified: GitHub REST API version 2022-11-28 via gh 2.98.0, August 2026

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.

Terminal window
# Under your own account
gh api --method POST user/repos \
-f name=new-service -f description="Service scaffold" \
-F private=true -F auto_init=true
# Under an organisation
gh api --method POST orgs/ORG/repos \
-f name=new-service -F private=true -F has_wiki=false
# From a template
gh api --method POST repos/ORG/service-template/generate \
-f owner=ORG -f name=new-service -F private=true

Two 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.

Terminal window
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.

Terminal window
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=false

Unlike 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.

Terminal window
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/protection

Labels are a good first automation target: low risk, genuinely useful, and naturally idempotent if written carefully.

Terminal window
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=D93F0B

Creating a label that exists returns 422. Making it idempotent means checking first, or treating that specific 422 as success:

Terminal window
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.

Terminal window
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=push
gh api --method PUT orgs/ORG/teams/TEAM_SLUG/repos/OWNER/REPO -f permission=maintain
gh api --method DELETE repos/OWNER/REPO/collaborators/USERNAME

Prefer 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.

Terminal window
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=false

Asset 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.

A sweep with those properties:

#!/usr/bin/env bash
set -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
done

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:

Terminal window
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.

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 bash
set -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.

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 bash
set -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"
done

Piping 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.

Three related endpoints that come up in provisioning.

Terminal window
# Deploy keys — repository-scoped SSH access, better than a user's key on a server
gh 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 configuration
gh variable set DEPLOY_REGION --body "eu-west-1" --repo OWNER/REPO
gh variable list --repo OWNER/REPO
# Actions secrets — encrypted, write-only
gh secret set DEPLOY_TOKEN --repo OWNER/REPO < token.txt
gh secret list --repo OWNER/REPO

gh 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.

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.

Rulesets are JSON, which makes them the part of repository configuration most amenable to being kept in version control and applied as code.

Terminal window
# What exists
gh api "repos/OWNER/REPO/rulesets" --jq '.[] | {id, name, target, enforcement}'
# Export one, for storing in a repository
gh api "repos/OWNER/REPO/rulesets/RULESET_ID" > rulesets/protect-main.json
# Apply it somewhere else
gh api --method POST "repos/OWNER/OTHER/rulesets" --input rulesets/protect-main.json

The exported form contains fields that cannot be posted back — id, created_at, _links — so a round trip needs filtering:

Terminal window
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:

Terminal window
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
done
done

The 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 unmaintained repositories is one of the higher-value sweeps available, and one people avoid because doing it by hand is tedious.

#!/usr/bin/env bash
set -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
done

Archiving 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:

Terminal window
# 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"
fi
done

Two 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.

Use a disposable repository and, if possible, a test organisation.

  1. Create a repository through the API with auto_init and private visibility.
  2. Read its settings and note delete_branch_on_merge.
  3. Enable it with a PATCH and confirm the other settings were untouched.
  4. Write ensure_label and run it twice, confirming the second run is harmless.
  5. Read the branch protection of a protected branch, and write it back unchanged — carefully, on a disposable repository.
  6. 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.

Repository webhooks are configurable through the API, which matters when provisioning:

Terminal window
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"
}
}
JSON

The 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.

Access accumulates. Individual grants made for a specific reason years ago outlive the reason, and nothing surfaces them.

#!/usr/bin/env bash
set -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
done

affiliation=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.

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.

Terminal window
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 true

Three 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.

  • Personal and organisation repository creation use different endpoints.
  • PATCH /repos/... merges; PUT .../protection replaces 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.

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.

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.

Terminal window
# What the organisation defines
gh api "orgs/ORG/properties/schema" --jq '.[] | {property_name, value_type, required}'
# Read a repository's values
gh api "repos/ORG/REPO/properties/values" --jq '.[] | [.property_name, .value] | @tsv'
# Set them
gh api --method PATCH "repos/ORG/REPO/properties/values" --input - <<'JSON'
{
"properties": [
{ "property_name": "tier", "value": "critical" },
{ "property_name": "owning_team", "value": "payments" }
]
}
JSON

The 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.

Terminal window
# Inventory by property
gh 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.

Professional ToolkitThe fine-grained token permissions matrix, App-vs-PAT guide and gh api recipes are in the Professional Toolkit.