DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A failed build un-bumps our version and a failed upload does not, and one line is the difference

pnpm release does three things in order: bumps the version in app/package.json, builds the installers with electron-builder, and uploads them to Cloudflare R2. One command, about 500 lines of Node, no CI involved, because the Mac installer can only be built on a Mac and the Windows one only on Windows.

Most of that script is uninteresting. Four decisions in it are not, and all four are about refusing to do something.

The bump has to survive a failed upload and not a failed build

A version bump is a file edit that happens before the thing it describes exists. If the build then fails, package.json claims a version that was never produced. So the script registers an exit hook:

let revertOnExit = bumped;
process.on('exit', (code) => {
  if (code !== 0 && revertOnExit) {
    fs.writeFileSync(PKG_PATH, pkgRaw);
    warn(`Reverted app/package.json back to ${current}.`);
  }
});
Enter fullscreen mode Exit fullscreen mode

The subtlety is when to stop doing that, and the comment above it is the whole rule:

// If the build fails the bump never shipped, so put package.json back.
// Once a build has succeeded the artifact carries the new version, so the
// bump must stick even if the upload fails (re-run with --skip-build).
Enter fullscreen mode Exit fullscreen mode

So after the artifacts are verified on disk, exactly one line runs:

revertOnExit = false;
Enter fullscreen mode Exit fullscreen mode

From that point the version is reverted by nothing. A failed upload leaves package.json at 1.1.1 and a built notifio-arm64.dmg whose Info.plist says 1.1.1, and recovering is pnpm release --skip-build. Reverting there would produce the thing that is genuinely hard to debug later: a repository that says 1.1.0 and a DMG in your release folder that says 1.1.1.

--skip-build carries the same invariant from the other direction:

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;
}
Enter fullscreen mode Exit fullscreen mode

Passing --skip-build --bump minor is a request to label artifacts that already exist with a version they were not built from. The script does not refuse it, it warns and ignores the bump, because the user's intent is obvious and failing would just make them run it again.

electron-builder's exit code is not evidence that it built anything

electron-builder exits non-zero when it fails, which is a perfectly good contract. The script still does not rely on it alone:

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.'
  );
}
Enter fullscreen mode Exit fullscreen mode

Every expected artifact has to exist and have been modified after the build started, with five seconds of slack for clock and filesystem granularity. The failure this catches is the expensive one: a build that short-circuits somewhere, exits 0, and leaves last week's DMG sitting in release/. That artifact then uploads cleanly, verifies cleanly, and is served to users as the new version. There is no error anywhere, and the only symptom is that a fix you shipped is not in the download.

The same check doubles as the output listing, so a normal run prints what it is about to send:

    notifio-arm64.dmg  278.0 MB
    notifio-x64.dmg  287.7 MB
Enter fullscreen mode Exit fullscreen mode

The R2 keys are fixed, and that is a decision, not laziness

/**
 * R2 object keys are fixed (the download API serves them by name), so a release
 * overwrites the previous one. See RELEASES.md.
 */
Enter fullscreen mode Exit fullscreen mode

There are exactly three objects in the bucket that matter: notifio-setup.exe, notifio-arm64.dmg and notifio-x64.dmg. notifio.app/download has three buttons, each hitting a route that signs a URL for one of those keys by name, which I wrote about in Our download button is a 302 to a URL that stops working in 60 seconds. No manifest, no latest.yml, no version in the path.

The cost is that there is no rollback by URL: an overwritten object is gone. --archive exists for that and uploads a second, version-stamped copy alongside the live one:

function archiveKey(file, version) {
  const ext = path.extname(file);
  return `${path.basename(file, ext)}-${version}${ext}`;
}
Enter fullscreen mode Exit fullscreen mode

It is opt-in rather than default because each archived copy is another 290 MB of storage for a desktop app nobody has ever asked to downgrade. The honest summary is that the fixed key is the right default and the archive flag is there because the day I want it, I will want it badly.

Which is also why the confirmation prompt says what it says:

const what = opts.skipUpload
  ? `Build ${targets.join(' + ')} at version ${version}?`
  : `Build ${targets.join(' + ')} at version ${version} and OVERWRITE the live downloads in R2?`;
Enter fullscreen mode Exit fullscreen mode

The AWS CLI needs its environment taken away from it

R2 speaks S3, so the upload is aws s3 cp. Getting that to work took two unobvious environment changes.

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 —
// an empty AWS_PROFILE makes the CLI look for a profile literally named "".
delete env.AWS_PROFILE;
delete env.AWS_DEFAULT_PROFILE;
delete env.AWS_SESSION_TOKEN;
Enter fullscreen mode Exit fullscreen mode

The checksum one is the first thing anybody hits. Recent AWS CLI v2 versions compute a CRC32 and send it as a trailing header by default, and R2 rejects the request rather than ignoring the part it does not implement. when_required turns it off for ordinary puts.

The profile one is subtler and is the reason the comment says "drop, not blank". My own machine has AWS credentials for unrelated work. Setting AWS_PROFILE='' does not clear it, it asks the CLI for a profile whose name is the empty string, and the error you get back is about a missing profile rather than about anything you did. delete is the only correct spelling. AWS_SESSION_TOKEN is in the same list because a stale session token from a different account will be preferred over the long-lived R2 keys and produce a signature failure that looks like bad credentials.

There is a matching platform detail in how the two child processes are spawned:

function runPnpm(args, label) {
  // pnpm is a .cmd shim on Windows, which Node refuses to spawn without a shell.
  const isWin = process.platform === 'win32';
  const res = spawnSync(isWin ? 'pnpm.cmd' : 'pnpm', args, { ..., shell: isWin });
Enter fullscreen mode Exit fullscreen mode
/** aws is a real executable on both platforms, so no shell (paths may contain spaces). */
function runAws(args, r2, { capture = false } = {}) {
  return spawnSync('aws', args, { ..., env: awsEnv(r2) });
Enter fullscreen mode Exit fullscreen mode

One needs a shell and the other must not have one. shell: true re-parses the argument array through the shell, so a path containing a space stops being one argument. Since the local artifact path is built from __dirname, and plenty of people have a space in their home directory or project path, that is a real break rather than a theoretical one.

Uploading is not the same as having uploaded

aws s3 cp exiting 0 is good but not conclusive, so every object is read back:

const res = runAws(['s3api', 'head-object', '--bucket', r2.bucket, '--key', key, ...], r2, { capture: true });
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}`);
Enter fullscreen mode Exit fullscreen mode

The upload sets --metadata version=1.1.1, and the verify step prints what R2 says that metadata is, which answers the question you actually have after a release: not "did the command succeed" but "is the file people are downloading right now the one I just built". A truncated multipart upload, a key typo, a bucket that is not the bucket you meant: all three show up here as a size mismatch or a stale version string, in a step that costs one HTTP request per object.

Why it is 290 MB per installer, and why that matters here

The DMGs are 278.0 MB and 287.7 MB because each one ships a Playwright Chromium. R2's dashboard upload tops out at 300 MB, which is why the script requires the CLI and says so in the error:

fail(
  'AWS CLI not found. It is used to upload files larger than R2\'s 300MB web UI limit.\n' +
  (process.platform === 'win32'
    ? '  Install: winget install Amazon.AWSCLI'
    : '  Install: brew install awscli')
);
Enter fullscreen mode Exit fullscreen mode

287.7 MiB is 301.7 MB in decimal, so which side of that limit the x64 DMG falls on depends on which megabyte Cloudflare means. Finding out by hand on a release day is not a thing I want to do, hence the CLI.

They are two separate DMGs rather than one universal binary for the same reason, which is the subject of Two DMGs, because a universal build would ship two Chromiums.

What generalises

The pattern in all four of these is the same: a release script's job is not to do the steps, it is to refuse to half-do them.

  1. Decide when a side effect becomes permanent, and write the line that makes it so. A mutation made in step one and committed in step three needs an explicit point where it stops being reversible.
  2. Verify the artifact, not the exit code. An mtime comparison is four lines and rules out the entire class of "built nothing, said nothing".
  3. Read back what you wrote. One head-object per object turns "the command succeeded" into "the bytes are there".
  4. Take the ambient environment away from any CLI you shell out to. Your machine has credentials, profiles and session tokens for other things. Delete rather than blank them, because an empty string is a value.

The output of all this is three files behind notifio.app/download. If you want to see what the app does with them once installed, notifio.app/help is the setup walkthrough.

Top comments (0)