Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 66 additions & 11 deletions .ado/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,6 @@ parameters:
variables:
- template: variables/windows.yml
- group: RNW Secrets
- name: SkipNpmPublishArgs
value: ''
- name: SkipGitPushPublishArgs
value: ''
- name: FailCGOnAlert
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
202 changes: 202 additions & 0 deletions .ado/scripts/npmGroupByTag.js
Original file line number Diff line number Diff line change
@@ -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 <packRoot> <customRoot> <latestRoot>');
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<string>} */
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');
})();
Loading