From dc8390d9089c6339936d4e0edeef19c425ad3894 Mon Sep 17 00:00:00 2001 From: Melissa Liu Date: Mon, 15 Jun 2026 19:32:45 -0400 Subject: [PATCH 1/3] [release] use yarn in release script and scope git add MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release script ran `npm install`, which generated a stray `package-lock.json` (and required `.npmrc` legacy-peer-deps) even though the repo uses yarn — CI installs with `yarn install --frozen-lockfile`. `git add .` then committed those npm artifacts. - Install with `yarn install` instead of `npm install`. - Stage only the workspace manifests + `yarn.lock` instead of `git add .`, so stray/unrelated files can never be committed by a release again. Verified: running this on main for 0.19.0 produces package manifests identical to the release/0.19.0 branch, with no package-lock.json/.npmrc. --- tools/npm/release.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tools/npm/release.js b/tools/npm/release.js index fd1b207fc..98c3d5869 100755 --- a/tools/npm/release.js +++ b/tools/npm/release.js @@ -107,12 +107,22 @@ console.log('Package manifest update complete'); // Change working directory to the repo root process.chdir(path.join(__dirname, '../..')); -execSync('npm install', { stdio: 'inherit' }); +// This repo uses yarn (see `packageManager` and CI). Running `npm install` +// here would generate a stray `package-lock.json`, so install with yarn. +execSync('yarn install', { stdio: 'inherit' }); // Commit changes if (commit) { - // add changes - execSync('git add .', { stdio: 'inherit' }); + // Stage only the files this release is expected to touch: the workspace + // manifests plus the yarn lockfile. Using an explicit pathspec instead of + // `git add .` avoids committing unrelated or stray artifacts (e.g. a + // `package-lock.json` if npm was ever run, or build output). + const manifestPaths = workspaces.map(({ packageJsonPath }) => + path.relative(repoRoot, packageJsonPath), + ); + execSync(`git add ${manifestPaths.map((p) => `"${p}"`).join(' ')} yarn.lock`, { + stdio: 'inherit', + }); // commit execSync(`git commit -m "${pkgVersion}" --no-verify`, { stdio: 'inherit' }); // tag From 0baeb2a2e4b1d64ca4844de28c8404fbbb5798d3 Mon Sep 17 00:00:00 2001 From: Melissa Liu Date: Tue, 16 Jun 2026 02:21:55 -0400 Subject: [PATCH 2/3] [release] make publish resilient, public access, and pin Node version Follow-on fixes to the release script surfaced while cutting 0.19.0: - Publish with `npm publish --access public`. New scoped packages (@stylexjs/*) default to restricted access and fail with E402 on first publish; all stylex packages are public. No-op for already-public ones. - Wrap each `npm publish` in try/catch so one failure (e.g. a package the user lacks publish rights for, like the unscoped `create-stylex-app`) logs and is skipped instead of aborting the whole release halfway. - Add `.nvmrc` pinned to Node 22 (matches CI `setup-node` 22.x). Node 26 breaks the `gen-types`/yargs build toolchain. --- .nvmrc | 1 + tools/npm/release.js | 26 +++++++++++++++++++++----- 2 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 .nvmrc diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/tools/npm/release.js b/tools/npm/release.js index 98c3d5869..e4b1128b5 100755 --- a/tools/npm/release.js +++ b/tools/npm/release.js @@ -130,7 +130,14 @@ if (commit) { } if (publish) { - const publishCmd = otp == null ? 'npm publish' : `npm publish --otp ${otp}`; + // Scoped packages (@stylexjs/*) default to restricted access on first + // publish, which fails with E402 unless you have a paid plan. All stylex + // packages are public, so always publish with `--access public`. This is a + // no-op for packages that are already public. + const publishCmd = + otp == null + ? 'npm publish --access public' + : `npm publish --access public --otp ${otp}`; // publish public packages workspaces.forEach(({ directory, packageJson }) => { if (!packageJson.private) { @@ -143,10 +150,19 @@ if (publish) { `Skipping ${packageName} as version ${version} has already been published`, ); } catch (error) { - // If the version has not been published, proceed with publishing - execSync(`cd ${directory} && ${publishCmd}`, { - stdio: 'inherit', - }); + // If the version has not been published, proceed with publishing. + // Don't let a single failure (e.g. a package the current user lacks + // publish rights for) abort the whole release — log it and continue + // so the remaining packages still get published. + try { + execSync(`cd ${directory} && ${publishCmd}`, { + stdio: 'inherit', + }); + } catch (publishError) { + console.error( + `Failed to publish ${packageName}@${version} — skipping. ${publishError.message}`, + ); + } } } }); From 66c52090b316fc78c2539e2d05063cd94346c736 Mon Sep 17 00:00:00 2001 From: Melissa Liu Date: Tue, 16 Jun 2026 02:24:52 -0400 Subject: [PATCH 3/3] [release] harden exec calls and exit non-zero on publish failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review + CodeQL feedback on the release script: - Track publish failures and set `process.exitCode = 1` at the end, so a release that skipped packages isn't reported as successful. Uses exitCode (not exit) so every package is still attempted first. - Replace all shell-interpolated `execSync` calls with `execFileSync` arg arrays (no shell). Fixes CodeQL "shell command built from environment values" — publish now runs via `cwd: directory` instead of `cd &&`, and npm/git args are passed as arrays. Only the static `yarn install` remains an execSync. - Declare `glob` in root devDependencies; the script requires it but it was only resolving via hoisting (caused "Cannot find module 'glob'"). --- package.json | 1 + tools/npm/release.js | 43 ++++++++++++++++++++++++++++++++----------- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 93539b7bd..6d5463b3c 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "eslint-plugin-react": "^7.37.1", "flow-bin": "^0.314.0", "flow-typed": "^4.1.1", + "glob": "^10.5.0", "hermes-eslint": "^0.36.1", "husky": "^8.0.0", "jest": "^30.2.0", diff --git a/tools/npm/release.js b/tools/npm/release.js index e4b1128b5..14676090d 100755 --- a/tools/npm/release.js +++ b/tools/npm/release.js @@ -12,7 +12,7 @@ const fs = require('fs'); const glob = require('glob'); const path = require('path'); const yargs = require('yargs/yargs'); -const { execSync } = require('child_process'); +const { execSync, execFileSync } = require('child_process'); const { hideBin } = require('yargs/helpers'); const args = yargs(hideBin(process.argv)) @@ -120,13 +120,17 @@ if (commit) { const manifestPaths = workspaces.map(({ packageJsonPath }) => path.relative(repoRoot, packageJsonPath), ); - execSync(`git add ${manifestPaths.map((p) => `"${p}"`).join(' ')} yarn.lock`, { + // Pass args as an array (execFileSync, no shell) so paths can't be + // interpreted by the shell. + execFileSync('git', ['add', ...manifestPaths, 'yarn.lock'], { stdio: 'inherit', }); // commit - execSync(`git commit -m "${pkgVersion}" --no-verify`, { stdio: 'inherit' }); + execFileSync('git', ['commit', '-m', pkgVersion, '--no-verify'], { + stdio: 'inherit', + }); // tag - // execSync(`git tag -fam ${pkgVersion} "${pkgVersion}"`, { stdio: 'inherit' }); + // execFileSync('git', ['tag', '-f', '-a', '-m', pkgVersion, pkgVersion], { stdio: 'inherit' }); } if (publish) { @@ -134,18 +138,23 @@ if (publish) { // publish, which fails with E402 unless you have a paid plan. All stylex // packages are public, so always publish with `--access public`. This is a // no-op for packages that are already public. - const publishCmd = - otp == null - ? 'npm publish --access public' - : `npm publish --access public --otp ${otp}`; + const publishArgs = ['publish', '--access', 'public']; + if (otp != null) { + publishArgs.push('--otp', otp); + } // publish public packages + const failed = []; workspaces.forEach(({ directory, packageJson }) => { if (!packageJson.private) { const version = packageJson.version; const packageName = packageJson.name; try { // Check if the version has already been published - execSync(`npm view --silent ${packageName}@${version} version`); + execFileSync( + 'npm', + ['view', '--silent', `${packageName}@${version}`, 'version'], + { stdio: 'ignore' }, + ); console.log( `Skipping ${packageName} as version ${version} has already been published`, ); @@ -153,19 +162,31 @@ if (publish) { // If the version has not been published, proceed with publishing. // Don't let a single failure (e.g. a package the current user lacks // publish rights for) abort the whole release — log it and continue - // so the remaining packages still get published. + // so the remaining packages still get published. Run in the package + // directory via `cwd` (no shell) instead of `cd && ...`. try { - execSync(`cd ${directory} && ${publishCmd}`, { + execFileSync('npm', publishArgs, { + cwd: directory, stdio: 'inherit', }); } catch (publishError) { console.error( `Failed to publish ${packageName}@${version} — skipping. ${publishError.message}`, ); + failed.push(`${packageName}@${version}`); } } } }); + // Surface a non-zero exit if any package failed, so the release isn't + // reported as successful when packages were silently skipped. Use + // `exitCode` (not `exit`) so every package is still attempted first. + if (failed.length > 0) { + console.error( + `\n${failed.length} package(s) failed to publish: ${failed.join(', ')}`, + ); + process.exitCode = 1; + } // push changes // execSync('git push --tags origin main', { stdio: 'inherit' }); }