From 55f249399f073553460434a9dc7b205f8fa80fba Mon Sep 17 00:00:00 2001 From: Timothy Jones Date: Tue, 21 Jul 2026 16:43:08 +1000 Subject: [PATCH] feat: support ESM configuration files Load .versionrc.mjs (and ESM-syntax .versionrc.js) via Node's require(esm), unwrapping the module-namespace wrapper to its default export. getConfiguration stays synchronous. Requires Node >= 22.12, where require(esm) is unflagged. Previously an ESM config loaded without error on Node >= 22.12 but was silently ignored, because the namespace wrapper passed the object check while the user's settings sat unread under `.default`. Tests spawn bin/cli.js in a child process: vitest's module runner intercepts createRequire and rejects ESM sources with a Babel SyntaxError, so in-process tests cannot observe real require(esm) behavior. Co-Authored-By: Claude Fable 5 --- README.md | 6 +- lib/configuration.js | 14 ++++- test/esm-config.integration-test.js | 97 +++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 test/esm-config.integration-test.js diff --git a/README.md b/README.md index 3ad9396..1d5d661 100644 --- a/README.md +++ b/README.md @@ -188,9 +188,9 @@ You can configure `commit-and-tag-version` either by: > Note for users who have migrated to > `commit-and-tag-version` from `standard-version`: the previous package.json configuration key of `standard-version` will still work. -2. Creating a `.versionrc`, `.versionrc.json` or `.versionrc.js`. +2. Creating a `.versionrc`, `.versionrc.json`, `.versionrc.js`, `.versionrc.cjs` or `.versionrc.mjs`. -- If you are using a `.versionrc.js` your default export must be a configuration object, or a function returning a configuration object. +- If you are using a `.versionrc.js`, `.versionrc.cjs` or `.versionrc.mjs` your default export must be a configuration object, or a function returning a configuration object. Both CommonJS and ES module configs are supported (ES module configs require Node.js >= 22.12). Any of the command line parameters accepted by `commit-and-tag-version` can instead be provided via configuration. Please refer to the [conventional-changelog-config-spec](https://github.com/conventional-changelog/conventional-changelog-config-spec/) for details on available configuration options. @@ -342,7 +342,7 @@ commit-and-tag-version -c ./path/to/.versionrc.js All config file formats can be used: ```sh -commit-and-tag-version -c ./path/to/.versionrc[.js|.cjs|.json] +commit-and-tag-version -c ./path/to/.versionrc[.js|.cjs|.mjs|.json] ``` ### Signing Commits and Tags diff --git a/lib/configuration.js b/lib/configuration.js index 96b5c8a..6d88092 100644 --- a/lib/configuration.js +++ b/lib/configuration.js @@ -8,6 +8,7 @@ const require = createRequire(import.meta.url); const CONFIGURATION_FILES = [ '.versionrc', '.versionrc.cjs', + '.versionrc.mjs', '.versionrc.json', '.versionrc.js', ]; @@ -23,8 +24,17 @@ export function getConfiguration(configFile) { return config; } const ext = path.extname(configPath); - if (ext === '.js' || ext === '.cjs') { - const jsConfiguration = require(configPath); + if (ext === '.js' || ext === '.cjs' || ext === '.mjs') { + let jsConfiguration = require(configPath); + // ES modules arrive as a namespace wrapper (via Node's `require(esm)`, + // available since 22.12); the configuration is its default export. + if ( + jsConfiguration != null && + (jsConfiguration.__esModule || + jsConfiguration[Symbol.toStringTag] === 'Module') + ) { + jsConfiguration = jsConfiguration.default; + } if (typeof jsConfiguration === 'function') { config = jsConfiguration(); } else { diff --git a/test/esm-config.integration-test.js b/test/esm-config.integration-test.js new file mode 100644 index 0000000..e845db9 --- /dev/null +++ b/test/esm-config.integration-test.js @@ -0,0 +1,97 @@ +import shell from 'shelljs'; +import fs from 'fs'; +import { execFileSync } from 'child_process'; +import { fileURLToPath } from 'url'; + +const CLI = fileURLToPath(new URL('../bin/cli.js', import.meta.url)); + +/** + * These tests spawn the real CLI in a child process instead of using the usual + * in-process harness. That is deliberate: `getConfiguration` loads ESM config + * files through Node's `require(esm)` (available since 22.12), and under vitest + * that require is intercepted by the module runner, which throws a Babel + * SyntaxError on ESM sources — a failure mode that does not exist in + * production. Only a child process observes the real behavior. + */ + +function setupTestDirectory() { + shell.rm('-rf', 'esm-config-temp'); + shell.config.silent = true; + shell.mkdir('esm-config-temp'); + shell.cd('esm-config-temp'); + shell.exec('git init'); + shell.exec('git config commit.gpgSign false'); + shell.exec('git config core.autocrlf false'); + shell.exec('git commit --allow-empty -m"root-commit"'); +} + +function resetShell() { + shell.cd('../'); + shell.rm('-rf', 'esm-config-temp'); +} + +function runCli() { + return execFileSync(process.execPath, [CLI, '--dry-run'], { + encoding: 'utf-8', + }); +} + +describe('ESM config files', function () { + beforeEach(function () { + setupTestDirectory(); + + fs.writeFileSync( + 'package.json', + JSON.stringify({ version: '1.0.0' }), + 'utf-8', + ); + }); + + afterEach(function () { + resetShell(); + }); + + it('reads an ES module config-object from .versionrc.js', function () { + fs.writeFileSync( + '.versionrc.js', + 'export default { tagPrefix: "custom-" }', + 'utf-8', + ); + + const output = runCli(); + expect(output).toContain('tagging release custom-1.0.1'); + }); + + it('reads an ES module config from .versionrc.mjs', function () { + fs.writeFileSync( + '.versionrc.mjs', + 'export default { tagPrefix: "custom-" }', + 'utf-8', + ); + + const output = runCli(); + expect(output).toContain('tagging release custom-1.0.1'); + }); + + it('evaluates an ES module config-function', function () { + fs.writeFileSync( + '.versionrc.mjs', + 'export default function () { return { tagPrefix: "custom-" }; }', + 'utf-8', + ); + + const output = runCli(); + expect(output).toContain('tagging release custom-1.0.1'); + }); + + it('applies the same configuration when written as CommonJS', function () { + fs.writeFileSync( + '.versionrc.js', + 'module.exports = { tagPrefix: "custom-" }', + 'utf-8', + ); + + const output = runCli(); + expect(output).toContain('tagging release custom-1.0.1'); + }); +});