Why Skip git at All?
Sometimes git isn't an option. Corporate proxies strip the smart HTTP protocol. A build agent has no credential helper and no working clone. You're inside a sandbox that only exposes HTTPS to api.github.com. The repository still needs to receive files, and GitHub Pages still needs to rebuild.
The Contents API gives you a REST path to the same effect: read a file, write a file, get a commit. It's slower than git push and it produces one commit per file, but it works from anywhere you can make an HTTPS request. This post covers the mechanics that make it behave well: SHA comparison to avoid no-op commits, a deterministic directory walk, rate-limit handling, and a verification read after every write.
The Blob SHA Is Not a Hash of the File
The first trap is assuming the sha field returned by the Contents API is sha1(file_bytes). It isn't. It's the git object ID of the blob, which is the SHA-1 of a header plus the content:
sha1(b"blob " + str(len(content)).encode() + b"\x00" + content)
That header is why you can compute the remote file's SHA locally without any network call. If your computed value matches the sha from GET /contents/{path}, the file on GitHub is byte-identical to your local copy and the PUT can be skipped entirely.
import hashlib
def git_blob_sha(content: bytes) -> str:
header = b"blob " + str(len(content)).encode("ascii") + b"\x00"
return hashlib.sha1(header + content).hexdigest()
This one function is the core of an idempotent deploy. Everything else is plumbing around it.
Why the skip matters
Without the comparison, every deploy rewrites every file. GitHub treats each PUT as a new commit, even when the content is unchanged, so you get:
- A commit log full of "Update index.html" entries with zero diff.
- A Pages build triggered per commit, so a 200-file site fires 200 builds.
- Rate-limit budget consumed on writes that changed nothing.
With the comparison, a redeploy of an unchanged site makes zero PUT calls. Only files that actually changed produce commits.
Reading the Current State
GET /repos/{owner}/{repo}/contents/{path} returns either a single object (for a file) or a list (for a directory). For a file you want three fields: sha, content, and encoding. The content field is base64 with newlines embedded, so strip whitespace before decoding if you need to compare bytes rather than SHAs.
import base64
import requests
API = "https://api.github.com"
def get_file(session, owner, repo, path, ref="main"):
url = f"{API}/repos/{owner}/{repo}/contents/{path}"
r = session.get(url, params={"ref": ref})
if r.status_code == 404:
return None
r.raise_for_status()
data = r.json()
if isinstance(data, list):
raise ValueError(f"{path} is a directory, not a file")
content = base64.b64decode(data["content"].replace("\n", ""))
return {"sha": data["sha"], "content": content}
A 404 means the file doesn't exist at that ref yet, which is the normal case on first deploy. Treat it as None, not as an error.
Walking the Tree Deterministically
Directory iteration order differs across filesystems, and it differs again between runs on the same machine. That matters because your deploy log, your commit order, and any partial-failure recovery all depend on a stable sequence. Sort explicitly.
from pathlib import Path
def iter_files(root: Path, ignore=(".git", ".github", "__pycache__")):
for p in sorted(root.rglob("*")):
if not p.is_file():
continue
rel = p.relative_to(root)
if any(part in ignore for part in rel.parts):
continue
yield rel.as_posix(), p.read_bytes()
Sorting on the full relative path string gives you a depth-first order that's stable across platforms. rglob returns paths, but you sort after filtering, so the order doesn't depend on os.scandir behavior.
Two details worth pinning down: use as_posix() for the API path so Windows separators never leak into URLs, and read bytes rather than text. Line-ending normalization or encoding guesses will change the blob SHA and defeat the comparison.
The Push
The PUT endpoint takes message, content (base64), and sha — but only when updating. Omitting sha on an existing file returns 409 Conflict, which is GitHub's way of stopping you from clobbering a concurrent write. Including a stale sha also fails, so the read and write need to be close together.
def push_file(session, owner, repo, path, content, current, message=None):
url = f"{API}/repos/{owner}/{repo}/contents/{path}"
local_sha = git_blob_sha(content)
if current is not None and current["sha"] == local_sha:
return {"status": "unchanged", "sha": local_sha}
body = {
"message": message or f"deploy: {path}",
"content": base64.b64encode(content).decode("ascii"),
}
if current is not None:
body["sha"] = current["sha"]
r = session.put(url, json=body)
r.raise_for_status()
return {"status": "pushed", "sha": r.json()["content"]["sha"]}
The response echoes the new blob SHA under content.sha. In principle it equals local_sha. In practice, don't assume — verify it (next section).
Base64 encoding
base64.b64encode produces standard base64 with = padding, which is what the API expects. The API tolerates newlines in the payload but there's no reason to add them. For large files, the entire payload goes in a JSON body, so the practical ceiling is around 1 MB per file for the standard Contents API — above that you need the Git Data API (blobs, trees, commits) instead.
Verifying the Write
A 200 from PUT means GitHub accepted the request. It does not prove the blob on the default branch matches what you sent — webhooks, branch protection rules, or an intervening push can all change the outcome. Re-fetching the SHA is cheap and closes the loop.
def deploy_file(session, owner, repo, path, content, ref="main"):
current = get_file(session, owner, repo, path, ref=ref)
result = push_file(session, owner, repo, path, content, current)
if result["status"] == "unchanged":
return result
check = get_file(session, owner, repo, path, ref=ref)
if check is None or check["sha"] != git_blob_sha(content):
raise RuntimeError(
f"verification failed for {path}: "
f"expected {git_blob_sha(content)}, got {check and check['sha']}"
)
return result
The read-after-write costs one extra request per changed file. On a deploy where most files are unchanged, that's a handful of requests. On a first deploy of a large site, it doubles the request count — worth it, because a silent mismatch means your Pages site is serving stale content and you won't know until someone notices.
Rate Limiting
Authenticated requests get 5,000 per hour against the REST API. A 300-file site with verification and a first-time full deploy costs roughly 300 GETs (returning 404 for new files), 300 PUTs, and 300 verification GETs — about 900 requests. Two deploys like that in an hour and you're at a third of your budget. Repeated deploys with the SHA skip cost 300 GETs each, since every file is still read.
Handle 403 and 429 explicitly. GitHub returns 403 with X-RateLimit-Remaining: 0 when exhausted, and Retry-After on secondary limits.
import time
def request_with_backoff(session, method, url, **kwargs):
for attempt in range(5):
r = session.request(method, url, **kwargs)
if r.status_code in (403, 429):
retry_after = r.headers.get("Retry-After")
wait = int(retry_after) if retry_after else 2 ** attempt
time.sleep(wait)
continue
return r
return r
Wire this into get_file and push_file by replacing session.get / session.put with request_with_backoff. Cap the retries — a hard-failing deploy should stop, not spin.
If you're near the limit, the cheapest optimization is a single GET /repos/{owner}/{repo}/git/trees/{ref}?recursive=1 call to fetch every blob SHA at once, then compare locally and only GET/PUT the files that differ. That collapses the read phase from N requests to 1.
The Commit-Per-File Tradeoff
Every PUT creates a commit. There's no way around that through the Contents API alone. The consequences:
-
History noise. A 50-file change becomes 50 commits. If you care about readable history, batch the message (
deploy: <path> [3/50]) or accept it. - Pages rebuilds. Pages builds on push to the publishing branch. Sequential commits can queue builds or cancel earlier ones. If your site takes 90 seconds to build, a 200-file deploy can take a long time to settle.
- Atomicity. A failure halfway through leaves the repo in a mixed state — some files new, some old. There's no transaction. Design the deploy to be resumable: because the SHA skip is idempotent, re-running the deploy after a failure picks up exactly where it stopped.
If any of those are unacceptable, the Git Data API is the answer: create blobs, build a tree, create one commit, update the ref. That's a single commit and a single Pages build, at the cost of more code and a wider blast radius if you get the tree wrong. For small sites and infrequent deploys, per-file commits are fine.
Putting It Together
def deploy_site(root: Path, owner: str, repo: str, ref: str = "main"):
session = requests.Session()
session.headers.update({
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
})
stats = {"pushed": 0, "unchanged": 0}
for path, content in iter_files(root):
result = deploy_file(session, owner, repo, path, content, ref=ref)
stats[result["status"]] += 1
print(f"{result['status']:9} {path}")
return stats
Run it twice. The second run should report every file as unchanged and make zero writes. If it doesn't, your comparison is broken — most often because you're reading text instead of bytes, or because something in the build pipeline is rewriting files with different line endings between runs.
That idempotency is the whole point. It makes the deploy safe to retry, keeps the commit log honest, and keeps Pages from rebuilding for nothing.
More notes like this ship every week on this site.
Daily Picks
The following pairs are selected from the multi-timeframe trend scanner (Gate.io futures) and are for technical-analysis study only — not investment advice.
Data updated: 2026-10-18 08:46:32
Long
| Pair | Signal | Price | Take Profit | Stop Loss | R/R |
|---|---|---|---|---|---|
| AGT | 弱做多 | $0.0843 | $0.0906 | $0.0822 | 1:3.0 |
| BTW | 短平快做多 | $2.3242 | $2.3939 | $2.2661 | 1:1.2 |
2 picks selected. Scanner runs every 15 minutes.
Top comments (0)