...
Back

Put the Version in the Filename, Not Just the Release Notes

Overwriting a file at the same URL means your CDN keeps serving the old bytes for hours. If nobody on the team holds a cache-purge token, the filename is your only cache key.

Put the Version in the Filename, Not Just the Release Notes

Put the Version in the Filename, Not Just the Release Notes

A release that is live in your bucket and not yet live for your users is a class of bug that produces no errors, no logs and no failed builds. It just quietly hands people the previous version until something expires.

We hit it moving our downloads onto object storage behind a CDN, and the fix turned out to be a naming convention rather than anything technical.


The shape of the problem

The obvious layout is one stable URL per artifact:

https://dl.example.com/YourApp-Setup-x64.exe

Nice for documentation, nice for support articles, nice for the download button that never has to change. Upload the new build over the old key and everyone gets the new build.

Except they do not. The CDN cached the object with a max-age, and the cache key is the URL. You replaced the bytes behind the key; you did not change the key. Every edge node that already has the old object keeps serving it until its own TTL runs out. With a four-hour max-age that is up to four hours of users downloading a build you believe you replaced — and it is staggered per edge, so your own test from one location can come back correct while half the world is still on the old file.


Why "just purge the cache" often is not available

The standard answer is to purge. That requires an API token with cache-purge permission on the zone.

Check whether you actually have one before you design around it. We did not. Both tokens in our deployment pipeline were scoped for what they were created to do — DNS records for one, storage for the other — and neither could purge. Minting a new token with broader permissions, wiring it into CI and storing it somewhere every release can reach it is a real change to your security surface, made under time pressure, to solve a problem that has a free alternative.

The free alternative is to stop reusing the key.


Versioned filenames plus a stable alias

Write the build to a versioned key, and keep the bare name as a copy:

YourApp-Setup-x64-1.1.0.exe     ← what the site links to
YourApp-Setup-x64.exe           ← "latest" alias, same bytes

The site's download config points at the versioned name. A new version is a new key, therefore a new cache entry, therefore live the moment the deploy lands — no purge, no waiting, no per-edge skew.

The bare name stays because links escape. They are in old release notes, in forum posts, in support replies, in someone's bookmarks. Keeping it pointing at current bytes means those links keep working, and the fact that they are served from a stale cache for a few hours matters much less when they are not the link you just told people to use.

Release order matters and is worth writing down next to the config:

  1. Upload the new versioned file.
  2. Overwrite the bare alias with the same bytes.
  3. Change the site to point at the new versioned name.
  4. Deploy.

Do it in that order and there is no window where the site references a key that does not exist yet.


Two things we would have got wrong without checking

Verify the backend is what you think it is. Our config had been updated to flat paths on the assumption that a storage migration was complete. It was not: the bucket existed and the objects were in it, but no custom domain was bound, so the hostname still resolved to the old provider. Every new-style URL returned 404 while every old-style URL returned 200. The give-away was the 404 body — it was the old provider's JSON error format, not the new one's — and the presence of provider-specific response headers on the 200s. Fetch a real file and read the headers before you trust a migration note.

Your own DNS lies to you right after a change. Binding the new hostname worked immediately, and our local curl still reported Could not resolve host for another eight minutes, because the resolver had negatively cached the earlier NXDOMAIN. It looks exactly like a failed binding. Query an authoritative resolver directly, or pin the address, before concluding anything:

dig +short dl.example.com @1.1.1.1
curl --resolve dl.example.com:443:<ip> https://dl.example.com/file

The check that should exist anyway

Whatever your naming scheme, the release is not done until you have fetched the artifact from its public URL — not from the bucket, not from a signed link, but the URL a user would click — and compared its hash to the file you built.

curl -sL -o /tmp/dl "$PUBLIC_URL"
shasum -a 256 /tmp/dl build_outputs/TheArtifact

Two identical hashes and you know the whole chain is correct: the upload, the binding, the cache, the link. It takes a minute and it is the only test that covers all four at once.

If you take one thing from this: the filename is a cache key. Treat changing it as part of shipping, the same way you treat bumping the version string.