I keep the images for my blog in GitHub Releases. Not software builds — PNGs. Article illustrations, avatar assets, that kind of thing, attached to releases as GitHub Release assets and served straight from releases/download/... URLs.
It works well: the release URLs open, the asset lists render, and the illustrations in my published articles load from those URLs every day.
Then I read my own automation logs for a second release I use for avatar assets: download=failure.
The URL for that release opens fine too.
TL;DR: "Upload succeeded" and "a consumer can get the same bytes back" are different claims, verified by different checks. A release URL proves the box exists, not that your files are in it. The fix is a 5-step round-trip check — URL, asset list, re-download through the consumer's path, SHA-256 comparison, ledger — designed around where to stop when something fails, not around the happy path.
Quick answer
Before you call a release "done", walk these five stages in order and stop at the first one that fails:
- Expectation first: decide which file, in which role (editable original / preview / compressed distribution), in which format you need back.
-
Asset list: query the release's assets via the API and compare
name/state/sizeagainst your expectation. - Re-download: fetch the assets through the same path a consumer would use, into an empty directory. Never inspect the folder you uploaded from.
- SHA-256: hash the canonical original and the round-tripped copy, and compare across that boundary.
- Ledger: record what each check actually verified — not a single "done" flag.
A failure at any stage parks the process at that stage. You don't advance, and you don't quietly switch to a different file or release to make the check pass.
A release URL is a shipping label, not the contents
In upload automation, "create the release" and "upload the assets" are separate steps — separate API calls, often separate script lines. The first can succeed while the second fails on permissions, a filename mismatch, or a plain network error. When that happens you get a perfectly valid release URL attached to a box that's missing contents. My own workflow logs include runs where the release-creation step reported success and an asset-upload step after it did not.
If your definition of done is "the URL came back", you will ship empty boxes and not know it. The URL opening is evidence that the box exists. That's all it's evidence of.
There's a second, quieter problem: the same artifact usually exists in several forms. The editable original, a preview, a resized copy, a compressed distribution — in a browser they can look identical. Naming conventions (original, source, preview) help as an index, but nobody enforces them, and they guarantee nothing about the bytes. Re-saving a PNG through a different tool changes its compression and metadata while the pixels look the same. Rebuilding a ZIP with the same files produces different bytes if timestamps or ordering changed. A copy that went through an image CDN or a chat app may be a resized or re-encoded version of what you sent. Filenames and eyeballs cannot settle "is this the same file". Hashes can.
Step 1: Fix your expectation before you check anything
The first thing to verify is not the URL. It's your own claim: which file, in which role, in which format do I need to get back?
Skip this and the check degrades into validating whatever happens to be there. If the release holds a preview and you needed the editable original, every downstream check can pass while verifying the wrong thing. Write the expectation down first — filename, role, format — and only then look at what the release actually holds.
If several releases could plausibly be the home of your canonical files (a trial-distribution one, a test one, an archive one), pick by the role each release has actually served, not by which has the friendliest name. And before adding a missing file to whichever release is closest at hand, check whether the real canonical home already exists — quietly promoting a scratch release to "canonical" changes its meaning for everyone who used it before.
Step 2: List what the server claims to have
GitHub calls files attached to a release assets, and the release assets REST API returns them machine-readably:
gh api "repos/<owner>/<repo>/releases/tags/<release-tag>" \
--jq '.assets[] | {name, state, size}'
Enter fullscreen mode Exit fullscreen mode
Per the docs, each asset carries a name, a state, a size, a browser_download_url, and a digest. Check three things against your expectation:
- Names: is everything you expected present — and are you not about to grab a similarly-named variant or an older version?
- State and size: an asset that's mid-processing or zero bytes isn't distributable, no matter how correct its name is.
- Scope: if one release serves several purposes, filter to your own files by name pattern instead of counting totals, so unrelated uploads don't move your baseline.
Then record the listing. This matters more than it looks: the asset list is evidence of what the server claims to hold. Evidence of what a consumer can actually receive doesn't exist yet — that's the next step, and conflating the two is exactly the mistake the URL already invited.
Step 3: Re-download through the consumer's path
DOWNLOAD_DIR="$(mktemp -d)"
gh release download "<release-tag>" \
--repo "<owner>/<repo>" \
--pattern "<asset-pattern>" \
--dir "$DOWNLOAD_DIR"
Enter fullscreen mode Exit fullscreen mode
Two rules make this step meaningful:
-
Empty directory. If a stray local copy is sitting in the target dir, you can end up hashing a file that never went through the distribution path.
mktemp -dmakes the mistake hard. - Consumer's path, consumer's permissions. Checking the folder you uploaded from proves nothing about whether the distribution channel works. And on a private repo, "an admin can download it" and "the intended consumer can download it" are different claims — test with the access level your consumers actually have instead of widening permissions until the error goes away.
When the download completes, don't jump to hashing. List what actually arrived and compare it against the recorded asset list first: anything missing, anything extra, any size that doesn't match — stop here.
This is also where my own avatar-assets check is parked. The log line says download=failure. Because re-download hasn't passed, everything downstream — size confirmation, hash comparison, round-trip completion — is unverified, and my records say exactly that instead of something more comfortable.
Step 4: Compare SHA-256 across the boundary
Matching name and matching size still don't mean matching bytes. SHA-256 computes a fixed-length digest over the entire content; change any byte and the digest changes completely. It's standardized in NIST's Secure Hash Standard (FIPS 180-4), and GNU coreutils documents the toolchain.
# macOS
shasum -a 256 "<canonical-original>"
shasum -a 256 "$DOWNLOAD_DIR/<asset-name>"
# Linux
sha256sum "<canonical-original>" "$DOWNLOAD_DIR/<asset-name>"
Enter fullscreen mode Exit fullscreen mode
The comparison is only meaningful if the two sides sit on opposite sides of the distribution boundary: the canonical original you fixed in step 1 on one side, the copy that round-tripped through the release on the other. Hashing two copies that live in the same place tells you nothing you didn't know.
On a mismatch, check what you compared before suspecting corruption: right file? right version? right release? any transform in between (re-save, re-compress, CDN)? What you don't do is shrug it through because "it looks the same". An unexplained mismatch keeps distribution on hold until it's explained.
What a match looks like is unspectacular by design — the same digest on both sides of the boundary:
<same-64-hex-digest> <canonical-original>
<same-64-hex-digest> $DOWNLOAD_DIR/<asset-name>
Enter fullscreen mode Exit fullscreen mode
Any difference between the two lines, however small the underlying change, produces completely different digests.
If you maintain a manifest of expected digests, sha256sum -c manifest.sha256 runs the whole comparison mechanically and goes red on any difference.
Step 5: Record what you verified, not what you planned
The last stage is a ledger: for each asset, the expectation side (role, expected format, release-side name, where the baseline digest lives) and the observation side (name actually present, download path that worked, re-download result, hash result).
Two rules keep the ledger honest:
- Don't collapse states into "done". Use states that say what was actually checked: planned, upload confirmed, re-downloaded, hash verified, on hold. A "planned" URL written before the release existed is fine — as long as it can't be mistaken for a verified one.
- Don't overwrite expectation with observation. If the plan said one thing and reality said another, that difference is information. Keep both columns.
Record the run conditions too — which release, which pattern, which auth, the fact that the download dir was empty, which baseline version you compared against. The same commands mean different things under different conditions. And keep the ledger itself under version control: a verified state with a lost ledger puts the next check back at zero.
The completion condition is not "I updated the ledger". It's "the ledger matches the facts I actually verified".
When a check goes red, split the failure in two
Automating this verification adds a new failure mode: the verifier itself. A red check does not mean the artifact is broken, and a green check does not mean everything is there.
Before touching the artifact, check the verifier's wiring: API target, working directory, auth, release tag, glob pattern. Any one of those being stale produces a red that has nothing to do with your files. Conversely, a green that verified one file that happened to exist says nothing about the full set being present.
Keeping "inventory" (what does the server claim to hold) and "round-trip" (can I get it back and does it match) as separate jobs with separate records is what makes this split cheap: you can see whether the listing failed, the download failed, or the content mismatched, instead of staring at one merged red light.
And on the red path, hold the line on two things:
- Never swap targets mid-verification. Switching to a different file or release to make the check pass replaces the question you were answering. The result may look green; it verified something else.
- Resume conditions are concrete. "The error went away" doesn't reopen distribution. The listing matches expectation again, the same target re-downloads into a fresh empty directory, and the hash comparison passes — that reopens it. If you fixed the verifier, redo the artifact checks from scratch: the verifier working again and the artifact being correct are separate facts.
This applies directly if an AI agent or a pipeline does the uploading for you. "Upload this" invites a report of accepted as if it were delivered. Ask for the asset list, the re-download result, the hash comparison, and the ledger update as separate results, and you can see exactly which stage stopped — the same property this whole procedure is built around.
Where my own check is stuck right now
Full disclosure of the current state, because the honest version is more useful than a tidy one:
- The article-images release works — it's load-bearing for my published articles daily, which is a continuous, if informal, round-trip test.
- The avatar-assets release is parked at step 3. The manifest lists the files to distribute and where their SHA-256 baselines live; the automation log records
download=failure; therefore size confirmation, hash comparison, and round-trip completion are all unverified and recorded as such.
Nothing in my ledger claims those files are recoverable, because nothing has proven it yet. That's not the procedure failing. That's the procedure doing the one thing it's for: refusing to let "the URL works" stand in for "I got the bytes back".
A practical checklist
If a GitHub Release (or any artifact store) holds files you'll need back someday, run this before calling it done:
- Write down which file, in which role, in which format you expect to recover — before looking at what's there.
- List the release's assets via the API and record
name/state/sizeagainst that expectation. - Re-download into an empty directory, through the path and permissions your consumers actually use.
- Compare SHA-256 digests across the distribution boundary — canonical original vs round-tripped copy.
- Record per-asset states the ledger can defend: planned / upload confirmed / re-downloaded / hash verified / on hold.
- On any red, split verifier failure from artifact failure before touching either.
- Never swap the target mid-check, and treat "error went away" as insufficient to resume.
FAQ
Isn't downloading from the release page by hand enough?
For everyday use, often yes. As verification that a delivery or a canonical archive is intact, no — you still haven't checked that the set is complete or that the content matches your baseline. Right version, no missing files, matching digest: three separate checks.
If the size matches, is it the same file?
No. Size is a cheap early filter for empty files and gross truncation. Different content with identical size is entirely possible; when content identity matters, compare hashes.
If SHA-256 matches, am I done?
For that one file's content, against the copy you compared — yes, that's a strong result. Whether all required files are present, and whether the download path will still be reachable next time, are separate questions. That's what the asset list and the ledger cover.
There's a failure in my logs. Can I just retry until it's green?
Retry, sure — but don't reinterpret. The check stays parked at the failed stage, the failure and its conditions go in the ledger, and the retry targets the same file and release. Quietly retrying against a different target destroys the comparison you were making.
I already keep a manifest. Doesn't that cover this?
A manifest fixes your expectation, which is genuinely half the work. It cannot rule out that a file was deleted, replaced, or became unreachable after you wrote it. The manifest is the expectation side; the asset list, the re-downloaded files, and the hash results are the observation side. You need both — and the release's own asset list doesn't replace the manifest either, because it can't tell you which original, in which role, each asset is supposed to correspond to.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.