Skip to content

Commit 5cce9cf

Browse files
authored
Enable ESRP Release process for NPM packages (#15405)
1 parent 97efdc5 commit 5cce9cf

File tree

2 files changed

+268
-11
lines changed

2 files changed

+268
-11
lines changed

.ado/publish.yml

Lines changed: 66 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,6 @@ parameters:
134134
variables:
135135
- template: variables/windows.yml
136136
- group: RNW Secrets
137-
- name: SkipNpmPublishArgs
138-
value: ''
139137
- name: SkipGitPushPublishArgs
140138
value: ''
141139
- name: FailCGOnAlert
@@ -146,6 +144,8 @@ variables:
146144
value: microsoft
147145
- name: ArtifactServices.Symbol.PAT
148146
value: $(pat-symbols-publish-microsoft)
147+
- name: SourceBranchWithFolders
148+
value: $[ replace(variables['Build.SourceBranch'], 'refs/heads/', '') ]
149149

150150
trigger: none
151151
pr: none
@@ -225,21 +225,71 @@ extends:
225225

226226
- template: .ado/templates/configure-git.yml@self
227227

228-
- script: |
229-
echo "##vso[task.setvariable variable=SkipNpmPublishArgs]--no-publish"
230-
displayName: Enable No-Publish (npm)
231-
condition: ${{ parameters.skipNpmPublish }}
232-
233-
- script: |
234-
echo "##vso[task.setvariable variable=SkipGitPushPublishArgs]--no-push"
228+
- pwsh: |
229+
Write-Host "##vso[task.setvariable variable=SkipGitPushPublishArgs]--no-push"
235230
displayName: Enable No-Publish (git)
236231
condition: ${{ parameters.skipGitPush }}
237232
238-
- script: npx beachball publish $(SkipNpmPublishArgs) $(SkipGitPushPublishArgs) --branch origin/$(Build.SourceBranchName) -n $(npmAuthToken) -yes --bump-deps --verbose --access public --message "applying package updates ***NO_CI***"
233+
# Beachball publishes NPM packages to the "$(Pipeline.Workspace)\published-packages" folder.
234+
# It pushes NPM version updates to Git depending on the SkipGitPushPublishArgs variable derived from the skipGitPush parameter.
235+
- script: |
236+
if exist "$(Pipeline.Workspace)\published-packages" rd /s /q "$(Pipeline.Workspace)\published-packages"
237+
mkdir "$(Pipeline.Workspace)\published-packages"
238+
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***"
239239
displayName: Beachball Publish
240240
241+
- script: dir /s "$(Pipeline.Workspace)\published-packages"
242+
displayName: Show created npm packages
243+
244+
# Beachball usually takes care about the NPM package tagging based on the values in package.json files.
245+
# We use the ESRP Release where we must provide the tag explictly (the productstate parameter).
246+
# Fortunately, we just use two tags: latest and some custom tag like "canary", "v0.73-stable", etc.
247+
# The npmGroupByTag.js script groups the created NPM package by these two tags into the specified folders.
248+
- pwsh: |
249+
node .ado/scripts/npmGroupByTag.js "$(Pipeline.Workspace)\published-packages" "$(Pipeline.Workspace)\published-packages\custom-tag" "$(Pipeline.Workspace)\published-packages\latest-tag"
250+
displayName: Group npm packages by tag
251+
252+
- script: dir /s "$(Pipeline.Workspace)\published-packages"
253+
displayName: Show grouped npm packages by tag
254+
255+
# Publish NPM packages using ESRP Release task with the custom tag such as "canary", "v0.73-stable", etc.
256+
- task: 'SFP.release-tasks.custom-build-release-task.EsrpRelease@10'
257+
displayName: 'ESRP Release to npmjs.com (custom tag)'
258+
condition: and(succeeded(), ${{ not(parameters.skipNpmPublish) }}, eq(variables['NpmCustomFolderHasContent'], 'true'))
259+
inputs:
260+
connectedservicename: 'ESRP-CodeSigning-OGX-JSHost-RNW'
261+
usemanagedidentity: false
262+
keyvaultname: 'OGX-JSHost-KV'
263+
authcertname: 'OGX-JSHost-Auth4'
264+
signcertname: 'OGX-JSHost-Sign3'
265+
clientid: '0a35e01f-eadf-420a-a2bf-def002ba898d'
266+
domaintenantid: 'cdc5aeea-15c5-4db6-b079-fcadd2505dc2'
267+
contenttype: npm
268+
folderlocation: '$(NpmCustomFolder)'
269+
productstate: '$(NpmCustomTag)'
270+
271+
approvers: '[email protected]'
272+
273+
# Publish NPM packages using ESRP Release task with the "latest" tag.
274+
- task: 'SFP.release-tasks.custom-build-release-task.EsrpRelease@10'
275+
displayName: 'ESRP Release to npmjs.com (latest)'
276+
condition: and(succeeded(), ${{ not(parameters.skipNpmPublish) }}, eq(variables['NpmLatestFolderHasContent'], 'true'))
277+
inputs:
278+
connectedservicename: 'ESRP-CodeSigning-OGX-JSHost-RNW'
279+
usemanagedidentity: false
280+
keyvaultname: 'OGX-JSHost-KV'
281+
authcertname: 'OGX-JSHost-Auth4'
282+
signcertname: 'OGX-JSHost-Sign3'
283+
clientid: '0a35e01f-eadf-420a-a2bf-def002ba898d'
284+
domaintenantid: 'cdc5aeea-15c5-4db6-b079-fcadd2505dc2'
285+
contenttype: npm
286+
folderlocation: '$(NpmLatestFolder)'
287+
productstate: 'latest'
288+
289+
approvers: '[email protected]'
290+
241291
# Beachball reverts to local state after publish, but we want the updates it added
242-
- script: git pull origin ${{ variables['Build.SourceBranchName'] }}
292+
- script: git pull origin $(SourceBranchWithFolders)
243293
displayName: git pull
244294

245295
- script: npx @rnw-scripts/create-github-releases --yes --authToken $(githubAuthToken)
@@ -261,6 +311,11 @@ extends:
261311

262312
templateContext:
263313
outputs:
314+
- output: pipelineArtifact
315+
displayName: 'Publish npm pack artifacts'
316+
condition: succeededOrFailed()
317+
targetPath: $(Pipeline.Workspace)/published-packages
318+
artifactName: NpmPackedTarballs
264319
- output: pipelineArtifact
265320
displayName: "📒 Publish Manifest Npm"
266321
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)