diff --git a/.ado/publish.yml b/.ado/publish.yml index dafc177afd4..d7d5fc3576b 100644 --- a/.ado/publish.yml +++ b/.ado/publish.yml @@ -134,8 +134,6 @@ parameters: variables: - template: variables/windows.yml - group: RNW Secrets - - name: SkipNpmPublishArgs - value: '' - name: SkipGitPushPublishArgs value: '' - name: FailCGOnAlert @@ -146,6 +144,8 @@ variables: value: microsoft - name: ArtifactServices.Symbol.PAT value: $(pat-symbols-publish-microsoft) + - name: SourceBranchWithFolders + value: $[ replace(variables['Build.SourceBranch'], 'refs/heads/', '') ] trigger: none pr: none @@ -225,21 +225,71 @@ extends: - template: .ado/templates/configure-git.yml@self - - script: | - echo "##vso[task.setvariable variable=SkipNpmPublishArgs]--no-publish" - displayName: Enable No-Publish (npm) - condition: ${{ parameters.skipNpmPublish }} - - - script: | - echo "##vso[task.setvariable variable=SkipGitPushPublishArgs]--no-push" + - pwsh: | + Write-Host "##vso[task.setvariable variable=SkipGitPushPublishArgs]--no-push" displayName: Enable No-Publish (git) condition: ${{ parameters.skipGitPush }} - - script: npx beachball publish $(SkipNpmPublishArgs) $(SkipGitPushPublishArgs) --branch origin/$(Build.SourceBranchName) -n $(npmAuthToken) -yes --bump-deps --verbose --access public --message "applying package updates ***NO_CI***" + # Beachball publishes NPM packages to the "$(Pipeline.Workspace)\published-packages" folder. + # It pushes NPM version updates to Git depending on the SkipGitPushPublishArgs variable derived from the skipGitPush parameter. + - script: | + if exist "$(Pipeline.Workspace)\published-packages" rd /s /q "$(Pipeline.Workspace)\published-packages" + mkdir "$(Pipeline.Workspace)\published-packages" + npx beachball publish --no-publish $(SkipGitPushPublishArgs) --pack-to-path "$(Pipeline.Workspace)\published-packages" --branch origin/$(SourceBranchWithFolders) -yes --bump-deps --verbose --access public --message "applying package updates ***NO_CI***" displayName: Beachball Publish + - script: dir /s "$(Pipeline.Workspace)\published-packages" + displayName: Show created npm packages + + # Beachball usually takes care about the NPM package tagging based on the values in package.json files. + # We use the ESRP Release where we must provide the tag explictly (the productstate parameter). + # Fortunately, we just use two tags: latest and some custom tag like "canary", "v0.73-stable", etc. + # The npmGroupByTag.js script groups the created NPM package by these two tags into the specified folders. + - pwsh: | + node .ado/scripts/npmGroupByTag.js "$(Pipeline.Workspace)\published-packages" "$(Pipeline.Workspace)\published-packages\custom-tag" "$(Pipeline.Workspace)\published-packages\latest-tag" + displayName: Group npm packages by tag + + - script: dir /s "$(Pipeline.Workspace)\published-packages" + displayName: Show grouped npm packages by tag + + # Publish NPM packages using ESRP Release task with the custom tag such as "canary", "v0.73-stable", etc. + - task: 'SFP.release-tasks.custom-build-release-task.EsrpRelease@10' + displayName: 'ESRP Release to npmjs.com (custom tag)' + condition: and(succeeded(), ${{ not(parameters.skipNpmPublish) }}, eq(variables['NpmCustomFolderHasContent'], 'true')) + 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: '$(NpmCustomFolder)' + productstate: '$(NpmCustomTag)' + owners: 'vmorozov@microsoft.com' + approvers: 'khosany@microsoft.com' + + # Publish NPM packages using ESRP Release task with the "latest" tag. + - task: 'SFP.release-tasks.custom-build-release-task.EsrpRelease@10' + displayName: 'ESRP Release to npmjs.com (latest)' + condition: and(succeeded(), ${{ not(parameters.skipNpmPublish) }}, eq(variables['NpmLatestFolderHasContent'], 'true')) + 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: '$(NpmLatestFolder)' + productstate: 'latest' + owners: 'vmorozov@microsoft.com' + approvers: 'khosany@microsoft.com' + # Beachball reverts to local state after publish, but we want the updates it added - - script: git pull origin ${{ variables['Build.SourceBranchName'] }} + - script: git pull origin $(SourceBranchWithFolders) displayName: git pull - script: npx @rnw-scripts/create-github-releases --yes --authToken $(githubAuthToken) @@ -261,6 +311,11 @@ extends: templateContext: outputs: + - output: pipelineArtifact + displayName: 'Publish npm pack artifacts' + condition: succeededOrFailed() + targetPath: $(Pipeline.Workspace)/published-packages + artifactName: NpmPackedTarballs - output: pipelineArtifact displayName: "📒 Publish Manifest Npm" artifactName: SBom-$(System.JobAttempt) diff --git a/.ado/scripts/npmGroupByTag.js b/.ado/scripts/npmGroupByTag.js new file mode 100644 index 00000000000..23d6320e689 --- /dev/null +++ b/.ado/scripts/npmGroupByTag.js @@ -0,0 +1,202 @@ +#!/usr/bin/env node +// @ts-check + +// Groups packed npm tarballs into tag-specific folders so ESRP can publish with the +// correct productstate value per tag. + +const fs = require('fs'); +const path = require('path'); + +/** + * @typedef {Object} PackageJsonBeachball + * @property {string | undefined} [defaultNpmTag] + */ + +/** + * @typedef {Object} PackageJson + * @property {string | undefined} [name] + * @property {string | undefined} [version] + * @property {boolean | undefined} [private] + * @property {PackageJsonBeachball | undefined} [beachball] + */ + +/** + * @returns {{packRootArg: string, customRootArg: string, latestRootArg: string}} + */ +function ensureArgs() { + const [, , packRootArg, customRootArg, latestRootArg] = process.argv; + if (!packRootArg || !customRootArg || !latestRootArg) { + console.error('Usage: node npmGroupByTag.js '); + process.exit(1); + } + return {packRootArg, customRootArg, latestRootArg}; +} + +/** + * @param {string} filePath + * @returns {unknown} + */ +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +/** + * @param {string} pkgName + * @param {string} version + * @returns {string} + */ +function sanitizedTarballName(pkgName, version) { + const prefix = pkgName.startsWith('@') + ? pkgName.slice(1).replace(/\//g, '-').replace(/@/g, '-') + : pkgName.replace(/@/g, '-'); + return `${prefix}-${version}.tgz`; +} + +/** + * @param {string} tarballName + * @returns {string} + */ +function normalizePackedTarballName(tarballName) { + // beachball prefixes packed tarballs with a monotonically increasing number to avoid collisions + // when multiple packages share the same filename. Strip that prefix (single or repeated) for comparison. + return tarballName.replace(/^(?:\d+[._-])+/u, ''); +} + +/** + * @param {string} root + * @returns {string[]} + */ +function findPackageJsons(root) { + /** @type {string[]} */ + const results = []; + /** @type {string[]} */ + const stack = [root]; + + while (stack.length) { + const current = stack.pop(); + if (!current) { + continue; + } + /** @type {fs.Stats | undefined} */ + let stats; + try { + stats = fs.statSync(current); + } catch (e) { + continue; + } + + if (!stats.isDirectory()) { + continue; + } + + const entries = fs.readdirSync(current, {withFileTypes: true}); + for (const entry of entries) { + if (entry.name === 'node_modules' || entry.name === '.git') { + continue; + } + const entryPath = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(entryPath); + } else if (entry.isFile() && entry.name === 'package.json') { + results.push(entryPath); + } + } + } + + return results; +} + +/** + * @param {string} name + * @param {string} value + */ +function setPipelineVariable(name, value) { + console.log(`##vso[task.setvariable variable=${name}]${value}`); +} + +(function main() { + const {packRootArg, customRootArg, latestRootArg} = ensureArgs(); + + const repoRoot = process.env.BUILD_SOURCESDIRECTORY || process.cwd(); + const packRoot = path.resolve(packRootArg); + const customRoot = path.resolve(customRootArg); + const latestRoot = path.resolve(latestRootArg); + + fs.mkdirSync(customRoot, {recursive: true}); + fs.mkdirSync(latestRoot, {recursive: true}); + + /** @type {string | null} */ + let customTag = null; + try { + const vnextPackageJson = /** @type {PackageJson} */ ( + readJson(path.join(repoRoot, 'vnext', 'package.json')) + ); + const tagFromVnext = vnextPackageJson?.beachball?.defaultNpmTag; + if (tagFromVnext && tagFromVnext !== 'latest') { + customTag = tagFromVnext; + } + } catch (e) { + console.warn('Unable to read vnext/package.json to determine custom tag.'); + } + + /** @type {string[]} */ + const tarballs = fs.existsSync(packRoot) + ? fs.readdirSync(packRoot).filter(file => file.endsWith('.tgz')) + : []; + + if (!tarballs.length) { + setPipelineVariable('NpmCustomTag', customTag || ''); + setPipelineVariable('NpmCustomFolder', customRoot); + setPipelineVariable('NpmCustomFolderHasContent', 'false'); + setPipelineVariable('NpmLatestFolder', latestRoot); + setPipelineVariable('NpmLatestFolderHasContent', 'false'); + return; + } + + /** @type {Set} */ + const customTarballs = new Set(); + + if (customTag) { + for (const packageJsonPath of findPackageJsons(repoRoot)) { + /** @type {PackageJson | undefined} */ + let pkg; + try { + pkg = /** @type {PackageJson} */ (readJson(packageJsonPath)); + } catch (e) { + continue; + } + + if (!pkg?.name || !pkg?.version) { + continue; + } + + const pkgTag = pkg?.beachball?.defaultNpmTag; + if (pkgTag === customTag && pkg.private !== true) { + customTarballs.add(sanitizedTarballName(pkg.name, pkg.version)); + } + } + } + + let customCount = 0; + let latestCount = 0; + + for (const tarball of tarballs) { + const sourcePath = path.join(packRoot, tarball); + const normalizedName = normalizePackedTarballName(tarball); + const destinationRoot = customTag && customTarballs.has(normalizedName) ? customRoot : latestRoot; + const destinationPath = path.join(destinationRoot, tarball); + fs.mkdirSync(path.dirname(destinationPath), {recursive: true}); + fs.renameSync(sourcePath, destinationPath); + if (destinationRoot === customRoot) { + customCount++; + } else { + latestCount++; + } + } + + setPipelineVariable('NpmCustomTag', customTag || ''); + setPipelineVariable('NpmCustomFolder', customRoot); + setPipelineVariable('NpmCustomFolderHasContent', customCount ? 'true' : 'false'); + setPipelineVariable('NpmLatestFolder', latestRoot); + setPipelineVariable('NpmLatestFolderHasContent', latestCount ? 'true' : 'false'); +})();