Notifio ships three installers: one NSIS setup for Windows and two DMGs for Mac, because a universal build would bundle two copies of Chromium. They are built locally, uploaded to Cloudflare R2 under fixed object keys, and served through an API route that presigns a URL and redirects to it.
That means one filename, notifio-arm64.dmg, has to be written identically in three files that know nothing about each other.
The triangle
First, electron-builder decides what the file on disk is called:
"mac": {
"target": [{ "target": "dmg", "arch": ["arm64", "x64"] }],
"artifactName": "notifio-${arch}.dmg"
},
"win": {
"target": ["nsis"],
"artifactName": "notifio-setup.exe"
}
Second, the release script decides what to upload and under what key, and its comment is doing all of the work that a type would otherwise do:
// Artifact name -> content type. Names must match package.json build.artifactName
// and the FILES map in server/src/app/api/download/[platform]/route.ts.
const ARTIFACTS = {
win: [{ file: 'notifio-setup.exe', contentType: 'application/octet-stream' }],
mac: [
{ file: 'notifio-arm64.dmg', contentType: 'application/x-apple-diskimage' },
{ file: 'notifio-x64.dmg', contentType: 'application/x-apple-diskimage' },
],
};
Third, the web app decides which key to presign for a given download button:
const FILES: Record<string, string> = {
"mac-arm64": "notifio-arm64.dmg",
"mac-x64": "notifio-x64.dmg",
win: "notifio-setup.exe",
};
Three files, two packages, one of them a desktop app and one a Next.js site, and the only thing keeping them in step is a sentence in a comment. Change artifactName and the build succeeds, the upload succeeds for the two files that still match, and the third fails with "Artifact missing" if you are lucky or uploads nothing if you changed all of them. Change only the download route and the build and upload both pass, and the download page serves a 403 from R2 for a key that is not there.
In fairness to the release script, it does not take electron-builder's word for anything. It checks that each expected artifact exists and that it was modified during this run:
const stat = fs.statSync(full);
if (stat.mtimeMs < buildStart - 5000) {
fail(
`${file} was not rebuilt (last modified ${stat.mtime.toISOString()}).\n` +
' The build reported success but produced no new artifact.'
);
}
So a renamed artifact is caught on the build side. It is the web side that has no idea.
The keys are fixed, so a release overwrites
The comment at the top of the release script states the consequence rather than burying it:
/**
* R2 object keys are fixed (the download API serves them by name), so a release
* overwrites the previous one. See RELEASES.md.
*/
There is a --archive flag that also uploads notifio-setup-1.1.0.exe alongside, and it is opt-in and off by default. So in the normal case there is exactly one copy of each installer and no history, which means no rollback: shipping a broken build and shipping the fix are the same operation at the same key.
What I did not appreciate until I went looking is that every upload records its version anyway:
const res = runAws([
's3', 'cp', local, `s3://${r2.bucket}/${key}`,
'--endpoint-url', r2.endpoint,
'--content-type', contentType,
'--metadata', `version=${version}`,
...
], r2);
And reads it back as part of the verify step, comparing the remote size against the local one:
if (head.ContentLength !== size) {
fail(`Size mismatch for ${key}: local ${size} bytes, remote ${head.ContentLength} bytes`);
}
const remoteVersion = head.Metadata?.version ?? '?';
info(`\x1b[32mok\x1b[0m ${key} ${humanSize(head.ContentLength)} version=${remoteVersion}`);
That is the only code in either package that reads it. The download route presigns a GET and redirects. It does not look at the metadata, it does not surface a version, and nothing on the website knows which build it is handing you.
One byte is enough to find out
The object metadata is served on the download response, so you can read it from outside with a range request for a single byte. Grab the redirect target first, since the presigned URL only lives for 60 seconds:
$ for p in win mac-arm64 mac-x64; do
loc=$(curl -s -D- -o /dev/null "https://notifio.app/api/download/$p" \
| grep -i '^location:' | sed 's/^[Ll]ocation: //;s/\r//')
echo "== $p"
curl -s -r 0-0 -D- -o /dev/null "$loc" \
| grep -iE "^(content-range|last-modified|x-amz-meta-version)"
done
== win
Content-Range: bytes 0-0/237616310
Last-Modified: Tue, 28 Jul 2026 11:35:55 GMT
x-amz-meta-version: 1.0.3
== mac-arm64
Content-Range: bytes 0-0/291511009
Last-Modified: Tue, 22 Sep 2026 12:24:46 GMT
x-amz-meta-version: 1.1.0
== mac-x64
Content-Range: bytes 0-0/301712899
Last-Modified: Tue, 22 Sep 2026 12:25:03 GMT
x-amz-meta-version: 1.1.0
| Platform | Key | Size | Version | Uploaded |
|---|---|---|---|---|
| Windows | notifio-setup.exe |
237,616,310 | 1.0.3 | 28 July |
| Mac arm64 | notifio-arm64.dmg |
291,511,009 | 1.1.0 | 22 September |
| Mac x64 | notifio-x64.dmg |
301,712,899 | 1.1.0 | 22 September |
I have written before about the two months between those dates, and the conclusion there was that nothing in the product can tell you which version you are downloading. That still holds for the product, and it is less true than I thought of the system around it. The answer has been sitting in x-amz-meta-version on every object since the first upload, written by the release script, verified by the release script, and read by nobody after that.
The reason for the split is structural rather than careless. Installers are not cross-buildable, and the script refuses rather than pretending:
fail(
`Cannot build ${impossible.join(' + ')} on ${os.platform()}.\n` +
` Installers are not cross-buildable here: run this script on a ${impossible.includes('mac') ? 'Mac' : 'Windows machine'}, ` +
`then use --skip-build to upload artifacts copied over from the other machine.`
);
A release is therefore two machines, and a release that only happens on one of them leaves the other key untouched at whatever version it last was. The script even says so at the end of a single-platform run. It just says it in a terminal that nobody is looking at two months later.
Why HEAD does not work
The obvious way to read that metadata is a HEAD request, and it returns 403. The presigned URL is signed for a specific operation, which you can see in the query string it hands you:
...&X-Amz-Expires=60&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject
x-id=GetObject is part of the signed material, so switching the verb invalidates the signature. A range GET is still a GET, which is why one byte works where zero bytes does not. The release script does use head-object, but it does so with the R2 credentials directly rather than through a presigned link, so it is not constrained the same way.
The 60 second expiry is deliberate and documented in the route:
// Presigned URL valid for 60 seconds — enough for the browser to start the
// download without permanently exposing the file.
Which is the right trade for a download button and is also why the shell loop above has to fetch a fresh redirect for each platform rather than reusing one.
One more number worth looking at
The release script requires the AWS CLI, and explains why:
fail(
'AWS CLI not found. It is used to upload files larger than R2\'s 300MB web UI limit.\n' + ...
);
The x64 DMG is 301,712,899 bytes. That is 287.7 MiB, so it is under 300MB if you mean the binary kind and over it if you mean the decimal kind. Either way the biggest artifact is now sitting on the exact boundary that the dependency exists to get around, and it is only that size because each installer bundles its own Chromium. The afterPack hook already deletes the browser for the architecture that build will never run, which is the only reason it is not twice as large.
What I would change
Both fixes are small and neither is about the upload.
The filename triangle wants one shared constant, and it cannot have one, because app/ and server/ are separate packages that do not import each other. The next best thing is a test in server/ that reads app/package.json, expands ${arch} and asserts the resulting set equals the values of FILES. It is a ten-line test that converts a comment into a failure.
The version header wants a reader. The download route already talks to R2 with credentials; a HeadObjectCommand before the redirect would let the download page print the version and build date next to each button. That is a real feature for a desktop app whose update mechanism is reinstalling, and the data is already there. The only reason it is not on the page is that nobody thought to ask the object what it was.
If you want to run the range-request loop above against the live endpoints, it is three HTTP requests and three bytes, and it will tell you more about what Notifio currently ships than the download page itself will.
Top comments (0)