| name | gh-bulk-repo-edit |
| description | Perform identical, surgical edits across many GitHub repositories without cloning them locally — using the `gh` CLI's Contents API to read files, create branches, commit changes, and open PRs in bulk. Use this skill whenever the user wants to make the same small change (update a README, remove a deprecated badge, fix a link, bump a config) across more than a handful of repos they own or maintain. Trigger this skill on phrases like "across all my repos", "in each of these repos", "bulk update", "open PRs for all of them", "without cloning", "deprecated X in many repos", or any task where the work is mechanically identical and spans 5+ repositories. Even when the user describes the task in domain terms (e.g. "the Snyk badge is deprecated everywhere") rather than "multi-repo edit", trigger this skill — it's the right tool whenever cloning N repos to make a one-line change would be wasteful. |
Multi-repo edits via the GitHub Contents API
Why this skill exists
Cloning tens or hundreds of repos to make a one-line README change is wasteful and slow. The GitHub Contents API lets you read a file, modify it, and commit the change on a new branch, then open a PR — all without ever cloning. The gh CLI is already authenticated, so the whole flow can be a single bash script.
The pattern this skill encodes is scan → verify on a sample → bulk apply with per-repo error handling. Skipping any of those three steps tends to cause real damage in production: a sloppy regex matches prose instead of the badge you meant to remove; a workflow name doesn't map to its filename; one repo's default branch is master not main; you discover this halfway through because you didn't dry-run.
When to use this skill
Use it when all of these are true:
- The change is mechanical (the same edit, parametrized at most by repo metadata like default branch or owner)
- It spans more than a handful of repositories
- The user has push access (or the repos are theirs)
- The change is safe to attempt unattended on each repo (you're opening PRs, not merging them)
If any of those are false — e.g. one repo, a hand-tailored change, a destructive force-push — write a one-off script instead. This skill is for fan-out, not for surgery on a single repo.
The three-phase workflow
Phase 1: Scan (no writes)
Build a list of candidate repos and detect, per repo, exactly what the change should be. Write nothing. The output is a candidates file plus a log of skip reasons.
gh repo list <user-or-org> --limit 300 --no-archived --source --json nameWithOwner -q '.[].nameWithOwner'
For each repo, fetch the file you intend to change. The /readme endpoint is preferred for READMEs because it finds the file regardless of casing (README.md, Readme.md, etc.):
gh api "repos/$repo/readme"
gh api "repos/$repo/contents/$path"
Run your detection logic on the decoded content. Write three log files:
- candidates (TSV): repos that need the change, plus any per-repo metadata you'll need in the apply phase
- skips (TSV): repos you intentionally won't touch, with reasons (
no_match, already_done, unsupported_variant, etc.)
- failures (TSV): repos where detection itself errored (network, 404, parse fail)
When a single repo can have multiple in-scope items (e.g. several entries in a YAML array, several badge instances in a README, several workflow files), also write a fourth log:
- details (TSV): one row per item with its classification and the raw current value (e.g.
repo<TAB>path<TAB>line=33<TAB>MERGE_LABELS: "automerge,dependencies")
The details file lets you see the bucket distribution before writing the transform — which is the cheapest way to keep the transform simple. If only one bucket is non-empty (e.g. every item to fix is the same kind), don't write a generalized transform that handles all theoretical cases. Pick the minimum transform that covers the populated buckets.
Log raw values, not just verdicts. A details file that only says ok / needs_change is useless if the user reverses the target state mid-task ("actually, set it to X, not Y"). With the raw current value captured, you can re-bucket against the new target without re-fetching every file — which makes mid-task pivots cheap.
Show the candidate count, the bucket distribution, and a sample of skip reasons to the user before proceeding. Suspiciously high skip counts often mean the detection regex is too narrow.
Phase 2: Verify on a sample
Pick 1–2 random candidates and produce a full before/after for them. Show the user:
- The exact line(s) being matched
- The proposed replacement
- Any per-repo metadata you derived from the API (default branch, mapped filenames, etc.)
Wait for explicit user approval. This is the cheapest gate that catches the most expensive class of mistakes — a regex that looked right on the example you wrote it for but matches prose elsewhere, an edit that strips the wrong line, a metadata lookup that returns the wrong key.
For samples on a list of repos: awk -F'\t' '{print $1}' candidates.tsv | sort -R | head -2 (shuf may be unavailable on macOS).
Phase 2.5: Smoke-test the apply pipeline
Phase 2 verifies the transform — does the new content look right? It does NOT verify the apply pipeline — do the PUT/DELETE calls work, does the PR-fallback fire correctly on protected branches, do error paths handle real API failures? These are independent failure surfaces.
Before running the apply script on the full candidate set, run it on 1–3 representative repos that span the distinct action paths in your bucket distribution. Pick deliberately:
-
One repo from the most common bucket (validates the happy path)
-
One repo from the most complex / multi-step bucket (validates the full sequence — multiple PUTs, a DELETE, a README rewrite, etc.)
-
If any of your repos run CI lint on the file you're editing, pick at least one of those as a smoke-test target and wait for its CI to complete after the smoke commit. Markdownlint, yamllint, actionlint, etc. surface lint failures here that cannot be caught by a local pre-flight: per-repo configs vary, and installing linters on the host running the skill is usually not viable. The repo's own CI is your lint pre-flight.
gh run watch -R "$repo"
gh pr checks -R "$repo"
Only proceed to the full bulk apply once smoke commits and their CI checks are green. If no candidate repo runs CI lint on the target file, acknowledge upfront that the bulk apply may surface lint failures post-hoc — and budget for a re-push pass rather than pretending the risk is zero. Re-pushing 200+ repos with a one-character fix is annoying but cheap; quietly breaking CI on 200+ repos is worse.
Triaging the smoke commit's CI runs. "Wait for all green" is the safe default, but in practice the smoke commit kicks off every workflow on push to the default branch — many of which can't possibly read the file you changed. Blocking on all of them wastes time. Two refinements make the gate principled rather than binary:
- Split affected vs. unaffected checks. Identify which workflows could plausibly be influenced by this specific edit: typically the workflow file itself (if you edited a workflow), plus any check whose inputs include the path or extension you touched (e.g.,
yamllint on .yml, markdownlint on .md, actionlint on .github/workflows/**). Block on those (gh run watch -R "$repo" <run-id>). For workflows that can't read the changed path (a links-checker scanning .md, a release workflow on tag-push, a scheduled job), it's fine to note their status and move on without waiting.
- Diff red checks against history before declaring regression. A check that's red on the smoke commit might already have been red on the prior commits — pre-existing breakage, not caused by you. Confirm with:
gh run list -R "$repo" -w '<workflow name>' -L 5 --json conclusion,headSha,createdAt
If the last few runs were already failing on unrelated SHAs, log it as pre-existing and proceed. If the smoke commit is the first red after a streak of greens, treat it as a regression and stop.
When you can't confidently split affected from unaffected (mixed-content edits, unfamiliar repo layouts, workflows you didn't look at), fall back to the default: gh run watch everything and wait for green. The shortcut only earns its keep when you've actually reasoned about which checks consume the bytes you changed.
Phase 3: Apply (bulk)
Per repo, in this exact order:
- Refetch the file to get a fresh
sha (don't trust the scan-time SHA — repos may have changed)
- Apply the transformation in-memory
- Sanity-check the result (e.g., grep that the new content is present and the old isn't)
- Look up the default branch:
gh api repos/$repo -q .default_branch
- Get the HEAD SHA of the default branch:
gh api repos/$repo/git/ref/heads/$default_branch -q .object.sha
- Create the working branch off that HEAD:
gh api -X POST repos/$repo/git/refs -f ref="refs/heads/$BRANCH" -f sha="$head_sha"
If this fails with 422, the branch already exists from a previous run — skip and log, don't try to overwrite.
- Commit the change via the Contents API:
gh api -X PUT "repos/$repo/contents/$path" \
-f message="$COMMIT_MSG" \
-f content="$(base64 < new_file | tr -d '\n')" \
-f sha="$FILE_SHA" \
-f branch="$BRANCH"
- Open the PR:
gh pr create -R "$repo" --base "$default_branch" --head "$BRANCH" \
--title "$PR_TITLE" --body "$PR_BODY"
Wrap each repo's work in a function that returns nonzero on any failure, and run it in a loop that continues on failure — log to a failures file, move on. One bad repo shouldn't block the other 43.
Reporting
End with a summary the user can act on:
Recommended script structure
A single bash script with these properties holds up well:
#!/bin/bash
export PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/local/bin:$PATH"
set -u
BRANCH='fix/some-thing'
COMMIT_MSG='fix: short imperative summary'
PR_TITLE="$COMMIT_MSG"
PR_BODY='One paragraph explaining what changed and why.'
OK=/tmp/<scope>/ok.log; SKIP=/tmp/<scope>/skip.log; FAIL=/tmp/<scope>/fail.log
: > "$OK"; : > "$SKIP"; : > "$FAIL"
process_repo() {
local repo="$1"
local tmpdir; tmpdir=$(mktemp -d)
trap "rm -rf '$tmpdir'" RETURN
}
while IFS=$'\t' read -r repo _meta; do
echo "--- $repo"
process_repo "$repo"
done < candidates.tsv
echo "OK: $(wc -l < "$OK"), SKIP: $(wc -l < "$SKIP"), FAIL: $(wc -l < "$FAIL")"
Two structural choices that matter:
- One function per repo with
local everything and a cleanup trap. Loops over hundreds of repos otherwise leak $tmpdirs.
- Continue on error. The bulk run's value is fan-out; aborting on one failure defeats the point.
- Run-scoped or append-only logs. Truncating logs at script start (
: > "$OK") silently wipes prior runs in the same session — risky when chaining smoke → production → retry passes through the same script (Phase 2.5 commits get nuked when Phase 3 starts; a fix-up re-push can't tell which repos were touched). Either name logs per run (OK=/tmp/<scope>/ok_$(date +%s).log) or append rather than truncate, and aggregate across runs with cat.
Pitfalls (this stuff bit us — do not relearn)
-
Probe for tools before reaching for an install. Don't reflexively pip install / npm install / brew install a parser library mid-task — many users keep their environment lean and won't appreciate the install. Before you write the script, run a quick probe to see what's actually available, e.g.:
which yq jq node python3 ruby perl
python3 -c 'import yaml' 2>&1 | head -1
ruby -ryaml -e 'p YAML.load("a: 1")' 2>&1
Pick whichever installed tool can do the job (Ruby's Psych and Perl's YAML are stdlib on macOS; jq is universally available; node may have nothing extra without npm). If absolutely nothing on the system can parse the format you need, surface the constraint to the user and ask before installing.
-
PATH in subshells. When a script is invoked from a non-interactive context, the inherited PATH may not include /opt/homebrew/bin (Homebrew tools like jq, gh, column). Set PATH explicitly at the top of every script.
-
Invoke written scripts as bash /path/to/script.sh, not chmod +x + direct execution. The auto-mode permission classifier treats direct execution of a freshly-written script path as "running an unknown binary" — content unverifiable at execute time — and denies it (unverifiable script content being run). Running it through bash <path> lets the classifier see a known interpreter reading a file the harness has already observed via Write, and it's allowed. There's no functional reason to chmod+x first; the bash <path> form runs the same code with fewer permission gates.
-
PATH resolution can flake in multi-line ad-hoc Bash tool calls; wrap in a script if you hit it. Observed once in a run: a multi-line Bash invocation reported command not found for gh, base64, and head mid-block, even though cat/wc/xargs earlier in the same block worked, and identical gh calls worked elsewhere as single-line invocations. Root cause not fully diagnosed (the multi-line block appeared to run in a different shell context than a script does), but the empirical workaround is reliable: put the sequence in a script file (which always carries its own PATH from line 1) and run via bash /path/script.sh, or split into single-line Bash tool calls with PATH=... cmd prefix per call. If you see command not found for a binary you know is installed, suspect this before debugging anything else.
-
for x in $VAR doesn't always split on newlines. The IFS in some shells doesn't include \n, so a multi-line $SAMPLE becomes a single iteration with a string like "repo1\nrepo2" that gets sent to gh api and produces invalid control character in URL. Use ... | while read -r repo instead.
-
printf '%b' can introduce NUL bytes. Bash's printf '%b' is a footgun for URL-decoding — it can leave trailing nulls. URL-decode in Python instead:
python3 -c "import urllib.parse,sys; print(urllib.parse.unquote(sys.argv[1]).rstrip())" "$encoded"
-
Don't interpolate variables into jq filters. A workflow name with a space or quote breaks the filter. Use --arg:
gh api ... | jq -r --arg n "$wf_name" '.workflows[] | select(.name == $n) | .path'
-
Beware regexes that match prose. A pattern like Known Vulnerabilities matched section headings and TOC entries, not just the badge alt text. Prefer structural signals (URLs, tag attributes, file paths) over labels. When in doubt, require a URL substring. Also: Markdown headings frequently include emoji or other non-letter prefixes (## 👤 Author, ## 🤝 Contributing) — a narrow regex like ^##+ +Author will miss these. Prefer ^#+ +.*Word\b (case-insensitive) when matching heading text.
-
Workflow name ≠ workflow filename. GitHub's old badge URL /workflows/<name>/badge.svg uses the workflow's name: field. The new URL needs the workflow's file path (ci.yml, main.yml, etc.). Resolve via GET /repos/{owner}/{repo}/actions/workflows, which returns [{name, path}, …]. Map by name, then basename the path.
-
Default branch is not always main. Don't hardcode. gh api repos/$repo -q .default_branch per repo — master, develop, etc. all show up in the wild.
-
README casing varies. Use the /readme endpoint, not /contents/README.md, when fetching READMEs. But know its limit: /readme returns only the root README — nested READMEs (packages/*/README.md, docs/README.md) are invisible to it. If your task touches anything that nested READMEs might reference, walk the tree explicitly via GET /repos/{owner}/{repo}/git/trees/{branch}?recursive=1 and filter for .md/.markdown/.mdx blobs.
-
When you delete or move a file, all references to the old path become stale — not just in the README. Other docs commonly link to CONTRIBUTING.md / CODE_OF_CONDUCT.md / LICENSE etc.: AGENTS.md, DEVELOPMENT.md, CHANGELOG.md, nested READMEs in monorepos, even contributing-related sections of SECURITY.md. If you only rewrote the root README, link checkers (lychee, etc.) will flag the rest in CI after the merge. After the apply phase, run a follow-up scan across the affected repos' full markdown tree (via the recursive tree API) for references to the old path. Either fold this into Phase 1 detection upfront, or run it as a Phase 4 "stale-reference sweep" after Phase 3.
-
Heredoc + bash variable substitution is fragile. When passing complex strings (matched lines, multi-character substitutions) to a python heredoc, prefer python3 - "$arg1" "$arg2" <<'PYEOF' (note the quoted 'PYEOF') and read via sys.argv. Unquoted heredocs get bash-substituted and lose newlines/spaces unpredictably.
-
Idempotency. Always check whether the working branch already exists before creating it (a previous run may have left it). Treat 422 as "skip + log", not "fail".
-
Trailing conditional → bogus exit code. A script ending with [[ -s "$FAIL" ]] && cat "$FAIL" exits 1 when $FAIL is empty, because the conditional is the script's last command and evaluates false. Everything succeeded, but the wrapping shell sees failure. Either guard each conditional with || true, follow the conditional with an unconditional command (an echo, a hint to view PRs), or end the script with an explicit exit 0.
-
A crashing transform produces empty stdout, which is silently catastrophic. If your transform script aborts mid-pipe (uncaught exception, missing require, parser failure), stdout is empty — and a naïve apply pipeline transform | base64 | gh api PUT will happily push an empty file to N repos before you notice. Two guards, both cheap:
ruby transform.rb < before > after || { echo "transform_failed" >> "$FAIL"; return 1; }
[ -s "$after" ] || { echo "transform_empty" >> "$FAIL"; return 1; }
Sample-verify catches this if you read the stderr line — but eyeballing only the diff (which shows "every line removed") can mislead you into thinking the transform was overzealous, not crashed. Build the apply path to fail closed on empty output.
-
gh api writes 404 response bodies to stdout, not stderr. A naïve check like resp=$(gh api ... 2>/dev/null); [ -n "$resp" ] is true for every 404 — the error JSON went to stdout. Always use the exit code: if gh api ... >file 2>/dev/null; then .... This bites detection logic ("does this file exist?") in the most damaging way: every repo looks like a match.
-
Bash read -r with IFS=$'\t' collapses adjacent empty fields. Tab is in IFS-whitespace, so per bash semantics consecutive tabs act as a single delimiter, dropping empty middle columns. A round-trip through while IFS=$'\t' read -r a b c d ...; do printf '%s\t...' ...; done silently shifts your TSV's column layout whenever a middle field (a SHA, a status) is empty. Fixes: write empties as a non-empty sentinel like - in your TSV, OR process with awk -F'\t' / Python which honor empty fields. Verify with awk -F'\t' '{print NF}' file | sort -u — every row should have the same field count.
-
Branch-protection rejection on direct commit ≠ "branch already exists." When you PUT to the Contents API on a protected default branch, gh returns nonzero with stderr like Could not create file: Changes must be made through a pull request. ... (HTTP 409). If your PR-fallback trigger matches on phrasing like "status code 4xx", it won't fire — gh actually emits HTTP 4xx. Match on substrings the API really prints: 'must be made through a pull request|HTTP 4(0[39]|22)'. Verify your error-detection against a known-protected repo before scale-out — a fallback path that never fires looks the same as a hard failure. Note this is separate from the 422 "branch already exists" case on git/refs POST.
Detection: a worked example
This is the pattern from a real run. The user wanted to remove the deprecated Snyk vulnerabilities badge from many READMEs.
First-cut regex (too loose):
grep -E 'Known Vulnerabilities|snyk\.io/test/'
The alt-text alternative caught two false positives (a section heading "Scan for Known Vulnerabilities" and its TOC entry). Lesson: prefer the URL signal alone:
grep -E 'snyk\.io/test/'
Form-agnostic substitution. The badge appears in two forms:
- HTML:
<a href="..."><img src=".../workflows/CI/badge.svg" alt="build"/></a>
- Markdown:
[](.../actions?workflow=CI)
Rather than parse the line, do substring replacements on the URLs themselves — both forms share the same URL structure, so URL-level edits are form-agnostic:
new = old.replace(
f'/workflows/{name}/badge.svg',
f'/actions/workflows/{file}/badge.svg?branch={branch}'
).replace(
f'/actions?workflow={name}',
f'/actions/workflows/{file}'
)
This generalizes: when transforming a URL that may appear in multiple wrapper formats, edit the URL substring and let the wrappers stay as they are.
Structured config edits (YAML / JSON / TOML): a second worked example
The URL-substitution pattern above breaks down when the file is structured config (.github/dependabot.yml, package.json, pyproject.toml, etc.), because:
- Indent and nesting matter — sed/grep can't reason about them.
- Round-tripping through a parser typically loses comments and ordering (the user's original
# Why this thing exists lines disappear).
- The change isn't always at one literal location — e.g. "ensure each entry under
updates: has a labels: key" requires walking N entries and inserting in the right place under each.
The workable pattern is a parse–edit–verify split:
-
Parse to decide — use a YAML/JSON parser (Ruby's YAML is stdlib, Perl's YAML is stdlib, jq for JSON) to classify each in-scope item: already correct, needs modification, missing entirely. This is purely diagnostic — no writes.
-
Surgical line edits to transform — once you know which items need what, edit the source text line-by-line: locate the entry by its anchor line (e.g. - package-ecosystem:), determine sibling indent from that line's column, and insert / modify lines at the right indent. Don't round-trip through a YAML dumper unless you genuinely don't care about comments or key order.
-
Re-parse to verify the structural invariant — after editing, parse the new content again and assert what you wanted is now true (e.g. every entry now has labels containing both X and Y). Failing this assertion should abort the per-repo run before any branch/commit/PR.
-
Plus a literal grep sanity check — count occurrences in the new content (e.g. # of "labels:" lines >= # of "- package-ecosystem:" lines). The parser check confirms structure, the grep check confirms exact text presence — they catch different failure modes (a parser-valid file with the wrong indent, vs. a missing line).
A real example from a dependabot.yml run: parser-side, classify each updates[*] entry as ok / needs_block / needs_add. Line-side, for each - package-ecosystem: line, find sibling indent (dash_col + 2), find the end of that entry's range (next - package-ecosystem: at the same column, or EOF, ignoring trailing blank lines), and insert the labels block just before any trailing blank-line separator. Re-parse the result and assert each entry's labels array now contains the targets; also grep that labels: count ≥ ecosystem count. Iterate from the bottom up when inserting into a line array, so earlier line indices remain valid.
A few file-format gotchas worth noting:
- Extension variants — try
.yml then .yaml; .toml vs .cfg; on 404 of one, fall through to the other before declaring "no match."
- Bucket distribution drives transform complexity — if your scan shows every needs-change item is the same kind, don't write code for the other theoretical kinds. Less code, less bug surface, faster sample diff.
Multi-file PRs (one PR, several file changes)
Plenty of bulk edits touch more than one file per repo — e.g. update an existing config and add a companion workflow alongside it. The skill's basic flow assumes one file per PR, but multi-file works the same way with a few extra rules.
One PR, several PUTs, several commits. The Contents API only edits one file per call, so a multi-file PR ends up with one commit per file change on the same branch. That's normal and acceptable for review. If you need true atomicity (all-or-nothing applied as one commit), drop down to the Git Data API to build a tree + commit yourself — but only reach for that when the cost is justified.
Per-file decide independently — don't conflate "this repo is in scope" with "every targeted file in this repo needs to change." For each file the PR is supposed to touch:
- Update existing: refetch, run transform,
cmp -s before after. If identical, this file is no-op for this repo — don't PUT it (the Contents API rejects identical-content PUTs anyway, but more importantly an empty diff in a multi-file PR is pure noise).
- Create new: pre-check existence with
GET repos/$repo/contents/$path. On 200, the file already exists — almost always you want to skip the whole repo with a clear reason like <file>_already_exists rather than overwrite a hand-rolled version. On 404, PUT without a sha field to create.
- Skip whole repo only when every file change is no-op or blocked. A repo where one file is already correct but another is missing still needs a PR — for the missing one. Don't accidentally treat it as "already done."
Concrete example (one update + one create):
update_needed=0
gh api "repos/$repo/contents/$path_a" > "$tmpdir/resp_a"
sha_a=$(jq -r .sha < "$tmpdir/resp_a")
jq -r .content < "$tmpdir/resp_a" | base64 -d > "$tmpdir/before_a"
ruby transform.rb < "$tmpdir/before_a" > "$tmpdir/after_a" || { echo fail; return 1; }
[ -s "$tmpdir/after_a" ] || { echo empty; return 1; }
cmp -s "$tmpdir/before_a" "$tmpdir/after_a" || update_needed=1
if gh api "repos/$repo/contents/$path_b" >/dev/null 2>&1; then
echo "$repo ${path_b}_already_exists" >> "$SKIP"; return 0
fi
gh api -X POST "repos/$repo/git/refs" -f ref="refs/heads/$BRANCH" -f sha="$head_sha" >/dev/null
[ "$update_needed" -eq 1 ] && gh api -X PUT "repos/$repo/contents/$path_a" \
-f message="$MSG_A" -f content="$(base64 < "$tmpdir/after_a" | tr -d '\n')" \
-f sha="$sha_a" -f branch="$BRANCH" >/dev/null
gh api -X PUT "repos/$repo/contents/$path_b" \
-f message="$MSG_B" -f content="$(base64 < "$LOCAL_NEW_FILE" | tr -d '\n')" \
-f branch="$BRANCH" >/dev/null
gh pr create -R "$repo" --base "$default_branch" --head "$BRANCH" --title "$PR_TITLE" --body "$PR_BODY"
Log per-file outcomes alongside the PR URL. When reviewing the run, you want to see which PRs touched which files (e.g. repo<TAB>pr_url<TAB>updated=1<TAB>created=1). Aggregate counts of "12 PRs added the new file only, 3 also fixed the existing one" are how you sanity-check that the apply matched the scan's bucket distribution — and how you spot drift between scan and apply (a refetched file may have shifted bucket; that's fine, but worth noticing).
When to confirm with the user
This skill drives side-effecting, externally-visible changes (PRs that notify reviewers, hit inboxes, may auto-trigger CI). Confirm before:
- The first dry-run finishes and you're about to apply (always)
- Bulk runs that exceed the dry-run sample size (always)
- Any unusual skip count (more skips than candidates, or skips with reasons you didn't predict — investigate first)
- Pacing: 50+ PRs at once produces a notification burst; offer the user the option to chunk
The user has tools but they don't have time. Confirm with a one-line summary plus a 2-option pick (proceed all / chunk into N) — not with paragraphs.
What this skill does NOT do
- It does not merge PRs. Opening PRs is reversible (close them); merging changes mainline.
- It does not use destructive git operations (force-push, branch delete on remote, etc.) — the Contents API path is non-destructive by design.
- It does not handle binary files well — the Contents API is base64 round-trip safe, but if the change is to a binary, you probably want a different approach.
- It does not scale to thousands of repos in one run. GitHub's authenticated rate limit is 5000/hour, and each repo costs ~5 calls (readme, default branch, head sha, ref create, contents PUT, pr create). 200–300 repos per run is comfortable; beyond that, batch.
Reference files
references/api-recipes.md — Common gh api patterns: read file, write file with branch, list workflows, list PRs, etc.
references/script-template.sh — A starting-point bash script with the structure described above; copy and customize per task.