Skip to content

Commit c6474e1

Browse files
authored
Enable ESRP Release process for NPM packages (#15402)
1 parent 46c64c8 commit c6474e1

File tree

2 files changed

+266
-9
lines changed

2 files changed

+266
-9
lines changed

.ado/publish.yml

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,6 @@ parameters:
104104
variables:
105105
- template: variables/windows.yml
106106
- group: RNW Secrets
107-
- name: SkipNpmPublishArgs
108-
value: ''
109107
- name: SkipGitPushPublishArgs
110108
value: ''
111109
- name: FailCGOnAlert
@@ -116,6 +114,8 @@ variables:
116114
value: microsoft
117115
- name: ArtifactServices.Symbol.PAT
118116
value: $(pat-symbols-publish-microsoft)
117+
- name: SourceBranchWithFolders
118+
value: $[ replace(variables['Build.SourceBranch'], 'refs/heads/', '') ]
119119

120120
trigger: none
121121
pr: none
@@ -195,21 +195,71 @@ extends:
195195

196196
- template: .ado/templates/configure-git.yml@self
197197

198-
- pwsh: |
199-
Write-Host "##vso[task.setvariable variable=SkipNpmPublishArgs]--no-publish"
200-
displayName: Enable No-Publish (npm)
201-
condition: ${{ parameters.skipNpmPublish }}
202-
203198
- pwsh: |
204199
Write-Host "##vso[task.setvariable variable=SkipGitPushPublishArgs]--no-push"
205200
displayName: Enable No-Publish (git)
206201
condition: ${{ parameters.skipGitPush }}
207202
208-
- script: npx beachball publish $(SkipNpmPublishArgs) $(SkipGitPushPublishArgs) --branch origin/$(Build.SourceBranchName) -n $(npmAuthToken) -yes --bump-deps --verbose --access public --message "applying package updates ***NO_CI***"
203+
# Beachball publishes NPM packages to the "$(Pipeline.Workspace)\published-packages" folder.
204+
# It pushes NPM version updates to Git depending on the SkipGitPushPublishArgs variable derived from the skipGitPush parameter.
205+
- script: |
206+
if exist "$(Pipeline.Workspace)\published-packages" rd /s /q "$(Pipeline.Workspace)\published-packages"
207+
mkdir "$(Pipeline.Workspace)\published-packages"
208+
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***"
209209
displayName: Beachball Publish
210210
211+
- script: dir /s "$(Pipeline.Workspace)\published-packages"
212+
displayName: Show created npm packages
213+
214+
# Beachball usually takes care about the NPM package tagging based on the values in package.json files.
215+
# We use the ESRP Release where we must provide the tag explictly (the productstate parameter).
216+
# Fortunately, we just use two tags: latest and some custom tag like "canary", "v0.73-stable", etc.
217+
# The npmGroupByTag.js script groups the created NPM package by these two tags into the specified folders.
218+
- pwsh: |
219+
node .ado/scripts/npmGroupByTag.js "$(Pipeline.Workspace)\published-packages" "$(Pipeline.Workspace)\published-packages\custom-tag" "$(Pipeline.Workspace)\published-packages\latest-tag"
220+
displayName: Group npm packages by tag
221+
222+
- script: dir /s "$(Pipeline.Workspace)\published-packages"
223+
displayName: Show grouped npm packages by tag
224+
225+
# Publish NPM packages using ESRP Release task with the custom tag such as "canary", "v0.73-stable", etc.
226+
- task: 'SFP.release-tasks.custom-build-release-task.EsrpRelease@10'
227+
displayName: 'ESRP Release to npmjs.com (custom tag)'
228+
condition: and(succeeded(), ${{ not(parameters.skipNpmPublish) }}, eq(variables['NpmCustomFolderHasContent'], 'true'))
229+
inputs:
230+
connectedservicename: 'ESRP-CodeSigning-OGX-JSHost-RNW'
231+
usemanagedidentity: false
232+
keyvaultname: 'OGX-JSHost-KV'
233+
authcertname: 'OGX-JSHost-Auth4'
234+
signcertname: 'OGX-JSHost-Sign3'
235+
clientid: '0a35e01f-eadf-420a-a2bf-def002ba898d'
236+
domaintenantid: 'cdc5aeea-15c5-4db6-b079-fcadd2505dc2'
237+
contenttype: npm
238+
folderlocation: '$(NpmCustomFolder)'
239+
productstate: '$(NpmCustomTag)'
240+
owners: 'vmorozov@microsoft.com'
241+
approvers: 'khosany@microsoft.com'
242+
243+
# Publish NPM packages using ESRP Release task with the "latest" tag.
244+
- task: 'SFP.release-tasks.custom-build-release-task.EsrpRelease@10'
245+
displayName: 'ESRP Release to npmjs.com (latest)'
246+
condition: and(succeeded(), ${{ not(parameters.skipNpmPublish) }}, eq(variables['NpmLatestFolderHasContent'], 'true'))
247+
inputs:
248+
connectedservicename: 'ESRP-CodeSigning-OGX-JSHost-RNW'
249+
usemanagedidentity: false
250+
keyvaultname: 'OGX-JSHost-KV'
251+
authcertname: 'OGX-JSHost-Auth4'
252+
signcertname: 'OGX-JSHost-Sign3'
253+
clientid: '0a35e01f-eadf-420a-a2bf-def002ba898d'
254+
domaintenantid: 'cdc5aeea-15c5-4db6-b079-fcadd2505dc2'
255+
contenttype: npm
256+
folderlocation: '$(NpmLatestFolder)'
257+
productstate: 'latest'
258+
owners: 'vmorozov@microsoft.com'
259+
approvers: 'khosany@microsoft.com'
260+
211261
# Beachball reverts to local state after publish, but we want the updates it added
212-
- script: git pull origin ${{ variables['Build.SourceBranchName'] }}
262+
- script: git pull origin $(SourceBranchWithFolders)
213263
displayName: git pull
214264

215265
- script: npx @rnw-scripts/create-github-releases --yes --authToken $(githubAuthToken)
@@ -231,6 +281,11 @@ extends:
231281

232282
templateContext:
233283
outputs:
284+
- output: pipelineArtifact
285+
displayName: 'Publish npm pack artifacts'
286+
condition: succeededOrFailed()
287+
targetPath: $(Pipeline.Workspace)/published-packages
288+
artifactName: NpmPackedTarballs
234289
- output: pipelineArtifact
235290
displayName: "📒 Publish Manifest Npm"
236291
artifactName: SBom-$(System.JobAttempt)

.ado/scripts/npmGroupByTag.js

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
#!/usr/bin/env node
2+
// @ts-check
3+
4+
// Groups packed npm tarballs into tag-specific folders so ESRP can publish with the
5+
// correct productstate value per tag.
6+
7+
const fs = require('fs');
8+
const path = require('path');
9+
10+
/**
11+
* @typedef {Object} PackageJsonBeachball
12+
* @property {string | undefined} [defaultNpmTag]
13+
*/
14+
15+
/**
16+
* @typedef {Object} PackageJson
17+
* @property {string | undefined} [name]
18+
* @property {string | undefined} [version]
19+
* @property {boolean | undefined} [private]
20+
* @property {PackageJsonBeachball | undefined} [beachball]
21+
*/
22+
23+
/**
24+
* @returns {{packRootArg: string, customRootArg: string, latestRootArg: string}}
25+
*/
26+
function ensureArgs() {
27+
const [, , packRootArg, customRootArg, latestRootArg] = process.argv;
28+
if (!packRootArg || !customRootArg || !latestRootArg) {
29+
console.error('Usage: node npmGroupByTag.js <packRoot> <customRoot> <latestRoot>');
30+
process.exit(1);
31+
}
32+
return {packRootArg, customRootArg, latestRootArg};
33+
}
34+
35+
/**
36+
* @param {string} filePath
37+
* @returns {unknown}
38+
*/
39+
function readJson(filePath) {
40+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
41+
}
42+
43+
/**
44+
* @param {string} pkgName
45+
* @param {string} version
46+
* @returns {string}
47+
*/
48+
function sanitizedTarballName(pkgName, version) {
49+
const prefix = pkgName.startsWith('@')
50+
? pkgName.slice(1).replace(/\//g, '-').replace(/@/g, '-')
51+
: pkgName.replace(/@/g, '-');
52+
return `${prefix}-${version}.tgz`;
53+
}
54+
55+
/**
56+
* @param {string} tarballName
57+
* @returns {string}
58+
*/
59+
function normalizePackedTarballName(tarballName) {
60+
// beachball prefixes packed tarballs with a monotonically increasing number to avoid collisions
61+
// when multiple packages share the same filename. Strip that prefix (single or repeated) for comparison.
62+
return tarballName.replace(/^(?:\d+[._-])+/u, '');
63+
}
64+
65+
/**
66+
* @param {string} root
67+
* @returns {string[]}
68+
*/
69+
function findPackageJsons(root) {
70+
/** @type {string[]} */
71+
const results = [];
72+
/** @type {string[]} */
73+
const stack = [root];
74+
75+
while (stack.length) {
76+
const current = stack.pop();
77+
if (!current) {
78+
continue;
79+
}
80+
/** @type {fs.Stats | undefined} */
81+
let stats;
82+
try {
83+
stats = fs.statSync(current);
84+
} catch (e) {
85+
continue;
86+
}
87+
88+
if (!stats.isDirectory()) {
89+
continue;
90+
}
91+
92+
const entries = fs.readdirSync(current, {withFileTypes: true});
93+
for (const entry of entries) {
94+
if (entry.name === 'node_modules' || entry.name === '.git') {
95+
continue;
96+
}
97+
const entryPath = path.join(current, entry.name);
98+
if (entry.isDirectory()) {
99+
stack.push(entryPath);
100+
} else if (entry.isFile() && entry.name === 'package.json') {
101+
results.push(entryPath);
102+
}
103+
}
104+
}
105+
106+
return results;
107+
}
108+
109+
/**
110+
* @param {string} name
111+
* @param {string} value
112+
*/
113+
function setPipelineVariable(name, value) {
114+
console.log(`##vso[task.setvariable variable=${name}]${value}`);
115+
}
116+
117+
(function main() {
118+
const {packRootArg, customRootArg, latestRootArg} = ensureArgs();
119+
120+
const repoRoot = process.env.BUILD_SOURCESDIRECTORY || process.cwd();
121+
const packRoot = path.resolve(packRootArg);
122+
const customRoot = path.resolve(customRootArg);
123+
const latestRoot = path.resolve(latestRootArg);
124+
125+
fs.mkdirSync(customRoot, {recursive: true});
126+
fs.mkdirSync(latestRoot, {recursive: true});
127+
128+
/** @type {string | null} */
129+
let customTag = null;
130+
try {
131+
const vnextPackageJson = /** @type {PackageJson} */ (
132+
readJson(path.join(repoRoot, 'vnext', 'package.json'))
133+
);
134+
const tagFromVnext = vnextPackageJson?.beachball?.defaultNpmTag;
135+
if (tagFromVnext && tagFromVnext !== 'latest') {
136+
customTag = tagFromVnext;
137+
}
138+
} catch (e) {
139+
console.warn('Unable to read vnext/package.json to determine custom tag.');
140+
}
141+
142+
/** @type {string[]} */
143+
const tarballs = fs.existsSync(packRoot)
144+
? fs.readdirSync(packRoot).filter(file => file.endsWith('.tgz'))
145+
: [];
146+
147+
if (!tarballs.length) {
148+
setPipelineVariable('NpmCustomTag', customTag || '');
149+
setPipelineVariable('NpmCustomFolder', customRoot);
150+
setPipelineVariable('NpmCustomFolderHasContent', 'false');
151+
setPipelineVariable('NpmLatestFolder', latestRoot);
152+
setPipelineVariable('NpmLatestFolderHasContent', 'false');
153+
return;
154+
}
155+
156+
/** @type {Set<string>} */
157+
const customTarballs = new Set();
158+
159+
if (customTag) {
160+
for (const packageJsonPath of findPackageJsons(repoRoot)) {
161+
/** @type {PackageJson | undefined} */
162+
let pkg;
163+
try {
164+
pkg = /** @type {PackageJson} */ (readJson(packageJsonPath));
165+
} catch (e) {
166+
continue;
167+
}
168+
169+
if (!pkg?.name || !pkg?.version) {
170+
continue;
171+
}
172+
173+
const pkgTag = pkg?.beachball?.defaultNpmTag;
174+
if (pkgTag === customTag && pkg.private !== true) {
175+
customTarballs.add(sanitizedTarballName(pkg.name, pkg.version));
176+
}
177+
}
178+
}
179+
180+
let customCount = 0;
181+
let latestCount = 0;
182+
183+
for (const tarball of tarballs) {
184+
const sourcePath = path.join(packRoot, tarball);
185+
const normalizedName = normalizePackedTarballName(tarball);
186+
const destinationRoot = customTag && customTarballs.has(normalizedName) ? customRoot : latestRoot;
187+
const destinationPath = path.join(destinationRoot, tarball);
188+
fs.mkdirSync(path.dirname(destinationPath), {recursive: true});
189+
fs.renameSync(sourcePath, destinationPath);
190+
if (destinationRoot === customRoot) {
191+
customCount++;
192+
} else {
193+
latestCount++;
194+
}
195+
}
196+
197+
setPipelineVariable('NpmCustomTag', customTag || '');
198+
setPipelineVariable('NpmCustomFolder', customRoot);
199+
setPipelineVariable('NpmCustomFolderHasContent', customCount ? 'true' : 'false');
200+
setPipelineVariable('NpmLatestFolder', latestRoot);
201+
setPipelineVariable('NpmLatestFolderHasContent', latestCount ? 'true' : 'false');
202+
})();

0 commit comments

Comments
 (0)