Notifio ships as desktop installers, so releasing it is a script rather than a deploy. pnpm release bumps the version, builds the installer for whichever OS it is run on, uploads the artifacts to Cloudflare R2, and then asks R2 whether what arrived is the right size. The download page hands out presigned links to those exact objects, which is why a release overwrites a fixed set of keys instead of publishing a new versioned path each time.
The script runs two external programs: pnpm, to do the actual building, and aws, to upload. They need opposite treatment, and I got one of them wrong in a way the Node docs now flag.
pnpm is not a program
On macOS, pnpm is an executable and spawnSync('pnpm', ['build:mac']) works. On Windows, pnpm is pnpm.cmd, a batch shim, and batch files are not executable on their own. Node's documentation is blunt about it: ".bat and .cmd files are not executable on their own without a terminal, and therefore cannot be launched using child_process.execFile()".
The code in the repo deals with this the obvious way:
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, {
cwd: APP_DIR,
stdio: 'inherit',
shell: isWin,
env: process.env,
});
if (res.error) fail(`${label} could not start: ${res.error.message}`);
if (res.status !== 0) fail(`${label} failed with exit code ${res.status}`);
}
Two conditionals on the same boolean: the name changes and the shell appears. That part is fine. What is not fine is that this is the option Node now tells you not to use.
The deprecation that applies to exactly this line
Node's child_process page lists three ways to launch a .cmd on Windows, and the first one is annotated: "using child_process.spawn() with the shell option set (not recommended, see DEP0190)".
DEP0190 is a runtime deprecation, and its reasoning is specific:
When an args array is passed to
child_process.execFileorchild_process.spawnwith the option{ shell: true }or{ shell: '/path/to/shell' }, the values are not escaped, only space-separated, which can lead to shell injection.
Our args are the literal strings 'install', 'build:win' and 'build:mac', so nothing here is injectable today. But "the args I pass happen to contain no metacharacters" is a property of the current call sites, not of the function, and runPnpm is a general-purpose helper sitting in a script that also reads values out of .env files. The documented alternative is to stop pretending and spawn the interpreter:
const res = isWin
? spawnSync('cmd.exe', ['/c', 'pnpm', ...args], { cwd: APP_DIR, stdio: 'inherit' })
: spawnSync('pnpm', args, { cwd: APP_DIR, stdio: 'inherit' });
Same two branches, no shell option, and the args array stays an args array instead of being flattened into a command line. That is the change this script wants.
aws is a program, so it gets no shell at all
The upload helper is the other half of the pair, and its comment is the whole design:
/** 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, {
cwd: APP_DIR,
stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
env: awsEnv(r2),
encoding: 'utf8',
});
}
This one is passed absolute paths into release/ and s3:// keys. If it ran through a shell, every one of those would be re-parsed, and a repository checked out under a directory with a space in it would start failing in ways that look like a credentials problem. No shell means the argv the function builds is the argv the program receives.
The environment the AWS CLI must not inherit
Uploading a 600MB disk image to R2 past the 300MB web upload limit means the CLI, and the CLI is unusually sensitive to what is already in your environment. The script builds a fresh env, adding five variables and removing three:
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.
// 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;
return env;
}
The deletions are the interesting part. Setting AWS_PROFILE: '' does not mean "no profile", it means "the profile whose name is the empty string", and the CLI will go and fail to find it. delete is the only way to say the thing you mean. AWS_SESSION_TOKEN is there for the same reason in a different shape: a stale token from an unrelated aws sso login earlier in the day gets sent alongside the R2 keys and the request is rejected as inconsistent, long after you have stopped suspecting your own terminal.
The two checksum variables are documented AWS settings rather than a workaround someone invented. AWS's data integrity reference says AWS_REQUEST_CHECKSUM_CALCULATION and AWS_RESPONSE_CHECKSUM_VALIDATION both default to WHEN_SUPPORTED, meaning the CLI computes and sends a CRC-based checksum on every upload that supports one. R2's S3 compatibility table shows its checksum support is partial rather than total: CRC64NVME is accepted as a full-object checksum, CRC32 and CRC32C only as composite. Setting both to when_required moves the CLI onto the path R2 implements.
AWS_DEFAULT_REGION: 'auto' is not laziness either. Cloudflare's own CLI instructions say to use auto because "it is required by the SDK but not used by R2".
The thing this script has not caught up with yet
While writing this I re-read Cloudflare's aws CLI page, and it now opens with a warning that did not apply when the script was written:
Starting with v2.38.0, the aws CLI uses the AWS Common Runtime (CRT) S3 transfer client by default. Some aws s3 operations against R2, such as downloading or deleting objects, may fail with a signature mismatch error.
The suggested fix is aws configure set default.s3.preferred_transfer_client classic. Our script does not set it, which means the fix currently lives in whatever machine state the build host happens to have, which is precisely the kind of thing the rest of this function exists to eliminate. It belongs in awsEnv as AWS_S3_PREFERRED_TRANSFER_CLIENT alongside the checksum pair, and it is going in next.
Verifying rather than trusting
The reason any of this is worth the trouble is the last step. After every upload the script asks R2 what it received:
const res = runAws(['s3api', 'head-object', '--bucket', r2.bucket, '--key', key,
'--endpoint-url', r2.endpoint, '--region', 'auto'], r2, { capture: true });
if (head.ContentLength !== size) {
fail(`Size mismatch for ${key}: local ${size} bytes, remote ${head.ContentLength} bytes`);
}
const remoteVersion = head.Metadata?.version ?? '?';
Every upload carries --metadata version=<x.y.z>, so head-object can report back which build is actually live. A release that uploaded nothing and a release that uploaded the previous build both look like success from the exit code alone.
Takeaways
-
.cmdon Windows needs an interpreter, andshell: trueplus an args array is now a deprecated way to provide one. Spawncmd.exe /cinstead. - Never hand a shell to a command whose arguments are filesystem paths.
- When clearing an environment variable for a child process,
deleteit. An empty string is a value, and some tools will try to use it.
If you are curious what gets released this carefully, the comparison pages explain what the app does differently from the hosted alternatives, and the pricing page covers the one-time licence.
Top comments (0)