I needed to roll out the same GitHub Actions workflow change across a bunch of repositories. Doing it repo by repo via clone-edit-commit-push sounded painful.
Turns out you can read, modify, and commit files directly through the GitHub API without ever cloning a repo. The technique works for any file, but workflow YAML is where I’ve found it most useful — shared CI config that lives in every repo and needs to stay in sync. I’d recommend doing the whole thing in Python rather than shell — the base64 encoding, JSON payloads, and regex transformations are much less painful than trying to wrangle them with bash quoting.
The approach Link to heading
GitHub’s Contents API lets you fetch a file (base64-encoded), update it, and commit back — all in two API calls. The gh CLI makes auth trivial.
Fetch a file:
gh api 'repos/myorg/myrepo/contents/.github/workflows/review.yaml?ref=master' \
-q '.content' | base64 -d
Commit it back:
gh api repos/myorg/myrepo/contents/.github/workflows/review.yaml \
-X PUT --input payload.json
The payload needs the new content (base64-encoded), the current file SHA (for optimistic locking), the target branch, and a commit message.
The script Link to heading
I wrote a Python script that loops through all repos, fetches each workflow file, applies regex transformations, and commits via the API:
import json, base64, subprocess, re
def fetch_file(repo, filepath, branch):
result = subprocess.run(
f"gh api 'repos/myorg/{repo}/contents/{filepath}?ref={branch}'",
shell=True, capture_output=True, text=True,
)
data = json.loads(result.stdout)
content = base64.b64decode(data["content"]).decode()
return content, data["sha"]
def commit_file(repo, filepath, content, sha, branch, message):
payload = json.dumps({
"message": message,
"content": base64.b64encode(content.encode()).decode(),
"sha": sha,
"branch": branch,
})
subprocess.run(
f"gh api repos/myorg/{repo}/contents/{filepath} -X PUT --input -",
shell=True, input=payload, capture_output=True, text=True,
)
The key insight: regex transformations on YAML are fragile, so I kept the changes surgical — only touching the specific lines that needed updating and preserving everything else exactly as-is.
Preserving indentation Link to heading
Some repos used 2-space indent, others used 4-space. Getting this wrong would be immediately obvious in PRs. I detected the indent from the existing file:
def detect_indent(content):
for line in content.split("\n"):
stripped = line.lstrip()
if stripped and not stripped.startswith("#"):
indent = len(line) - len(stripped)
if indent in (2, 4):
return indent
return 2
Then all the regex patterns used the detected indent for both matching and replacement. This meant 2-space repos stayed 2-space and 4-space repos stayed 4-space.
Branch detection Link to heading
Not all repos use master — some use main, a couple only have staging. Rather than querying the default branch (which isn’t always what you want), I just tried branches in priority order:
for branch in ("master", "main", "staging"):
result = fetch_file(repo, filepath, branch)
if result:
break
Dry-run first Link to heading
I added a dry-run mode that shows unified diffs without committing anything. This was essential — I ran it in batches (3 repos, then 5, then 6, then the rest) to build confidence before going wider.
import difflib
diff = difflib.unified_diff(
original.splitlines(), modified.splitlines(),
lineterm="", n=1,
)
for line in diff:
if line.startswith(("@@", "-", "+")) and not line.startswith(("---", "+++")):
print(f" {line}")
Gotchas Link to heading
The SHA is required. The Contents API uses optimistic locking — you must provide the current SHA of the file you’re replacing. If someone pushes between your fetch and commit, it’ll 409. Not a problem in practice since workflow files don’t change often, but worth knowing.
Base64 newlines. When fetching content via gh api -q .content, the base64 string contains newlines. Pipe through base64 -d directly, or strip newlines before decoding in Python.
Further reading Link to heading
- GitHub Contents API — the create-or-update endpoint
gh api— the CLI wrapper that handles auth