From 3a70579b6cffaf2d9acc9c72d3a932837f16479c Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Wed, 7 Oct 2026 16:49:39 +0530 Subject: [PATCH 1/3] Release Tooling: Install the release branch with its own npm config The publish workflow starts the CLI with `npm exec --no release-cli`, and `npm exec` exports the merged config of the tool's checkout (trunk) to child processes as `npm_config_*` variables. Environment variables outrank a project `.npmrc`, so every command the CLI runs in the release branch (`npm ci`, `lerna version`, `lerna publish`) used trunk's settings instead of the branch's. On a `wp/X.Y` branch this installs with trunk's `install-strategy = linked` and `strict-peer-deps`: a rehearsal of `npm-wp` on wp/7.1 got a linked tree without root bins, and the `lerna version` commit hook failed on a missing `docgen`. Commands run in the release repository now drop the variables that are exact copies of the tool's `.npmrc` values, so the branch's own `.npmrc` applies. Values from the user config (registry, auth via setup-node's userconfig), the environment or the command line are kept. Publishing from trunk itself is unchanged: its `.npmrc` provides the same values again. --- tools/release/commands/packages.js | 71 +++++++++++++++++++++++-- tools/release/commands/test/packages.js | 48 +++++++++++++++++ 2 files changed, 114 insertions(+), 5 deletions(-) diff --git a/tools/release/commands/packages.js b/tools/release/commands/packages.js index 8c86eb4be3bc5e..91f5dab5d7484e 100644 --- a/tools/release/commands/packages.js +++ b/tools/release/commands/packages.js @@ -44,6 +44,66 @@ const NPM_RELEASE_PREPARED_REF_PREFIX = 'refs/npm-release'; class NpmReleaseVerificationPendingError extends Error {} +/** + * Drops the `npm_config_*` variables that `npm exec` exported from the release + * tool's `.npmrc`. They outrank a project `.npmrc`, so a `wp/X.Y` branch would + * otherwise install with trunk's settings. + * + * Only exact copies of the tool's `.npmrc` values are dropped, so settings from + * the user config, the environment or the command line still apply. + * + * @param {Object} env Environment to filter. + * + * @return {Object} Filtered environment. + */ +function getRepositoryCommandEnv( env = process.env ) { + const toolPrefix = env.npm_config_local_prefix; + if ( ! toolPrefix ) { + return env; + } + + let npmrc = ''; + try { + npmrc = fs.readFileSync( join( toolPrefix, '.npmrc' ), 'utf8' ); + } catch {} + const toolConfig = new Map( [ [ 'local_prefix', toolPrefix ] ] ); + for ( const line of npmrc.split( /\r?\n/ ) ) { + const match = line.match( /^\s*([^#;=\s][^=]*?)\s*=\s*(.*?)\s*$/ ); + if ( match ) { + toolConfig.set( + match[ 1 ].replace( /-/g, '_' ).toLowerCase(), + match[ 2 ].replace( /^(["'])(.*)\1$/, '$2' ) + ); + } + } + + return Object.fromEntries( + Object.entries( env ).filter( ( [ name, value ] ) => { + const match = name.match( /^npm_config_(.+)$/i ); + return ( + ! match || toolConfig.get( match[ 1 ].toLowerCase() ) !== value + ); + } ) + ); +} + +/** + * Runs a command in the release repository with that repository's own npm + * configuration. + * + * @param {string} commandString Command to run. + * @param {Object} options execa options. + * + * @return {Promise} execa result. + */ +function commandInRepository( commandString, options = {} ) { + return command( commandString, { + ...options, + env: getRepositoryCommandEnv(), + extendEnv: false, + } ); +} + /** * Release type names. * @@ -757,7 +817,7 @@ async function runNpmPublishPreflight( { distTag, gitWorkingDirectoryPath, publishCommit, releasePackages }, deps = {} ) { - const { commandFn = command } = deps; + const { commandFn = commandInRepository } = deps; /* * `npm whoami` fails for every credential problem that happens in practice: * a missing, expired, or revoked auth token, or an unreachable registry. @@ -1327,7 +1387,7 @@ async function installNpmReleaseDependencies( { gitWorkingDirectoryPath }, deps = {} ) { - const { commandFn = command } = deps; + const { commandFn = commandInRepository } = deps; log( '>> Installing npm packages.' ); await commandFn( 'npm ci', { cwd: gitWorkingDirectoryPath, @@ -1398,7 +1458,7 @@ function getNpmReleasePreparedPluginBranch( */ async function resumePreparedNpmRelease( config, deps = {} ) { const { - commandFn = command, + commandFn = commandInRepository, deletePreparedCommitFn = deleteNpmReleasePreparedCommit, getPreparedChangelogCommitFn = getNpmReleasePreparedChangelogCommit, getPreparedCommitFn = getNpmReleasePreparedCommit, @@ -1547,7 +1607,7 @@ async function publishVersionedPackagesToNpm( deps = {} ) { const { - commandFn = command, + commandFn = commandInRepository, git = simpleGit( gitWorkingDirectoryPath ), getNpmReleasePackagesFn = getNpmReleasePackages, pushNpmReleaseGitMetadataFn = pushNpmReleaseGitMetadata, @@ -1703,7 +1763,7 @@ async function publishPackagesToNpm( deps = {} ) { const { - commandFn = command, + commandFn = commandInRepository, git = simpleGit( gitWorkingDirectoryPath ), publishVersionedPackagesToNpmFn = publishVersionedPackagesToNpm, } = deps; @@ -2129,6 +2189,7 @@ module.exports = { getNpmReleasePackages, getNpmReleaseGitRecoveryCommands, getRemoteBranchSha, + getRepositoryCommandEnv, getRemoteTagShas, getTagPushCommands, getTagRefspec, diff --git a/tools/release/commands/test/packages.js b/tools/release/commands/test/packages.js index 0f2724ffe8f131..092a3ec530fc4e 100644 --- a/tools/release/commands/test/packages.js +++ b/tools/release/commands/test/packages.js @@ -14,6 +14,7 @@ let { getNpmReleaseGitRecoveryCommands, getRemoteBranchSha, getRemoteTagShas, + getRepositoryCommandEnv, getTagPushCommands, getTagRefspec, prepareNpmRelease, @@ -43,6 +44,7 @@ beforeAll( () => { getNpmReleaseGitRecoveryCommands, getRemoteBranchSha, getRemoteTagShas, + getRepositoryCommandEnv, getTagPushCommands, getTagRefspec, prepareNpmRelease, @@ -63,6 +65,52 @@ beforeAll( () => { } = require( '../packages' ) ); } ); +describe( 'getRepositoryCommandEnv', () => { + it( "drops only the values copied from the release tool's .npmrc", async () => { + const toolPrefix = await mkdtemp( + join( tmpdir(), 'npm-release-tool-' ) + ); + try { + await writeFile( + join( toolPrefix, '.npmrc' ), + [ + 'install-strategy = "linked"', + '# strict-allow-scripts = true', + 'strict-peer-deps=true', + 'min-release-age = 1', + ].join( '\n' ) + ); + + expect( + getRepositoryCommandEnv( { + PATH: '/bin', + NPM_CONFIG_USERCONFIG: '/runner/.npmrc', + npm_config_install_strategy: 'linked', + npm_config_local_prefix: toolPrefix, + npm_config_registry: 'https://registry.npmjs.org/', + npm_config_strict_allow_scripts: 'true', + npm_config_strict_peer_deps: 'true', + NPM_CONFIG_MIN_RELEASE_AGE: '0', + } ) + ).toEqual( { + PATH: '/bin', + NPM_CONFIG_USERCONFIG: '/runner/.npmrc', + npm_config_registry: 'https://registry.npmjs.org/', + npm_config_strict_allow_scripts: 'true', + NPM_CONFIG_MIN_RELEASE_AGE: '0', + } ); + } finally { + await rm( toolPrefix, { recursive: true, force: true } ); + } + } ); + + it( 'keeps the environment when not run through npm', () => { + const env = { PATH: '/bin', npm_config_install_strategy: 'linked' }; + + expect( getRepositoryCommandEnv( env ) ).toBe( env ); + } ); +} ); + describe( 'prepareNpmRelease', () => { it.each( [ [ 'latest', 'release/23.5' ], From ff096b21926a6aae3c94d8d877c0fdadcf9966e2 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Wed, 7 Oct 2026 16:50:01 +0530 Subject: [PATCH 2/3] Release Tooling: Accept an absolute --repository-path `getConfig` built the repository path with `join( process.cwd(), path )`, which prefixes the working directory even when the path is absolute, so `--repository-path /tmp/publish` pointed at `/tmp/publish`. The workflows pass `../publish` and were unaffected, but running the CLI locally against a scratch clone (as when rehearsing a `wp/X.Y` release) failed in a confusing way. `path.resolve` handles both forms. --- tools/release/cli.js | 2 +- tools/release/commands/packages.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/release/cli.js b/tools/release/cli.js index f028d1f01805bc..5909a6f75c673d 100755 --- a/tools/release/cli.js +++ b/tools/release/cli.js @@ -13,7 +13,7 @@ const semverOption = [ '--semver ', 'Semantic Versioning', 'patch' ]; const ciOption = [ '-c, --ci', 'Run in CI (non interactive)' ]; const repositoryPathOption = [ '--repository-path ', - 'Relative path to the git repository.', + 'Path to the git repository, relative to the working directory or absolute.', ]; program diff --git a/tools/release/commands/packages.js b/tools/release/commands/packages.js index 91f5dab5d7484e..2b3fd5c72bf336 100644 --- a/tools/release/commands/packages.js +++ b/tools/release/commands/packages.js @@ -120,7 +120,7 @@ function commandInRepository( commandString, options = {} ) { * @typedef WPPackagesCommandOptions * * @property {boolean} [ci] Disables interactive mode when executed in CI mode. - * @property {string} [repositoryPath] Relative path to the git repository. + * @property {string} [repositoryPath] Path to the git repository, relative to the working directory or absolute. * @property {SemVer} [semver] The selected semantic versioning. Defaults to `patch`. * @property {string} [wpVersion] The major WordPress version number, example: `6.0`. */ @@ -2123,7 +2123,7 @@ function getConfig( abortMessage: 'Aborting!', distTag, gitWorkingDirectoryPath: - repositoryPath && join( process.cwd(), repositoryPath ), + repositoryPath && path.resolve( repositoryPath ), interactive: ! ci, minimumVersionBump: semver, npmReleaseBranch, From 55c0fc3deb1b8ac35b482b205d6208f316b1b979 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Wed, 7 Oct 2026 20:14:50 +0530 Subject: [PATCH 3/3] Release Tooling: Run the npm publish CLI with node instead of npm exec `npm exec` exports the tool checkout's `.npmrc` as `npm_config_*` variables, which outrank the release branch's own `.npmrc`. Starting the CLI with `node` exports nothing, so the commands it runs in the release branch read that branch's config without filtering the environment. This reverts the environment filter from 25d1a22847b. --- .github/workflows/build-plugin-zip.yml | 3 +- .github/workflows/publish-npm-packages.yml | 9 +-- tools/release/commands/packages.js | 72 ++-------------------- tools/release/commands/test/packages.js | 48 --------------- 4 files changed, 12 insertions(+), 120 deletions(-) diff --git a/.github/workflows/build-plugin-zip.yml b/.github/workflows/build-plugin-zip.yml index 764818b01bc58f..6d6aaff33ba9ae 100644 --- a/.github/workflows/build-plugin-zip.yml +++ b/.github/workflows/build-plugin-zip.yml @@ -598,10 +598,11 @@ jobs: working-directory: main cache: false + # Not `npm exec`: it exports the tool's .npmrc as npm_config_* variables, which override the release branch's own .npmrc. - name: Publish packages to npm ("latest" dist-tag) run: | cd main npm ci - npm exec --no release-cli -- npm-latest --semver minor --ci --repository-path ../publish + node tools/release/cli.js npm-latest --semver minor --ci --repository-path ../publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/publish-npm-packages.yml b/.github/workflows/publish-npm-packages.yml index b1985a64ed5a75..bf2c97fa3b2cab 100644 --- a/.github/workflows/publish-npm-packages.yml +++ b/.github/workflows/publish-npm-packages.yml @@ -91,12 +91,13 @@ jobs: working-directory: cli cache: false + # Not `npm exec`: it exports the tool's .npmrc as npm_config_* variables, which override the release branch's own .npmrc. - name: Publish development packages to npm ("next" dist-tag) if: ${{ github.event.inputs.release_type == 'development' }} run: | cd cli npm ci - npm exec --no release-cli -- npm-next --ci --repository-path ../publish + node tools/release/cli.js npm-next --ci --repository-path ../publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -105,7 +106,7 @@ jobs: run: | cd cli npm ci - npm exec --no release-cli -- npm-latest --semver minor --ci --repository-path ../publish + node tools/release/cli.js npm-latest --semver minor --ci --repository-path ../publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -114,7 +115,7 @@ jobs: run: | cd cli npm ci - npm exec --no release-cli -- npm-bugfix --ci --repository-path ../publish + node tools/release/cli.js npm-bugfix --ci --repository-path ../publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -123,7 +124,7 @@ jobs: run: | cd cli npm ci - npm exec --no release-cli -- npm-wp --wp-version "$WP_VERSION" --ci --repository-path ../publish + node tools/release/cli.js npm-wp --wp-version "$WP_VERSION" --ci --repository-path ../publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} WP_VERSION: ${{ github.event.inputs.wp_version }} diff --git a/tools/release/commands/packages.js b/tools/release/commands/packages.js index 2b3fd5c72bf336..720c14d08e8b06 100644 --- a/tools/release/commands/packages.js +++ b/tools/release/commands/packages.js @@ -1,7 +1,6 @@ const path = require( 'path' ); const fs = require( 'fs' ); const readline = require( 'readline' ); -const { join } = require( 'path' ); const { command } = require( 'execa' ); const glob = require( 'fast-glob' ); const { inc: semverInc, parse: semverParse } = require( 'semver' ); @@ -44,66 +43,6 @@ const NPM_RELEASE_PREPARED_REF_PREFIX = 'refs/npm-release'; class NpmReleaseVerificationPendingError extends Error {} -/** - * Drops the `npm_config_*` variables that `npm exec` exported from the release - * tool's `.npmrc`. They outrank a project `.npmrc`, so a `wp/X.Y` branch would - * otherwise install with trunk's settings. - * - * Only exact copies of the tool's `.npmrc` values are dropped, so settings from - * the user config, the environment or the command line still apply. - * - * @param {Object} env Environment to filter. - * - * @return {Object} Filtered environment. - */ -function getRepositoryCommandEnv( env = process.env ) { - const toolPrefix = env.npm_config_local_prefix; - if ( ! toolPrefix ) { - return env; - } - - let npmrc = ''; - try { - npmrc = fs.readFileSync( join( toolPrefix, '.npmrc' ), 'utf8' ); - } catch {} - const toolConfig = new Map( [ [ 'local_prefix', toolPrefix ] ] ); - for ( const line of npmrc.split( /\r?\n/ ) ) { - const match = line.match( /^\s*([^#;=\s][^=]*?)\s*=\s*(.*?)\s*$/ ); - if ( match ) { - toolConfig.set( - match[ 1 ].replace( /-/g, '_' ).toLowerCase(), - match[ 2 ].replace( /^(["'])(.*)\1$/, '$2' ) - ); - } - } - - return Object.fromEntries( - Object.entries( env ).filter( ( [ name, value ] ) => { - const match = name.match( /^npm_config_(.+)$/i ); - return ( - ! match || toolConfig.get( match[ 1 ].toLowerCase() ) !== value - ); - } ) - ); -} - -/** - * Runs a command in the release repository with that repository's own npm - * configuration. - * - * @param {string} commandString Command to run. - * @param {Object} options execa options. - * - * @return {Promise} execa result. - */ -function commandInRepository( commandString, options = {} ) { - return command( commandString, { - ...options, - env: getRepositoryCommandEnv(), - extendEnv: false, - } ); -} - /** * Release type names. * @@ -817,7 +756,7 @@ async function runNpmPublishPreflight( { distTag, gitWorkingDirectoryPath, publishCommit, releasePackages }, deps = {} ) { - const { commandFn = commandInRepository } = deps; + const { commandFn = command } = deps; /* * `npm whoami` fails for every credential problem that happens in practice: * a missing, expired, or revoked auth token, or an unreachable registry. @@ -1387,7 +1326,7 @@ async function installNpmReleaseDependencies( { gitWorkingDirectoryPath }, deps = {} ) { - const { commandFn = commandInRepository } = deps; + const { commandFn = command } = deps; log( '>> Installing npm packages.' ); await commandFn( 'npm ci', { cwd: gitWorkingDirectoryPath, @@ -1458,7 +1397,7 @@ function getNpmReleasePreparedPluginBranch( */ async function resumePreparedNpmRelease( config, deps = {} ) { const { - commandFn = commandInRepository, + commandFn = command, deletePreparedCommitFn = deleteNpmReleasePreparedCommit, getPreparedChangelogCommitFn = getNpmReleasePreparedChangelogCommit, getPreparedCommitFn = getNpmReleasePreparedCommit, @@ -1607,7 +1546,7 @@ async function publishVersionedPackagesToNpm( deps = {} ) { const { - commandFn = commandInRepository, + commandFn = command, git = simpleGit( gitWorkingDirectoryPath ), getNpmReleasePackagesFn = getNpmReleasePackages, pushNpmReleaseGitMetadataFn = pushNpmReleaseGitMetadata, @@ -1763,7 +1702,7 @@ async function publishPackagesToNpm( deps = {} ) { const { - commandFn = commandInRepository, + commandFn = command, git = simpleGit( gitWorkingDirectoryPath ), publishVersionedPackagesToNpmFn = publishVersionedPackagesToNpm, } = deps; @@ -2189,7 +2128,6 @@ module.exports = { getNpmReleasePackages, getNpmReleaseGitRecoveryCommands, getRemoteBranchSha, - getRepositoryCommandEnv, getRemoteTagShas, getTagPushCommands, getTagRefspec, diff --git a/tools/release/commands/test/packages.js b/tools/release/commands/test/packages.js index 092a3ec530fc4e..0f2724ffe8f131 100644 --- a/tools/release/commands/test/packages.js +++ b/tools/release/commands/test/packages.js @@ -14,7 +14,6 @@ let { getNpmReleaseGitRecoveryCommands, getRemoteBranchSha, getRemoteTagShas, - getRepositoryCommandEnv, getTagPushCommands, getTagRefspec, prepareNpmRelease, @@ -44,7 +43,6 @@ beforeAll( () => { getNpmReleaseGitRecoveryCommands, getRemoteBranchSha, getRemoteTagShas, - getRepositoryCommandEnv, getTagPushCommands, getTagRefspec, prepareNpmRelease, @@ -65,52 +63,6 @@ beforeAll( () => { } = require( '../packages' ) ); } ); -describe( 'getRepositoryCommandEnv', () => { - it( "drops only the values copied from the release tool's .npmrc", async () => { - const toolPrefix = await mkdtemp( - join( tmpdir(), 'npm-release-tool-' ) - ); - try { - await writeFile( - join( toolPrefix, '.npmrc' ), - [ - 'install-strategy = "linked"', - '# strict-allow-scripts = true', - 'strict-peer-deps=true', - 'min-release-age = 1', - ].join( '\n' ) - ); - - expect( - getRepositoryCommandEnv( { - PATH: '/bin', - NPM_CONFIG_USERCONFIG: '/runner/.npmrc', - npm_config_install_strategy: 'linked', - npm_config_local_prefix: toolPrefix, - npm_config_registry: 'https://registry.npmjs.org/', - npm_config_strict_allow_scripts: 'true', - npm_config_strict_peer_deps: 'true', - NPM_CONFIG_MIN_RELEASE_AGE: '0', - } ) - ).toEqual( { - PATH: '/bin', - NPM_CONFIG_USERCONFIG: '/runner/.npmrc', - npm_config_registry: 'https://registry.npmjs.org/', - npm_config_strict_allow_scripts: 'true', - NPM_CONFIG_MIN_RELEASE_AGE: '0', - } ); - } finally { - await rm( toolPrefix, { recursive: true, force: true } ); - } - } ); - - it( 'keeps the environment when not run through npm', () => { - const env = { PATH: '/bin', npm_config_install_strategy: 'linked' }; - - expect( getRepositoryCommandEnv( env ) ).toBe( env ); - } ); -} ); - describe( 'prepareNpmRelease', () => { it.each( [ [ 'latest', 'release/23.5' ],