Corporate firewalls are rarely all-or-nothing. They are usually the result of a proxy policy, a TLS inspection rule, or a category filter that someone applied years ago and forgot about. That is why you can end up in a situation where github.com:443 is silently dropped, while api.github.com and raw.githubusercontent.com answer requests without complaint.
I ran into this on a Windows build box inside a restricted network. git push hung until timeout. git clone over HTTPS failed. But curl.exe https://api.github.com returned a JSON payload in under a second. The deployment pipeline needed to publish a handful of generated files to a repository, and the network had effectively amputated the git transport while leaving the REST API intact.
This post covers how to diagnose that split, how to push files through the GitHub Contents API instead of git, and why the HTTP-only path is worth keeping around even on networks where git works fine.
Probing the firewall with curl.exe
Before writing any Python, establish exactly which endpoints survive. On Windows 10 and 11, curl.exe ships with the OS, so you do not need to install anything or fight with the PowerShell curl alias (which is actually Invoke-WebRequest and behaves differently).
Test the three hosts that matter:
curl.exe -sS -o NUL -w "%{http_code} %{time_total}s\n" https://github.com
curl.exe -sS -o NUL -w "%{http_code} %{time_total}s\n" https://api.github.com
curl.exe -sS -o NUL -w "%{http_code} %{time_total}s\n" https://raw.githubusercontent.com
-o NUL discards the body, -w prints the status code and elapsed time. On the blocked network, the first command sat there for roughly 20 seconds and exited with code 28 (operation timed out). The other two returned 200 in a few hundred milliseconds.
A timeout is more informative than a 403. A 403 means something answered — a proxy, a filter appliance — and you can sometimes negotiate with it. A timeout means the connection was dropped or blackholed, which usually points to a routing or SNI-based block that you cannot talk your way out of.
Confirm the git transport specifically:
git ls-remote https://github.com/your-org/your-repo.git
If that hangs while curl.exe to api.github.com succeeds, you have confirmed the split: git's smart HTTP protocol talks to github.com, the API talks to api.github.com, and only one of them is reachable.
One caveat worth checking early: raw.githubusercontent.com serves file content but does not accept writes. It is useful for reads and for verifying that a push landed, but every write goes through api.github.com.
Authenticating without a browser
The Contents API needs a token. A classic personal access token with repo scope works, or a fine-grained token with Contents: Read and write on the target repository. Put it in an environment variable rather than the script:
$env:GITHUB_TOKEN = "ghp_..."
Then verify the token and check your rate limit budget in one call:
curl.exe -sS -H "Authorization: Bearer $env:GITHUB_TOKEN" `
-H "Accept: application/vnd.github+json" `
https://api.github.com/rate_limit
The response includes resources.core.limit, normally 5000, and remaining. That number is per hour, per authenticated token. Unauthenticated requests get 60 per hour from a shared IP pool, which is useless for any real deployment — always authenticate.
The Contents API, step by step
The Contents API models a repository file as a resource at:
/repos/{owner}/{repo}/contents/{path}
A write is a two-step dance, because the API is optimistic-concurrency aware. You cannot just PUT a file and hope; if the file already exists, you must supply the current blob SHA so the server knows you are updating the version you think you are updating.
Step 1: GET the current SHA
import base64
import os
import time
import requests
API = "https://api.github.com"
OWNER = "your-org"
REPO = "your-repo"
BRANCH = "main"
TOKEN = os.environ["GITHUB_TOKEN"]
HEADERS = {
"Authorization": f"Bearer {TOKEN}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
def get_file_sha(path: str) -> str | None:
url = f"{API}/repos/{OWNER}/{REPO}/contents/{path}"
r = requests.get(url, headers=HEADERS, params={"ref": BRANCH}, timeout=30)
if r.status_code == 404:
return None
r.raise_for_status()
return r.json()["sha"]
404 means the file does not exist yet — that is a create, not an update. Anything else non-2xx should raise, because silently continuing on a 401 or 403 just produces confusing failures later.
Step 2: PUT the base64 content
def put_file(path: str, content: bytes, message: str, sha: str | None) -> dict:
url = f"{API}/repos/{OWNER}/{REPO}/contents/{path}"
payload = {
"message": message,
"content": base64.b64encode(content).decode("ascii"),
"branch": BRANCH,
}
if sha is not None:
payload["sha"] = sha
r = requests.put(url, headers=HEADERS, json=payload, timeout=30)
r.raise_for_status()
return r.json()
The content field must be base64 with no line breaks. Python's base64.b64encode produces exactly that. If you are reading a text file, encode it to UTF-8 first — do not pass a str to b64encode, it will raise.
Step 3: One commit per file, with a sleep
The Contents API commits one file per request. There is no multi-file atomic commit through this endpoint; for that you would need the Git Data API (blobs, trees, commits, refs), which is more calls, not fewer. For a deployment that publishes a handful of generated artifacts, one commit per file is fine and much simpler to reason about.
def push_files(files: dict[str, bytes], message_prefix: str) -> None:
for path, content in files.items():
sha = get_file_sha(path)
verb = "Update" if sha else "Add"
result = put_file(path, content, f"{verb} {path}", sha)
commit_sha = result["commit"]["sha"][:7]
print(f"{verb.lower():>6} {path} -> {commit_sha}")
time.sleep(0.4)
The 0.4 second sleep is not superstition. GitHub's secondary rate limits are separate from the 5000/hour primary budget and are triggered by bursty write patterns. A tight loop of PUTs to the same repository is exactly the shape that trips them. Four tenths of a second keeps you under the radar while still pushing a few hundred files in a couple of minutes. If you are pushing thousands of files, raise it or batch through the Git Data API instead.
A 409 response on the PUT means the SHA you supplied is stale — someone else committed between your GET and your PUT. The correct handling is to re-GET and retry once:
def put_file_with_retry(path, content, message, attempts=3):
for i in range(attempts):
sha = get_file_sha(path)
try:
return put_file(path, content, message, sha)
except requests.HTTPError as e:
if e.response.status_code == 409 and i < attempts - 1:
time.sleep(1.0)
continue
raise
Do not loop forever on 409. If two processes are fighting over the same file, retrying just amplifies the conflict.
Verifying the push without git
After the PUTs, confirm the content actually landed. raw.githubusercontent.com is reachable on this network and serves the file at the branch tip:
def verify(path: str, expected: bytes) -> bool:
url = f"https://raw.githubusercontent.com/{OWNER}/{REPO}/{BRANCH}/{path}"
r = requests.get(url, timeout=30)
r.raise_for_status()
return r.content == expected
There is a caching layer in front of raw.githubusercontent.com with a short TTL — usually a minute or two. If verification fails immediately after a push, wait and retry before assuming the push failed. The commit itself is authoritative; check GET /repos/{owner}/{repo}/commits?path={path} if you need certainty faster than the CDN refreshes.
Why this path is worth keeping
The obvious use case is a network that blocks github.com but not the API. That is what prompted this work. But the HTTP-only push path has a second, less obvious value: it works when git itself is broken.
I have hit all of these on build machines:
- A
gitbinary too old to negotiate the current TLS or protocol version, with no admin rights to upgrade it. - A corrupted
~/.gitconfigor credential helper that makes every git invocation fail before it reaches the network. - A container image where the git install is stripped down and
git pushfails on missing helpers. - A CI runner with a read-only filesystem except for a scratch directory, where git's lock files cannot be written.
In all of those cases, a Python script using requests and a token pushes files just fine, because it depends on nothing but the standard library and an HTTP client. The API surface is stable and versioned via the X-GitHub-Api-Version header. There is no local state to corrupt.
The trade-off is real: one commit per file, no atomic multi-file changes, no branches created in a single call, and a hard 100 MB limit per file (the API rejects blobs over that size). For deployment artifacts — config files, generated reports, small data files, documentation — none of that matters. For a large repository with binary assets, stick with git and fix the network instead.
Operational notes
Keep the token out of the script and out of logs. requests will happily include it in an exception traceback if you print the request; log only the URL and status code.
Set an explicit timeout on every call. The default is no timeout, and on a flaky network that means a hung process rather than a clean failure.
If you are pushing to a protected branch, the token's identity must be allowed to bypass the protection or have the right to push. The API enforces branch protection the same way git does, and a 422 on the PUT often means a required status check or review is missing, not that your payload is malformed. Read the message field in the response body — it usually says which rule blocked you.
Finally, keep the probe script. Networks change. The day github.com comes back, git push is still the better tool, and you will want to know which path is live without re-deriving it from scratch.
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-06 12:36:33
Long
| Pair | Signal | Price | Take Profit | Stop Loss | R/R |
|---|---|---|---|---|---|
| SKYAI | $0.0416 | $0.0433 | $0.0406 | 1:1.6 | |
| RE | $0.4991 | $0.5181 | $0.4866 | 1:1.5 |
2 picks selected. Scanner runs every 15 minutes.
Top comments (0)