From 4d93ee0954a38a835af0904d2d2befad843f56bf Mon Sep 17 00:00:00 2001 From: Vladimir Morozov Date: Tue, 16 Dec 2025 16:21:34 -0800 Subject: [PATCH 01/12] Release NPM packages in publish pipeline --- .ado/publish.yml | 23 +++++++++++++++++++++++ .ado/release.yml | 40 ---------------------------------------- 2 files changed, 23 insertions(+), 40 deletions(-) diff --git a/.ado/publish.yml b/.ado/publish.yml index 869a2b72d9a..7b67792ebac 100644 --- a/.ado/publish.yml +++ b/.ado/publish.yml @@ -227,6 +227,29 @@ extends: parameters: buildEnvironment: Continuous + - script: echo NpmDistTag is $(NpmDistTag) + displayName: Show NPM dist tag + + - script: dir /s "$(Pipeline.Workspace)\published-packages" + displayName: Show npm packages before ESRP release + + - task: 'SFP.release-tasks.custom-build-release-task.EsrpRelease@10' + displayName: 'ESRP Release to npmjs.com' + condition: and(succeeded(), ne(variables['NpmDistTag'], '')) + inputs: + connectedservicename: 'ESRP-CodeSigning-OGX-JSHost-RNW' + usemanagedidentity: false + keyvaultname: 'OGX-JSHost-KV' + authcertname: 'OGX-JSHost-Auth4' + signcertname: 'OGX-JSHost-Sign3' + clientid: '0a35e01f-eadf-420a-a2bf-def002ba898d' + domaintenantid: 'cdc5aeea-15c5-4db6-b079-fcadd2505dc2' + contenttype: npm + folderlocation: '$(Pipeline.Workspace)\published-packages' + productstate: '$(NpmDistTag)' + owners: 'vmorozov@microsoft.com' + approvers: 'khosany@microsoft.com' + - task: AzureArtifacts.manifest-generator-task.manifest-generator-task.ManifestGeneratorTask@0 displayName: šŸ“’ Generate Manifest Npm inputs: diff --git a/.ado/release.yml b/.ado/release.yml index c3a17ad095e..8aa9db3ec64 100644 --- a/.ado/release.yml +++ b/.ado/release.yml @@ -29,46 +29,6 @@ extends: - stage: Release displayName: Publish artifacts jobs: - - job: PushNpm - displayName: npmjs.com - Publish npm packages - variables: - - group: RNW Secrets - timeoutInMinutes: 0 - templateContext: - inputs: - - input: pipelineArtifact - pipeline: 'Publish' - artifactName: 'NpmPackedTarballs' - targetPath: '$(Pipeline.Workspace)/published-packages' - - input: pipelineArtifact - pipeline: 'Publish' - artifactName: 'VersionEnvVars' - targetPath: '$(Pipeline.Workspace)/VersionEnvVars' - steps: - - checkout: none - - task: CmdLine@2 - displayName: Apply version variables - inputs: - script: node $(Pipeline.Workspace)/VersionEnvVars/versionEnvVars.js - - script: dir /s "$(Pipeline.Workspace)\published-packages" - displayName: Show npm packages - - task: 'SFP.release-tasks.custom-build-release-task.EsrpRelease@10' - displayName: 'ESRP Release to npmjs.com' - condition: and(succeeded(), ne(variables['NpmDistTag'], '')) - inputs: - connectedservicename: 'ESRP-CodeSigning-OGX-JSHost-RNW' - usemanagedidentity: false - keyvaultname: 'OGX-JSHost-KV' - authcertname: 'OGX-JSHost-Auth4' - signcertname: 'OGX-JSHost-Sign3' - clientid: '0a35e01f-eadf-420a-a2bf-def002ba898d' - domaintenantid: 'cdc5aeea-15c5-4db6-b079-fcadd2505dc2' - contenttype: npm - folderlocation: '$(Pipeline.Workspace)\published-packages' - productstate: '$(NpmDistTag)' - owners: 'vmorozov@microsoft.com' - approvers: 'khosany@microsoft.com' - - job: PushPrivateAdo displayName: ADO - react-native timeoutInMinutes: 0 From 7977a9fedd79fef49a13780f0f45609c2f3f5eb0 Mon Sep 17 00:00:00 2001 From: Vladimir Morozov Date: Thu, 18 Dec 2025 16:53:03 -0800 Subject: [PATCH 02/12] Try running yarn build before publish --- .ado/templates/verdaccio-start.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.ado/templates/verdaccio-start.yml b/.ado/templates/verdaccio-start.yml index 0778348224e..0eb7f221d1e 100644 --- a/.ado/templates/verdaccio-start.yml +++ b/.ado/templates/verdaccio-start.yml @@ -16,6 +16,11 @@ steps: - template: compute-beachball-branch-name.yml + - script: yarn build + displayName: Build packages before publish + env: + YARN_ENABLE_IMMUTABLE_INSTALLS: false + - ${{ if eq(parameters.beachballPublish, true) }}: - script: npx beachball publish --branch origin/$(BeachBallBranchName) --no-push --registry http://localhost:4873 --yes --verbose --access public --changehint "Run `yarn change` from root of repo to generate a change file." displayName: Publish packages to verdaccio From cbb4e55b5c56b1883835d1841f577c84df8cd715 Mon Sep 17 00:00:00 2001 From: Vladimir Morozov Date: Thu, 18 Dec 2025 17:34:57 -0800 Subject: [PATCH 03/12] Another attempt to fix CLI --- .ado/templates/verdaccio-start.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.ado/templates/verdaccio-start.yml b/.ado/templates/verdaccio-start.yml index 0eb7f221d1e..9672ef3fc47 100644 --- a/.ado/templates/verdaccio-start.yml +++ b/.ado/templates/verdaccio-start.yml @@ -16,6 +16,14 @@ steps: - template: compute-beachball-branch-name.yml + # Ensure native layout artifacts (e.g., JSI files) are copied before publishing + - script: | + cd vnext + npx just layoutMSRNCxx + displayName: Run layoutMSRNCxx (generate native headers/jsi) + env: + YARN_ENABLE_IMMUTABLE_INSTALLS: false + - script: yarn build displayName: Build packages before publish env: From a8b188da0ce433abf4b0ad524dca28d54dcc0853 Mon Sep 17 00:00:00 2001 From: Vladimir Morozov Date: Thu, 18 Dec 2025 17:57:41 -0800 Subject: [PATCH 04/12] Another attempt --- .ado/templates/verdaccio-start.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.ado/templates/verdaccio-start.yml b/.ado/templates/verdaccio-start.yml index 9672ef3fc47..7d79a4dc814 100644 --- a/.ado/templates/verdaccio-start.yml +++ b/.ado/templates/verdaccio-start.yml @@ -24,11 +24,18 @@ steps: env: YARN_ENABLE_IMMUTABLE_INSTALLS: false - - script: yarn build - displayName: Build packages before publish + - script: yarn build --no-cache + displayName: Build packages before publish (no cache) env: YARN_ENABLE_IMMUTABLE_INSTALLS: false + # Verify the generated JSI source exists before publishing + - powershell: | + if (-not (Test-Path "$(Build.SourcesDirectory)\vnext\Microsoft.ReactNative.Cxx\jsi\jsi.cpp")) { + Write-Error "Missing generated jsi.cpp; layoutMSRNCxx did not run" + } + displayName: Validate generated JSI layout + - ${{ if eq(parameters.beachballPublish, true) }}: - script: npx beachball publish --branch origin/$(BeachBallBranchName) --no-push --registry http://localhost:4873 --yes --verbose --access public --changehint "Run `yarn change` from root of repo to generate a change file." displayName: Publish packages to verdaccio From 0b218a1362459aca86df6f131fa52358ffdd98fb Mon Sep 17 00:00:00 2001 From: Vladimir Morozov Date: Fri, 19 Dec 2025 11:22:44 -0800 Subject: [PATCH 05/12] Initial version of npmPack --- .ado/scripts/npmPack.js | 187 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 .ado/scripts/npmPack.js diff --git a/.ado/scripts/npmPack.js b/.ado/scripts/npmPack.js new file mode 100644 index 00000000000..0d9992aa32c --- /dev/null +++ b/.ado/scripts/npmPack.js @@ -0,0 +1,187 @@ +#!/usr/bin/env node +// @ts-check + +/** + * npmPack.js - Pack all non-private workspace packages to tgz files + * + * Usage: + * node npmPack.js [targetDir] [--clean] + * + * Arguments: + * targetDir - Target directory for .tgz files (default: npm-pkgs in repo root) + * --clean - Clean target directory if it's not empty + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); +const glob = require('glob'); + +/** + * Find the enlistment root by going up two directories from script location + */ +function findEnlistmentRoot() { + const scriptDir = __dirname; + const repoRoot = path.resolve(scriptDir, '..', '..'); + + // Verify this is the repo root by checking for package.json + const packageJsonPath = path.join(repoRoot, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + throw new Error(`Could not find package.json at ${packageJsonPath}`); + } + + return repoRoot; +} + +/** + * Get workspace package paths from root package.json + */ +function getWorkspacePackages(repoRoot) { + const packageJsonPath = path.join(repoRoot, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + + if (!packageJson.workspaces || !packageJson.workspaces.packages) { + throw new Error('No workspaces.packages found in root package.json'); + } + + return packageJson.workspaces.packages; +} + +/** + * Find all package.json files matching workspace patterns + */ +function findWorkspacePackageJsons(repoRoot, workspacePatterns) { + const packageJsonPaths = []; + + for (const pattern of workspacePatterns) { + const searchPattern = path.join(repoRoot, pattern, 'package.json'); + const matches = glob.sync(searchPattern, { windowsPathsNoEscape: true }); + packageJsonPaths.push(...matches); + } + + return packageJsonPaths; +} + +/** + * Check if a package is private + */ +function isPrivatePackage(packageJsonPath) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + return packageJson.private === true; +} + +/** + * Pack a package using npm pack + */ +function packPackage(packageDir, targetDir) { + const packageJsonPath = path.join(packageDir, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const packageName = packageJson.name; + + console.log(`Packing ${packageName}...`); + + try { + // Run npm pack in the package directory, output to target directory + const output = execSync(`npm pack --pack-destination "${targetDir}"`, { + cwd: packageDir, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }); + + const tgzFileName = output.trim().split('\n').pop(); + console.log(` āœ“ Created ${tgzFileName}`); + return true; + } catch (error) { + console.error(` āœ— Failed to pack ${packageName}: ${error.message}`); + return false; + } +} + +/** + * Main function + */ +function main() { + const args = process.argv.slice(2); + const cleanFlag = args.includes('--clean'); + const targetDirArg = args.find(arg => !arg.startsWith('--')); + + try { + // Find repo root + const repoRoot = findEnlistmentRoot(); + console.log(`Repository root: ${repoRoot}`); + + // Determine target directory + const targetDir = targetDirArg + ? path.resolve(repoRoot, targetDirArg) + : path.join(repoRoot, 'npm-pkgs'); + + console.log(`Target directory: ${targetDir}`); + + // Handle target directory + if (fs.existsSync(targetDir)) { + const files = fs.readdirSync(targetDir); + if (files.length > 0) { + if (!cleanFlag) { + console.error(`Error: Target directory is not empty: ${targetDir}`); + console.error('Use --clean flag to clean the directory before packing'); + process.exit(1); + } + + console.log('Cleaning target directory...'); + for (const file of files) { + fs.rmSync(path.join(targetDir, file), { recursive: true, force: true }); + } + } + } else { + console.log('Creating target directory...'); + fs.mkdirSync(targetDir, { recursive: true }); + } + + // Get workspace packages + const workspacePatterns = getWorkspacePackages(repoRoot); + console.log(`Workspace patterns: ${workspacePatterns.join(', ')}`); + + // Find all package.json files + const packageJsonPaths = findWorkspacePackageJsons(repoRoot, workspacePatterns); + console.log(`Found ${packageJsonPaths.length} workspace packages\n`); + + // Pack non-private packages + let packedCount = 0; + let skippedCount = 0; + let failedCount = 0; + + for (const packageJsonPath of packageJsonPaths) { + const packageDir = path.dirname(packageJsonPath); + + if (isPrivatePackage(packageJsonPath)) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + console.log(`Skipping private package: ${packageJson.name}`); + skippedCount++; + continue; + } + + const success = packPackage(packageDir, targetDir); + if (success) { + packedCount++; + } else { + failedCount++; + } + } + + console.log(`\nāœ“ Packing complete:`); + console.log(` Packed: ${packedCount}`); + console.log(` Skipped (private): ${skippedCount}`); + console.log(` Failed: ${failedCount}`); + console.log(` Target: ${targetDir}`); + + if (failedCount > 0) { + process.exit(1); + } + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +// Run main function +main(); From 088afd9814c838d5c507f417914920ad4c12e310 Mon Sep 17 00:00:00 2001 From: Vladimir Morozov Date: Fri, 19 Dec 2025 11:28:16 -0800 Subject: [PATCH 06/12] Avoid using glob --- .ado/scripts/npmPack.js | 67 +++++++++++++++++++++++++++++++++++++++-- .gitignore | 2 ++ 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/.ado/scripts/npmPack.js b/.ado/scripts/npmPack.js index 0d9992aa32c..ab51bde11f4 100644 --- a/.ado/scripts/npmPack.js +++ b/.ado/scripts/npmPack.js @@ -15,7 +15,6 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); -const glob = require('glob'); /** * Find the enlistment root by going up two directories from script location @@ -47,6 +46,69 @@ function getWorkspacePackages(repoRoot) { return packageJson.workspaces.packages; } +/** + * Recursively find all package.json files in a directory + */ +function findPackageJsonsRecursive(dir, results = []) { + if (!fs.existsSync(dir)) { + return results; + } + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + // Skip node_modules directories + if (entry.name === 'node_modules') { + continue; + } + findPackageJsonsRecursive(fullPath, results); + } else if (entry.isFile() && entry.name === 'package.json') { + results.push(fullPath); + } + } + + return results; +} + +/** + * Match a pattern against a path + * Supports patterns like "packages/*" or "packages/@react-native-windows/*" + */ +function matchPattern(pattern, basePath) { + // Remove trailing /* if present + const cleanPattern = pattern.replace(/\/\*$/, ''); + const patternPath = path.join(basePath, cleanPattern); + + const results = []; + + // Check if pattern ends with /* + if (pattern.endsWith('/*')) { + // Pattern like "packages/*" - find all direct subdirectories + if (fs.existsSync(patternPath)) { + const entries = fs.readdirSync(patternPath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const packageJsonPath = path.join(patternPath, entry.name, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + results.push(packageJsonPath); + } + } + } + } + } else { + // Exact path - check if package.json exists + const packageJsonPath = path.join(patternPath, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + results.push(packageJsonPath); + } + } + + return results; +} + /** * Find all package.json files matching workspace patterns */ @@ -54,8 +116,7 @@ function findWorkspacePackageJsons(repoRoot, workspacePatterns) { const packageJsonPaths = []; for (const pattern of workspacePatterns) { - const searchPattern = path.join(repoRoot, pattern, 'package.json'); - const matches = glob.sync(searchPattern, { windowsPathsNoEscape: true }); + const matches = matchPattern(pattern, repoRoot); packageJsonPaths.push(...matches); } diff --git a/.gitignore b/.gitignore index ec29f307843..94a413d154c 100644 --- a/.gitignore +++ b/.gitignore @@ -194,3 +194,5 @@ nul # midgard-yarn-strict .store*/* + +/npm-pkgs From 0113766b2bc078981790f0eee7afed3b9b9f179e Mon Sep 17 00:00:00 2001 From: Vladimir Morozov Date: Fri, 19 Dec 2025 11:36:26 -0800 Subject: [PATCH 07/12] Fix type warnings --- .ado/scripts/npmPack.js | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/.ado/scripts/npmPack.js b/.ado/scripts/npmPack.js index ab51bde11f4..30ce3566c6f 100644 --- a/.ado/scripts/npmPack.js +++ b/.ado/scripts/npmPack.js @@ -18,6 +18,7 @@ const { execSync } = require('child_process'); /** * Find the enlistment root by going up two directories from script location + * @returns {string} Repository root path */ function findEnlistmentRoot() { const scriptDir = __dirname; @@ -34,6 +35,8 @@ function findEnlistmentRoot() { /** * Get workspace package paths from root package.json + * @param {string} repoRoot - Repository root directory + * @returns {string[]} Array of workspace patterns */ function getWorkspacePackages(repoRoot) { const packageJsonPath = path.join(repoRoot, 'package.json'); @@ -48,6 +51,9 @@ function getWorkspacePackages(repoRoot) { /** * Recursively find all package.json files in a directory + * @param {string} dir - Directory to search + * @param {string[]} results - Accumulated results + * @returns {string[]} Array of package.json file paths */ function findPackageJsonsRecursive(dir, results = []) { if (!fs.existsSync(dir)) { @@ -76,6 +82,9 @@ function findPackageJsonsRecursive(dir, results = []) { /** * Match a pattern against a path * Supports patterns like "packages/*" or "packages/@react-native-windows/*" + * @param {string} pattern - Workspace pattern to match + * @param {string} basePath - Base path to resolve pattern from + * @returns {string[]} Array of matching package.json paths */ function matchPattern(pattern, basePath) { // Remove trailing /* if present @@ -111,6 +120,9 @@ function matchPattern(pattern, basePath) { /** * Find all package.json files matching workspace patterns + * @param {string} repoRoot - Repository root directory + * @param {string[]} workspacePatterns - Array of workspace patterns + * @returns {string[]} Array of package.json file paths */ function findWorkspacePackageJsons(repoRoot, workspacePatterns) { const packageJsonPaths = []; @@ -125,6 +137,8 @@ function findWorkspacePackageJsons(repoRoot, workspacePatterns) { /** * Check if a package is private + * @param {string} packageJsonPath - Path to package.json file + * @returns {boolean} True if package is private */ function isPrivatePackage(packageJsonPath) { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); @@ -133,6 +147,9 @@ function isPrivatePackage(packageJsonPath) { /** * Pack a package using npm pack + * @param {string} packageDir - Directory containing the package + * @param {string} targetDir - Directory to output .tgz file + * @returns {boolean} True if packing succeeded */ function packPackage(packageDir, targetDir) { const packageJsonPath = path.join(packageDir, 'package.json'); @@ -153,7 +170,8 @@ function packPackage(packageDir, targetDir) { console.log(` āœ“ Created ${tgzFileName}`); return true; } catch (error) { - console.error(` āœ— Failed to pack ${packageName}: ${error.message}`); + const message = error instanceof Error ? error.message : String(error); + console.error(` āœ— Failed to pack ${packageName}: ${message}`); return false; } } @@ -239,7 +257,8 @@ function main() { process.exit(1); } } catch (error) { - console.error(`Error: ${error.message}`); + const message = error instanceof Error ? error.message : String(error); + console.error(`Error: ${message}`); process.exit(1); } } From aad5bb2f9b2a6a0cce15c7fbfe65f2646dc84d2f Mon Sep 17 00:00:00 2001 From: Vladimir Morozov Date: Fri, 19 Dec 2025 11:53:19 -0800 Subject: [PATCH 08/12] Use colors --- .ado/scripts/npmPack.js | 136 ++++++++++++++++++++++++++++++++++------ 1 file changed, 116 insertions(+), 20 deletions(-) diff --git a/.ado/scripts/npmPack.js b/.ado/scripts/npmPack.js index 30ce3566c6f..ccfeedde9e3 100644 --- a/.ado/scripts/npmPack.js +++ b/.ado/scripts/npmPack.js @@ -15,6 +15,63 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); +const { parseArgs } = require('util'); + +// ANSI color codes +const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + dim: '\x1b[2m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', + gray: '\x1b[90m', +}; + +/** @type {boolean} */ +let useColors = true; + +/** + * Colorize text if colors are enabled + * @param {string} text - Text to colorize + * @param {string} color - Color code from colors object + * @returns {string} Colorized text + */ +function colorize(text, color) { + if (!useColors) { + return text; + } + return color + text + colors.reset; +} + +/** + * Display help information + */ +function showHelp() { + console.log(` +npmPack.js - Pack all non-private workspace packages to tgz files + +Usage: + node npmPack.js [options] [targetDir] + +Arguments: + targetDir Target directory for .tgz files + Default: npm-pkgs (in repository root) + +Options: + --clean Clean target directory if it's not empty + --no-color Disable colored output + --help, -h Show this help message + +Examples: + node npmPack.js + node npmPack.js --clean + node npmPack.js path/to/output + node npmPack.js --clean --no-color path/to/output +`); +} /** * Find the enlistment root by going up two directories from script location @@ -156,7 +213,7 @@ function packPackage(packageDir, targetDir) { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); const packageName = packageJson.name; - console.log(`Packing ${packageName}...`); + console.log(`Packing ${colorize(packageName, colors.cyan)}...`); try { // Run npm pack in the package directory, output to target directory @@ -167,11 +224,11 @@ function packPackage(packageDir, targetDir) { }); const tgzFileName = output.trim().split('\n').pop(); - console.log(` āœ“ Created ${tgzFileName}`); + console.log(` ${colorize('āœ“', colors.green)} Created ${tgzFileName}`); return true; } catch (error) { const message = error instanceof Error ? error.message : String(error); - console.error(` āœ— Failed to pack ${packageName}: ${message}`); + console.error(` ${colorize('āœ—', colors.red)} Failed to pack ${colorize(packageName, colors.cyan)}: ${message}`); return false; } } @@ -180,49 +237,88 @@ function packPackage(packageDir, targetDir) { * Main function */ function main() { - const args = process.argv.slice(2); - const cleanFlag = args.includes('--clean'); - const targetDirArg = args.find(arg => !arg.startsWith('--')); + // Parse command line arguments + /** @type {import('util').ParseArgsConfig['options']} */ + const options = { + help: { + type: 'boolean', + short: 'h', + default: false, + }, + clean: { + type: 'boolean', + default: false, + }, + 'no-color': { + type: 'boolean', + default: false, + }, + }; + + let args; + try { + args = parseArgs({ + options, + allowPositionals: true, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`${colorize('Error parsing arguments:', colors.red)} ${message}`); + console.error('Use --help for usage information'); + process.exit(1); + } + + // Show help if requested + if (args.values.help) { + showHelp(); + process.exit(0); + } + + // Set color mode (colors enabled by default, disabled if --no-color is passed) + useColors = !args.values['no-color']; + + const cleanFlag = args.values.clean; + const targetDirArg = args.positionals[0]; try { // Find repo root const repoRoot = findEnlistmentRoot(); - console.log(`Repository root: ${repoRoot}`); + console.log(`${colorize('Repository root:', colors.bright)} ${repoRoot}`); // Determine target directory const targetDir = targetDirArg ? path.resolve(repoRoot, targetDirArg) : path.join(repoRoot, 'npm-pkgs'); - console.log(`Target directory: ${targetDir}`); + console.log(`${colorize('Target directory:', colors.bright)} ${targetDir}`); // Handle target directory if (fs.existsSync(targetDir)) { const files = fs.readdirSync(targetDir); if (files.length > 0) { if (!cleanFlag) { - console.error(`Error: Target directory is not empty: ${targetDir}`); + console.error(`${colorize('Error:', colors.red)} Target directory is not empty: ${targetDir}`); console.error('Use --clean flag to clean the directory before packing'); process.exit(1); } - console.log('Cleaning target directory...'); + console.log(`${colorize('Cleaning target directory...', colors.yellow)}`); for (const file of files) { fs.rmSync(path.join(targetDir, file), { recursive: true, force: true }); } } } else { - console.log('Creating target directory...'); + console.log(`${colorize('Creating target directory...', colors.yellow)}`); fs.mkdirSync(targetDir, { recursive: true }); } // Get workspace packages const workspacePatterns = getWorkspacePackages(repoRoot); - console.log(`Workspace patterns: ${workspacePatterns.join(', ')}`); + console.log(`${colorize('Workspace patterns:', colors.bright)} ${workspacePatterns.join(', ')}`); // Find all package.json files const packageJsonPaths = findWorkspacePackageJsons(repoRoot, workspacePatterns); - console.log(`Found ${packageJsonPaths.length} workspace packages\n`); + console.log(`${colorize('Found', colors.bright)} ${colorize(packageJsonPaths.length.toString(), colors.cyan)} ${colorize('workspace packages', colors.bright)}\n`); // Pack non-private packages let packedCount = 0; @@ -234,7 +330,7 @@ function main() { if (isPrivatePackage(packageJsonPath)) { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - console.log(`Skipping private package: ${packageJson.name}`); + console.log(`${colorize('Skipping private package:', colors.gray)} ${colorize(packageJson.name, colors.dim)}`); skippedCount++; continue; } @@ -247,18 +343,18 @@ function main() { } } - console.log(`\nāœ“ Packing complete:`); - console.log(` Packed: ${packedCount}`); - console.log(` Skipped (private): ${skippedCount}`); - console.log(` Failed: ${failedCount}`); - console.log(` Target: ${targetDir}`); + console.log(`\n${colorize('āœ“', colors.green)} ${colorize('Packing complete:', colors.bright)}`); + console.log(` ${colorize('Packed:', colors.bright)} ${colorize(packedCount.toString(), colors.green)}`); + console.log(` ${colorize('Skipped (private):', colors.bright)} ${colorize(skippedCount.toString(), colors.gray)}`); + console.log(` ${colorize('Failed:', colors.bright)} ${failedCount > 0 ? colorize(failedCount.toString(), colors.red) : colorize(failedCount.toString(), colors.green)}`); + console.log(` ${colorize('Target:', colors.bright)} ${targetDir}`); if (failedCount > 0) { process.exit(1); } } catch (error) { const message = error instanceof Error ? error.message : String(error); - console.error(`Error: ${message}`); + console.error(`${colorize('Error:', colors.red)} ${message}`); process.exit(1); } } From 1a2f91a08db6ff24051778f3b5798274aaee4d4a Mon Sep 17 00:00:00 2001 From: Vladimir Morozov Date: Fri, 19 Dec 2025 12:15:15 -0800 Subject: [PATCH 09/12] Work in progress on validation --- .ado/scripts/npmPack.js | 197 +++++++++++++++++++++++++++++++++------- 1 file changed, 163 insertions(+), 34 deletions(-) diff --git a/.ado/scripts/npmPack.js b/.ado/scripts/npmPack.js index ccfeedde9e3..261c03c9434 100644 --- a/.ado/scripts/npmPack.js +++ b/.ado/scripts/npmPack.js @@ -5,11 +5,13 @@ * npmPack.js - Pack all non-private workspace packages to tgz files * * Usage: - * node npmPack.js [targetDir] [--clean] + * node npmPack.js [targetDir] [--clean] [--check-npm] [--no-pack] * * Arguments: - * targetDir - Target directory for .tgz files (default: npm-pkgs in repo root) - * --clean - Clean target directory if it's not empty + * targetDir - Target directory for .tgz files (default: npm-pkgs in repo root) + * --clean - Clean target directory if it's not empty + * --check-npm - Check each package against npmjs.com and remove already published ones + * --no-pack - Skip packing, only check and clean target folder */ const fs = require('fs'); @@ -62,12 +64,16 @@ Arguments: Options: --clean Clean target directory if it's not empty + --check-npm Check each package against npmjs.com and remove already published ones + --no-pack Skip packing, only check and clean target folder --no-color Disable colored output --help, -h Show this help message Examples: node npmPack.js node npmPack.js --clean + node npmPack.js --check-npm + node npmPack.js --no-pack --check-npm node npmPack.js path/to/output node npmPack.js --clean --no-color path/to/output `); @@ -202,6 +208,97 @@ function isPrivatePackage(packageJsonPath) { return packageJson.private === true; } +/** + * Check if a package version is published on npmjs.com + * @param {string} packageName - Name of the package + * @param {string} version - Version to check + * @returns {boolean} True if the package version is already published + */ +function isPublishedOnNpm(packageName, version) { + try { + // Use npm view to check if the specific version exists + execSync(`npm view ${packageName}@${version} version`, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }); + return true; + } catch (error) { + // If npm view fails, the version doesn't exist + return false; + } +} + +/** + * Extract package name and version from a .tgz file by reading its package.json + * @param {string} tgzPath - Full path to the .tgz file + * @returns {{name: string, version: string} | null} Package info or null if extraction fails + */ +function getPackageInfoFromTgz(tgzPath) { + try { + // Convert Windows path to Unix-style path for tar command + // tar on Windows (via Git Bash) expects forward slashes + const unixPath = tgzPath.replace(/\\/g, '/'); + + // Use tar to extract package/package.json from the tarball + // The -O flag outputs to stdout, -xzf extracts from gzipped tar + const output = execSync(`tar -xzf "${unixPath}" package/package.json -O`, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }); + + const packageJson = JSON.parse(output); + return { + name: packageJson.name, + version: packageJson.version + }; + } catch (error) { + // If extraction fails, return null + return null; + } +} + +/** + * Check and remove already published packages from target directory + * @param {string} targetDir - Directory containing .tgz files + * @returns {{checked: number, removed: number}} Statistics about checked and removed packages + */ +function checkAndRemovePublishedPackages(targetDir) { + let checkedCount = 0; + let removedCount = 0; + + if (!fs.existsSync(targetDir)) { + return { checked: checkedCount, removed: removedCount }; + } + + const files = fs.readdirSync(targetDir); + const tgzFiles = files.filter(f => f.endsWith('.tgz')); + + console.log(`\n${colorize('Checking packages against npmjs.com...', colors.bright)}`); + + for (const tgzFile of tgzFiles) { + const tgzPath = path.join(targetDir, tgzFile); + const packageInfo = getPackageInfoFromTgz(tgzPath); + + if (!packageInfo) { + console.log(` ${colorize('⚠', colors.yellow)} Skipping ${tgzFile} (cannot extract package.json)`); + continue; + } + + checkedCount++; + console.log(` Checking ${colorize(packageInfo.name, colors.cyan)}@${colorize(packageInfo.version, colors.dim)}...`); + + if (isPublishedOnNpm(packageInfo.name, packageInfo.version)) { + fs.rmSync(tgzPath); + console.log(` ${colorize('āœ“', colors.green)} Already published - removed ${tgzFile}`); + removedCount++; + } else { + console.log(` ${colorize('→', colors.blue)} Not published - keeping ${tgzFile}`); + } + } + + return { checked: checkedCount, removed: removedCount }; +} + /** * Pack a package using npm pack * @param {string} packageDir - Directory containing the package @@ -249,6 +346,14 @@ function main() { type: 'boolean', default: false, }, + 'check-npm': { + type: 'boolean', + default: false, + }, + 'no-pack': { + type: 'boolean', + default: false, + }, 'no-color': { type: 'boolean', default: false, @@ -278,6 +383,8 @@ function main() { useColors = !args.values['no-color']; const cleanFlag = args.values.clean; + const checkNpmFlag = args.values['check-npm']; + const noPackFlag = args.values['no-pack']; const targetDirArg = args.positionals[0]; try { @@ -296,15 +403,18 @@ function main() { if (fs.existsSync(targetDir)) { const files = fs.readdirSync(targetDir); if (files.length > 0) { - if (!cleanFlag) { + // Only enforce clean directory requirement if we're packing + if (!noPackFlag && !cleanFlag) { console.error(`${colorize('Error:', colors.red)} Target directory is not empty: ${targetDir}`); console.error('Use --clean flag to clean the directory before packing'); process.exit(1); } - console.log(`${colorize('Cleaning target directory...', colors.yellow)}`); - for (const file of files) { - fs.rmSync(path.join(targetDir, file), { recursive: true, force: true }); + if (cleanFlag) { + console.log(`${colorize('Cleaning target directory...', colors.yellow)}`); + for (const file of files) { + fs.rmSync(path.join(targetDir, file), { recursive: true, force: true }); + } } } } else { @@ -312,42 +422,61 @@ function main() { fs.mkdirSync(targetDir, { recursive: true }); } - // Get workspace packages - const workspacePatterns = getWorkspacePackages(repoRoot); - console.log(`${colorize('Workspace patterns:', colors.bright)} ${workspacePatterns.join(', ')}`); - - // Find all package.json files - const packageJsonPaths = findWorkspacePackageJsons(repoRoot, workspacePatterns); - console.log(`${colorize('Found', colors.bright)} ${colorize(packageJsonPaths.length.toString(), colors.cyan)} ${colorize('workspace packages', colors.bright)}\n`); - - // Pack non-private packages + // Pack non-private packages (unless --no-pack is specified) let packedCount = 0; let skippedCount = 0; let failedCount = 0; - for (const packageJsonPath of packageJsonPaths) { - const packageDir = path.dirname(packageJsonPath); + if (!noPackFlag) { + // Get workspace packages + const workspacePatterns = getWorkspacePackages(repoRoot); + console.log(`${colorize('Workspace patterns:', colors.bright)} ${workspacePatterns.join(', ')}`); - if (isPrivatePackage(packageJsonPath)) { - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - console.log(`${colorize('Skipping private package:', colors.gray)} ${colorize(packageJson.name, colors.dim)}`); - skippedCount++; - continue; - } + // Find all package.json files + const packageJsonPaths = findWorkspacePackageJsons(repoRoot, workspacePatterns); + console.log(`${colorize('Found', colors.bright)} ${colorize(packageJsonPaths.length.toString(), colors.cyan)} ${colorize('workspace packages', colors.bright)}\n`); - const success = packPackage(packageDir, targetDir); - if (success) { - packedCount++; - } else { - failedCount++; + for (const packageJsonPath of packageJsonPaths) { + const packageDir = path.dirname(packageJsonPath); + + if (isPrivatePackage(packageJsonPath)) { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + console.log(`${colorize('Skipping private package:', colors.gray)} ${colorize(packageJson.name, colors.dim)}`); + skippedCount++; + continue; + } + + const success = packPackage(packageDir, targetDir); + if (success) { + packedCount++; + } else { + failedCount++; + } } + + console.log(`\n${colorize('āœ“', colors.green)} ${colorize('Packing complete:', colors.bright)}`); + console.log(` ${colorize('Packed:', colors.bright)} ${colorize(packedCount.toString(), colors.green)}`); + console.log(` ${colorize('Skipped (private):', colors.bright)} ${colorize(skippedCount.toString(), colors.gray)}`); + console.log(` ${colorize('Failed:', colors.bright)} ${failedCount > 0 ? colorize(failedCount.toString(), colors.red) : colorize(failedCount.toString(), colors.green)}`); + console.log(` ${colorize('Target:', colors.bright)} ${targetDir}`); + } else { + console.log(`\n${colorize('Skipping packing (--no-pack specified)', colors.yellow)}`); } - console.log(`\n${colorize('āœ“', colors.green)} ${colorize('Packing complete:', colors.bright)}`); - console.log(` ${colorize('Packed:', colors.bright)} ${colorize(packedCount.toString(), colors.green)}`); - console.log(` ${colorize('Skipped (private):', colors.bright)} ${colorize(skippedCount.toString(), colors.gray)}`); - console.log(` ${colorize('Failed:', colors.bright)} ${failedCount > 0 ? colorize(failedCount.toString(), colors.red) : colorize(failedCount.toString(), colors.green)}`); - console.log(` ${colorize('Target:', colors.bright)} ${targetDir}`); + // Check and remove already published packages if requested + let checkedCount = 0; + let removedCount = 0; + + if (checkNpmFlag) { + const result = checkAndRemovePublishedPackages(targetDir); + checkedCount = result.checked; + removedCount = result.removed; + + console.log(`\n${colorize('āœ“', colors.green)} ${colorize('NPM check complete:', colors.bright)}`); + console.log(` ${colorize('Checked:', colors.bright)} ${colorize(checkedCount.toString(), colors.cyan)}`); + console.log(` ${colorize('Removed (already published):', colors.bright)} ${colorize(removedCount.toString(), colors.green)}`); + console.log(` ${colorize('Remaining:', colors.bright)} ${colorize((checkedCount - removedCount).toString(), colors.cyan)}`); + } if (failedCount > 0) { process.exit(1); From fb6f7bed942ebb903689d3586ac8fd70dfd0a6a2 Mon Sep 17 00:00:00 2001 From: Vladimir Morozov Date: Fri, 19 Dec 2025 12:25:54 -0800 Subject: [PATCH 10/12] Fix package.json extraction --- .ado/scripts/npmPack.js | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/.ado/scripts/npmPack.js b/.ado/scripts/npmPack.js index 261c03c9434..a89519b6f04 100644 --- a/.ado/scripts/npmPack.js +++ b/.ado/scripts/npmPack.js @@ -231,7 +231,7 @@ function isPublishedOnNpm(packageName, version) { /** * Extract package name and version from a .tgz file by reading its package.json * @param {string} tgzPath - Full path to the .tgz file - * @returns {{name: string, version: string} | null} Package info or null if extraction fails + * @returns {{name: string, version: string, error?: string} | null} Package info or null if extraction fails */ function getPackageInfoFromTgz(tgzPath) { try { @@ -240,8 +240,8 @@ function getPackageInfoFromTgz(tgzPath) { const unixPath = tgzPath.replace(/\\/g, '/'); // Use tar to extract package/package.json from the tarball - // The -O flag outputs to stdout, -xzf extracts from gzipped tar - const output = execSync(`tar -xzf "${unixPath}" package/package.json -O`, { + // The -xzf extracts from gzipped tar, the -O flag outputs to stdout, then the file to extract + const output = execSync(`tar -xzf "${unixPath}" -O package/package.json`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); @@ -252,8 +252,9 @@ function getPackageInfoFromTgz(tgzPath) { version: packageJson.version }; } catch (error) { - // If extraction fails, return null - return null; + // If extraction fails, return error information + const message = error instanceof Error ? error.message : String(error); + return { name: '', version: '', error: message }; } } @@ -279,8 +280,10 @@ function checkAndRemovePublishedPackages(targetDir) { const tgzPath = path.join(targetDir, tgzFile); const packageInfo = getPackageInfoFromTgz(tgzPath); - if (!packageInfo) { - console.log(` ${colorize('⚠', colors.yellow)} Skipping ${tgzFile} (cannot extract package.json)`); + if (!packageInfo || packageInfo.error || !packageInfo.name) { + const errorMsg = packageInfo?.error || 'cannot extract package.json'; + console.log(` ${colorize('⚠', colors.yellow)} Skipping ${tgzFile}`); + console.log(` ${colorize('Error:', colors.red)} ${errorMsg}`); continue; } From 0aad615e045642e5d266718276a0f7e44c1862d1 Mon Sep 17 00:00:00 2001 From: Vladimir Morozov Date: Fri, 19 Dec 2025 12:41:24 -0800 Subject: [PATCH 11/12] Start using the npmPack --- .ado/templates/verdaccio-start.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.ado/templates/verdaccio-start.yml b/.ado/templates/verdaccio-start.yml index 7d79a4dc814..3727f74f68b 100644 --- a/.ado/templates/verdaccio-start.yml +++ b/.ado/templates/verdaccio-start.yml @@ -37,7 +37,14 @@ steps: displayName: Validate generated JSI layout - ${{ if eq(parameters.beachballPublish, true) }}: - - script: npx beachball publish --branch origin/$(BeachBallBranchName) --no-push --registry http://localhost:4873 --yes --verbose --access public --changehint "Run `yarn change` from root of repo to generate a change file." + - script: node .ado/scripts/npmPack.js --clean + displayName: Pack all workspace packages + + - script: | + for %%f in (npm-pkgs\*.tgz) do ( + echo Publishing %%f to verdaccio... + npm publish "%%f" --registry http://localhost:4873 --access public + ) displayName: Publish packages to verdaccio - script: | From e80e91ee8e920d1a124f6f21c3469f7524c67089 Mon Sep 17 00:00:00 2001 From: Vladimir Morozov Date: Fri, 19 Dec 2025 12:43:29 -0800 Subject: [PATCH 12/12] Add back beachball bump --- .ado/templates/verdaccio-start.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.ado/templates/verdaccio-start.yml b/.ado/templates/verdaccio-start.yml index 3727f74f68b..83686633d74 100644 --- a/.ado/templates/verdaccio-start.yml +++ b/.ado/templates/verdaccio-start.yml @@ -37,6 +37,9 @@ steps: displayName: Validate generated JSI layout - ${{ if eq(parameters.beachballPublish, true) }}: + - script: npx beachball bump --branch origin/$(BeachBallBranchName) --no-push --yes --verbose --changehint "Run `yarn change` from root of repo to generate a change file." + displayName: Beachball bump versions + - script: node .ado/scripts/npmPack.js --clean displayName: Pack all workspace packages