Summary
deno's configFiles list contains both a bare filename and the precise key check for the same file (src/formatters.ts:102):
const deno: Formatter = {
name: "deno",
// https://docs.deno.com/runtime/reference/cli/fmt/#configuring-the-formatter
configFiles: ["deno.json", "deno.jsonc", { file: "deno.json", key: "fmt" }],
...
};
detect() iterates that array in order and returns on the first hit (src/detect.ts), so the bare "deno.json" always matches before the { file: "deno.json", key: "fmt" } entry is reached. The third entry is dead code: no deno.json can ever reach it, and the linked documentation says the fmt key is exactly what configures deno fmt.
The consequence is that the mere existence of a deno.json — for any reason, including one that says nothing about formatting — selects the deno formatter. deno is second in defaultDetectOrder and prettier is last, so it wins over a prettier the project actually depends on.
That would be a harmless mis-detection for any other entry in the table. It is not harmless for deno, because deno is the only formatter with no packageName:
| Formatter |
How it is invoked |
prettier, biome, oxfmt, dprint |
packageManagerExecute(...) → resolveCommand(pm, "execute-local", …), falling back to npx — resolves out of node_modules |
deno |
spawnProcess("deno", ["fmt", …]) — straight off PATH, no existence check, no fallback |
So on any machine without Deno installed — which includes GitHub's ubuntu-latest, macos-latest and windows-latest runners — the result is an unconditional spawn deno ENOENT.
Reproducible example
// probe.mjs
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { detect } from '@changesets/format';
const root = mkdtempSync(join(tmpdir(), 'fmt-'));
const fixture = (name, files) => {
const dir = join(root, name);
mkdirSync(dir, { recursive: true });
for (const [f, c] of Object.entries(files)) writeFileSync(join(dir, f), c);
return detect({ cwd: dir, stopDir: dir });
};
console.log('prettier only ->', await fixture('a', { '.prettierrc': '{}' }));
console.log('deno.json, no fmt key ->', await fixture('b', {
'deno.json': JSON.stringify({ nodeModulesDir: 'auto' }), '.prettierrc': '{}',
}));
console.log('deno.json with fmt key ->', await fixture('c', {
'deno.json': JSON.stringify({ fmt: { lineWidth: 80 } }), '.prettierrc': '{}',
}));
$ node probe.mjs # @changesets/format@0.1.2
prettier only -> prettier
deno.json, no fmt key -> deno <-- expected prettier
deno.json with fmt key -> deno
Fixture b is the defect: a deno.json that configures only nodeModulesDir selects the deno formatter over the prettier that is installed and configured. Fixtures a and c show the intended behaviour is otherwise reachable — which is why the bare string is the thing to remove, not the key check.
Note also that detect() uses traverseUpwards, so a deno.json in any parent directory selects deno for a nested package. In a monorepo one unrelated Deno subproject changes the formatter for everything beside it.
How this reached production
link-foundation/browser-commander had a js/deno.json containing exactly fixture b's content — orphaned configuration, referenced by no workflow, no npm script and no document — beside a .prettierrc and an installed prettier. After @changesets/cli moved to v3, run 33974450016 had changeset version die with spawn deno ENOENT from inside applyReleasePlan, after bumping package.json and writing CHANGELOG.md and before deleting the changeset it had just consumed. The half-applied state landed on the default branch: a changeset queued to be released a second time, and a CHANGELOG.md with trailing whitespace that then failed the repository's prettier --check on an unrelated contributor's pull request.
Deleting deno.json fixed it, and pinning "format": "prettier" in .changeset/config.json keeps it fixed. Neither is discoverable from the error message, which names only deno.
Suggestions for fixing this in code
1. Drop the bare filenames from deno.configFiles, keeping only the key check. This is the one-line version, and it makes the entry that is already written do what it says:
configFiles: [
{ file: "deno.json", key: "fmt" },
{ file: "deno.jsonc", key: "fmt" },
],
checkConfigFileWithKeyExists currently uses JSON.parse, which rejects a .jsonc with comments, so covering deno.jsonc properly needs a comment-tolerant parse (jsonc-parser, or the strip-json-comments already common in this ecosystem). If that is not wanted, keeping "deno.jsonc" as a bare string while making deno.json key-checked would still fix the common case.
2. Make deno fall through when it is not on PATH. Detection currently commits to a formatter that may not be runnable. Since deno cannot be resolved from node_modules, an existence check is the equivalent of the packageName/node_modules fallback the other four already get:
const deno: Formatter = {
name: "deno",
isAvailable: () => which("deno") !== undefined, // or `spawnSync("deno", ["--version"])`
...
};
with detect() skipping a formatter whose isAvailable returns false. Failing that, a clearer error at the call site — "detected the deno formatter from <path>/deno.json, but deno is not on PATH; set format in .changeset/config.json" — would have turned the incident above into a five-minute fix instead of an afternoon.
3. Consider whether deno should outrank prettier at all when both are configured. The current order means a project that has both a deno.json and a .prettierrc gets deno regardless of which one is actually installed. The node_modules traversal that already exists as a fallback would be a reasonable tiebreak in the primary pass too.
Environment
@changesets/format@0.1.2, and src/formatters.ts on main at the time of writing carries the same list
- reproduced on Node v20.20.2 and v24.20.0, Linux
- downstream:
@changesets/cli@^3.0.2
Context
Found while auditing CI/CD false positives, false negatives, warnings and errors in link-foundation/browser-commander (issue #83, PR #84). The probe above is kept runnable there as experiments/ci-repro/repro-changesets-format-detect.mjs.
Summary
deno'sconfigFileslist contains both a bare filename and the precise key check for the same file (src/formatters.ts:102):detect()iterates that array in order and returns on the first hit (src/detect.ts), so the bare"deno.json"always matches before the{ file: "deno.json", key: "fmt" }entry is reached. The third entry is dead code: nodeno.jsoncan ever reach it, and the linked documentation says thefmtkey is exactly what configuresdeno fmt.The consequence is that the mere existence of a
deno.json— for any reason, including one that says nothing about formatting — selects the deno formatter.denois second indefaultDetectOrderandprettieris last, so it wins over a prettier the project actually depends on.That would be a harmless mis-detection for any other entry in the table. It is not harmless for
deno, becausedenois the only formatter with nopackageName:prettier,biome,oxfmt,dprintpackageManagerExecute(...)→resolveCommand(pm, "execute-local", …), falling back tonpx— resolves out ofnode_modulesdenospawnProcess("deno", ["fmt", …])— straight offPATH, no existence check, no fallbackSo on any machine without Deno installed — which includes GitHub's
ubuntu-latest,macos-latestandwindows-latestrunners — the result is an unconditionalspawn deno ENOENT.Reproducible example
Fixture b is the defect: a
deno.jsonthat configures onlynodeModulesDirselects the deno formatter over the prettier that is installed and configured. Fixtures a and c show the intended behaviour is otherwise reachable — which is why the bare string is the thing to remove, not the key check.Note also that
detect()usestraverseUpwards, so adeno.jsonin any parent directory selects deno for a nested package. In a monorepo one unrelated Deno subproject changes the formatter for everything beside it.How this reached production
link-foundation/browser-commanderhad ajs/deno.jsoncontaining exactly fixture b's content — orphaned configuration, referenced by no workflow, no npm script and no document — beside a.prettierrcand an installedprettier. After@changesets/climoved to v3, run 33974450016 hadchangeset versiondie withspawn deno ENOENTfrom insideapplyReleasePlan, after bumpingpackage.jsonand writingCHANGELOG.mdand before deleting the changeset it had just consumed. The half-applied state landed on the default branch: a changeset queued to be released a second time, and aCHANGELOG.mdwith trailing whitespace that then failed the repository'sprettier --checkon an unrelated contributor's pull request.Deleting
deno.jsonfixed it, and pinning"format": "prettier"in.changeset/config.jsonkeeps it fixed. Neither is discoverable from the error message, which names onlydeno.Suggestions for fixing this in code
1. Drop the bare filenames from
deno.configFiles, keeping only the key check. This is the one-line version, and it makes the entry that is already written do what it says:checkConfigFileWithKeyExistscurrently usesJSON.parse, which rejects a.jsoncwith comments, so coveringdeno.jsoncproperly needs a comment-tolerant parse (jsonc-parser, or thestrip-json-commentsalready common in this ecosystem). If that is not wanted, keeping"deno.jsonc"as a bare string while makingdeno.jsonkey-checked would still fix the common case.2. Make
denofall through when it is not onPATH. Detection currently commits to a formatter that may not be runnable. Sincedenocannot be resolved fromnode_modules, an existence check is the equivalent of thepackageName/node_modulesfallback the other four already get:with
detect()skipping a formatter whoseisAvailablereturns false. Failing that, a clearer error at the call site — "detected the deno formatter from<path>/deno.json, butdenois not on PATH; setformatin.changeset/config.json" — would have turned the incident above into a five-minute fix instead of an afternoon.3. Consider whether
denoshould outrankprettierat all when both are configured. The current order means a project that has both adeno.jsonand a.prettierrcgets deno regardless of which one is actually installed. Thenode_modulestraversal that already exists as a fallback would be a reasonable tiebreak in the primary pass too.Environment
@changesets/format@0.1.2, andsrc/formatters.tsonmainat the time of writing carries the same list@changesets/cli@^3.0.2Context
Found while auditing CI/CD false positives, false negatives, warnings and errors in
link-foundation/browser-commander(issue #83, PR #84). The probe above is kept runnable there asexperiments/ci-repro/repro-changesets-format-detect.mjs.