You add --dry-run to a batch job to suppress side effects and see what would happen first. But if a check itself depends on the result of a side effect, suppressing the effect also removes the check. The real run behaves correctly while only the dry run prints the wrong answer. Based on a case hit on 16 August 2026, here is the cause, the fix and the regression test.
What happened
The job renames files carrying an older date to today's date before processing them, which is a common shape. The new name is "today's date plus whatever follows the date in the original name".
2026-08-09-report-blogger.md → 2026-08-16-report-blogger.md
2026-08-11-report-blogger.md → 2026-08-16-report-blogger.md // the same name
Two files that differ only in their date collide at the destination. The implementation had a guard for that: just before renaming, it checked the destination with fs.existsSync and skipped if it was already taken.
const to = path.join(dir, promotedName(base, today));
// skip if the destination already exists
if (fs.existsSync(to)) { warn(`skip: ${to}`); continue; }
if (!DRY) fs.renameSync(from, to);
log(`promote${DRY ? ' [dry]' : ''}: ${base} -> ${path.basename(to)}`);
In a real run the guard works. The moment the first renameSync completes, the destination exists, so the second iteration's existsSync returns true and that file is skipped. Nothing is lost.
The dry run is the problem. if (!DRY) skips renameSync, so the destination is never created. The second existsSync stays false and both files are reported as being renamed.
$ node promote.js --dry
promote [dry]: 2026-08-09-report-blogger.md -> 2026-08-16-report-blogger.md
promote [dry]: 2026-08-11-report-blogger.md -> 2026-08-16-report-blogger.md
Generalising the cause
The shape is not specific to renaming. Any code that checks "what I am about to create" against external state has the same hole.
| What the check reads | What disappears in a dry run |
|---|---|
fs.existsSync(path) | The file created earlier in the loop is missing, so everything looks uncreated |
| Re-listing a directory | Same thing: creations are never reflected, so the same name passes repeatedly |
| A SELECT for duplicate keys | The INSERT is suppressed, so the second row with the same key also looks new |
| Port or lock availability | Nothing is reserved, so a plan that gives the same port to several processes passes |
| Looking up issued IDs | Nothing is issued, so the plan hands out the same ID more than once |
What they share is that the check reads the current state of the world. In a real run the world advances as the loop proceeds, so the check holds; in a dry run it never advances, so the check does not.
The fix: check against the plan, not the world
The fix is simple. Record the results of the operations you intend to perform in your own ledger and check both the external state and the ledger. The ledger is updated in both modes, so both reach the same conclusion.
const claimed = new Set(); // destinations claimed by this run for (const base of targets) { const toBase = promotedName(base, today); const to = path.join(dir, toBase); // consult both the world and this run's plan if (fs.existsSync(to) || claimed.has(toBase)) { warn(`skip: ${toBase} (${base} stays under its original name)`); continue; } claimed.add(toBase); if (!DRY) fs.renameSync(from, to); log(`promote${DRY ? ' [dry]' : ''}: ${base} -> ${toBase}`); }
After the fix the dry run prints the same conclusion as the real run.
$ node promote.js --dry
promote [dry]: 2026-08-09-report-blogger.md -> 2026-08-16-report-blogger.md
skip: 2026-08-16-report-blogger.md (2026-08-11-report-blogger.md stays under its original name)
It also matters that the skip message says what happens to the rejected item. "Skipped" alone leaves the reader unsure whether that file was deleted or kept. Here it states explicitly that the file stays under its original date.
Regression test: run both modes on the same input
Because this class of bug appears in one mode only, exercising both modes in a single test is the reliable approach. What you assert is that the number of eligible items is the same.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { spawnSync } = require('node:child_process');
test('two targets differing only by date promote exactly once (dry agrees)', () => {
for (const dry of [true, false]) {
const dir = makeWorkspace(['2026-08-09-z.md', '2026-08-11-z.md']);
const args = ['promote.js', '--date', '2026-08-16'];
if (dry) args.push('--dry');
const r = spawnSync('node', args, { cwd: dir, encoding: 'utf8' });
const out = (r.stdout || '') + (r.stderr || ''); // warnings go to stderr
const promoted = out.split('\n').filter((l) => /^promote/.test(l));
assert.equal(promoted.length, 1, `exactly one promotion expected (dry=${dry})`);
assert.match(out, /^skip:/m);
if (!dry) {
// the rejected file is still there under its original name
const left = fs.readdirSync(dir).filter((f) => /^2026-08-(09|11)-z\.md$/.test(f));
assert.equal(left.length, 1);
}
}
});
One thing that tripped us up
The first version of the test used execFileSync, and the warning was invisible in the non-dry case only. The reason is where it is written. execFileSync returns stdout alone when the process succeeds, and the warning goes to console.warn, that is stderr. The dry case passed because it happened to exit non-zero and take the exception path, where the helper concatenated stdout and stderr.
When a test needs both streams, use spawnSync and concatenate stdout and stderr explicitly. A test that inspects less when the command succeeds is exactly the kind that lets a quiet failure through.
Checklist
If your batch job has a dry-run mode, three things are worth checking.
- Is there a check inside the loop that assumes what the previous iteration created?
existsSync, re-listing, and SELECT-based duplicate checks are the usual suspects. - Does that check still hold in a dry run? If not, add a ledger that records the plan.
- Is there a test that runs both modes on the same input and asserts an equal count? If not, add one in the shape above.
A dry run exists so that a person can decide before anything is executed. When the material for that decision diverges from the real run, the feature works against you. "The real run is correct, so it is minor" is the wrong reading: the accuracy of the output deserves the same weight.
Reproduction steps
- Put
2026-08-09-z.mdand2026-08-11-z.mdin a working directory - Run the pre-fix code with and without
--dryand compare the output lines (before the fix, the dry run prints two promotions while the real run prints one promotion and one skip) - Run the fixed code, with the
claimedledger, both ways and confirm the two outputs agree - Measured on 16 August 2026 with Node.js v22
Frequently asked questions
Why would a dry run and a real run produce different results?
Because the check itself depends on the result of a side effect. A duplicate check based on fs.existsSync works in a real run, since the preceding rename has already created the file, but in a dry run the rename is skipped, so the file does not exist and both entries look eligible. Suppressing the side effect also removed the check that relied on it.
How do I fix it?
Move the check from the state of the world to the state of the plan. Record the names you are about to create in a ledger such as a Set, and check both the external state and the ledger. The ledger is updated in both modes, so the two runs reach the same conclusion.
How should the regression test be written?
Run the same input through both the dry and the real mode and assert that the number of eligible items matches. Beyond the count, also assert that in the real run the rejected item is still there under its original name, which catches the case where something disappears silently.
Why does the test sometimes fail to see the warning message?
Because the warning goes to stderr, and a helper that captures only stdout on success will not see it. Node.js execFileSync returns just stdout when the process exits zero, so use spawnSync and concatenate stdout and stderr when the test needs to inspect both.