Notifio is a desktop app that watches rental search pages and emails you when a new listing appears. Shipping it is one command:
pnpm release
That bumps the version in app/package.json, builds the installers for whatever OS it is running on, uploads them to Cloudflare R2 under fixed object keys, and then does a set of checks that are the actual subject of this post. I have written before about the version bump being reverted when the build fails but not when the upload does. This is the other half: everything the script refuses to take on trust.
The thing it is shipping is a paid download with no auto-update, served from notifio.app/download. A bad artifact there is not a rollback, it is an email from someone whose app will not open.
Trust problem 1: a successful build may not have built anything
electron-builder exits non-zero when it fails, so a naive script can treat exit 0 as "the DMG is on disk and it is new". It is not quite the same claim. A stale artifact from last week's build sitting in release/ satisfies "the file exists" perfectly well, and the paths involved are long enough that a typo in a target name produces exactly that.
So after the build step, every expected artifact is checked twice:
step('Verifying build output');
for (const { file } of uploads) {
const full = path.join(RELEASE_DIR, file);
if (!fs.existsSync(full)) {
fail(`Expected artifact missing after build: ${path.relative(REPO_ROOT, full)}`);
}
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.'
);
}
info(`${file} ${humanSize(stat.size)}`);
}
buildStart is captured before the first electron-builder invocation, so the rule is: every artifact must be younger than the build. The five second slack is for clock and filesystem granularity, not for politeness.
The printed size line matters more than it looks. Each Mac DMG is around 600MB because the app bundles Playwright's Chromium, and a size that jumps by a few hundred megabytes means the afterPack hook that strips the wrong-arch browser quietly did nothing. That hook is deliberately written to skip rather than throw, which only works if something downstream shows me the number.
Trust problem 2: an upload that returned 0 may not be the file
aws s3 cp exiting zero means the CLI thinks it finished. Across a 600MB multipart upload, over hotel wifi, to an S3-compatible endpoint that is not S3, I would like a second opinion. Every object gets read back:
step('Verifying objects in R2');
for (const { key, size } of results) {
const res = runAws([
's3api', 'head-object',
'--bucket', r2.bucket,
'--key', key,
'--endpoint-url', r2.endpoint,
'--region', 'auto',
], r2, { capture: true });
if (res.status !== 0) {
fail(`head-object failed for ${key}: ${(res.stderr || '').trim()}`);
}
let head;
try {
head = JSON.parse(res.stdout);
} catch {
fail(`Could not parse head-object response for ${key}`);
}
if (head.ContentLength !== size) {
fail(`Size mismatch for ${key}: local ${size} bytes, remote ${head.ContentLength} bytes`);
}
const remoteVersion = head.Metadata?.version ?? '?';
info(`ok ${key} ${humanSize(head.ContentLength)} version=${remoteVersion}`);
}
Two details in there are worth stealing.
The size comparison is exact bytes, local against remote, not a rounded megabyte figure. Rounding is how a truncated upload passes a check.
And the version is readable from the object itself, because it was written as object metadata at upload time:
'--metadata', `version=${version}`,
The R2 keys are fixed. notifio-setup.exe is always notifio-setup.exe, because the download API serves objects by name, so a release overwrites its predecessor and the key tells you nothing about what is in it. Metadata is the only thing that can answer "which version is live right now", and that question is worth being able to answer from a terminal rather than by installing the app.
Trust problem 3: the AWS CLI is not talking to AWS
R2 is S3-compatible, which is a claim with footnotes. Two of them cost me a confused hour each, and both live in the environment the CLI is spawned with:
function awsEnv(r2) {
const env = {
...process.env,
AWS_ACCESS_KEY_ID: r2.accessKeyId,
AWS_SECRET_ACCESS_KEY: r2.secretAccessKey,
AWS_DEFAULT_REGION: 'auto',
AWS_REGION: 'auto',
// R2 rejects the CRC32 trailer that AWS CLI v2 sends by default.
AWS_REQUEST_CHECKSUM_CALCULATION: 'when_required',
AWS_RESPONSE_CHECKSUM_VALIDATION: 'when_required',
AWS_EC2_METADATA_DISABLED: 'true',
};
// Drop (not blank) any local profile/session that could shadow the R2 keys.
delete env.AWS_PROFILE;
delete env.AWS_DEFAULT_PROFILE;
delete env.AWS_SESSION_TOKEN;
return env;
}
Recent AWS CLI v2 versions attach a streaming CRC32 checksum trailer to uploads by default. R2 rejects it, and the error you get does not say the word checksum. when_required turns that off without turning off checksums where the protocol actually needs them.
The other one is subtler. Deleting AWS_PROFILE is not the same as setting it to an empty string: an empty AWS_PROFILE makes the CLI go looking for a profile named "", fail to find it, and report a credentials problem while four correct credentials sit in the same environment. Anything that could shadow the keys is removed from the object rather than emptied.
AWS_EC2_METADATA_DISABLED is there because a CLI that cannot find credentials will try the EC2 instance metadata endpoint, and on a laptop that is a timeout rather than an answer.
Trust problem 4: the host cannot build what you asked for
Installers are not cross-buildable here, so the script refuses early and says what to do instead:
const host = process.platform === 'win32' ? 'win' : process.platform === 'darwin' ? 'mac' : null;
if (!host) fail(`Building is only supported on Windows and macOS (this is ${process.platform}).`);
const impossible = wanted.filter((p) => p !== host);
if (impossible.length) {
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.`
);
}
Every fail() in this script tries to name the next action. The failure modes of a release script are all encountered months apart, by one person, who has forgotten everything, and that person is me.
--skip-build has a matching guard, because uploading artifacts built elsewhere must not move the version:
if (opts.skipBuild && (opts.bump !== 'none' || opts.version)) {
warn('--skip-build: keeping the current version so package.json stays in sync with the built artifacts.');
opts.bump = 'none';
opts.version = null;
}
A bump here would describe the DMG as 1.2.0 when the binary inside says 1.1.0. The flag combination is a mistake rather than an instruction, so it is corrected out loud instead of being obeyed or rejected.
Two small things that are not verification but are the same instinct
Spawning subprocesses cross-platform has one rule each way:
// pnpm is a .cmd shim on Windows, which Node refuses to spawn without a shell.
const res = spawnSync(isWin ? 'pnpm.cmd' : 'pnpm', args, { shell: isWin, ... });
// aws is a real executable on both platforms, so no shell (paths may contain spaces).
return spawnSync('aws', args, { ... });
A shell is enabled for exactly the one command that needs it, and not for the one that takes file paths as arguments. shell: true everywhere is the convenient answer and it is how a user named C:\Users\Dan Pertu ends up with a release that fails on a space.
And because the R2 keys are fixed, the confirmation prompt says what is really at stake:
const what = opts.skipUpload
? `Build ${targets.join(' + ')} at version ${version}?`
: `Build ${targets.join(' + ')} at version ${version} and OVERWRITE the live downloads in R2?`;
There is also a --dry-run that prints the whole plan, including which .env file the credentials came from, and touches nothing. I use it every time, which is the strongest thing I can say about it.
Why this much paranoia for a 20 pound app
Because there is no auto-update. The install flow is a download from notifio.app/download, an activation token, and then the app sits in the menu bar watching the searches you gave it, which is the thing the per-site alert pages describe. A broken artifact is not a bad deploy that gets replaced in four minutes. It is a file that people keep downloading until someone tells me, and the first person to tell me will do it through the form on notifio.app/help.
Checks that run before an upload are cheap. The expensive version of this post is the one written after shipping a zero byte DMG.
Top comments (0)