Skip to content

Commit 62d740d

Browse files
Add ESRP-based npm release pipeline; make CI publish PR-only (#1187)
- Add azure-pipelines-release.yml: new release pipeline that builds, tests, packs, and publishes azure-pipelines-task-lib to npm via the EsrpRelease@12 task (Build / Package / PublishToNpmViaESRP stages). - Route all agent-side npm operations through the internal ADO feed (.npmrc + NpmAuthenticate@0); ESRP performs the npmjs publish out-of-band, so the agent never contacts the public registry. - Remove the old npm publish block from azure-pipelines-steps-node.yml so CI is build/test only (PR builds). - Remove the npm-tokens variable group from azure-pipelines.yml (only the old publish step used it). - Add docs/esrp-npm-release.md: onboarding, required variables, service connection prerequisites, and troubleshooting. Co-authored-by: Tarun Ramsinghani <tarunramsinghani@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: adeae005-934b-42dd-ae33-4ea3e7992002
1 parent 54bbefd commit 62d740d

4 files changed

Lines changed: 479 additions & 14 deletions

File tree

azure-pipelines-release.yml

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
# =============================================================================
2+
# Release pipeline: publish azure-pipelines-task-lib to npmjs.org via ESRP
3+
# =============================================================================
4+
#
5+
# This pipeline builds, tests, packs, and RELEASES the npm package using the
6+
# ESRP Release (EsrpRelease) task instead of `npm publish`.
7+
#
8+
# HOW ESRP PUBLISHING WORKS (read before editing):
9+
# * npm package publication is performed by the ESRP Release task, NOT by
10+
# `npm publish`. ESRP is Microsoft's managed release service (adds signing,
11+
# provenance, and an approval/notification workflow).
12+
# * ESRP only publishes PRE-BUILT .tgz package files. It does NOT run
13+
# `npm pack`. This pipeline runs `npm pack` (in the Package stage) and hands
14+
# ESRP a folder of .tgz files; ESRP just distributes them to npmjs.org.
15+
# * The npm dist-tag can be inferred by ESRP from the package's
16+
# `publishConfig.tag` field in package.json. When it is not set, ESRP
17+
# publishes under the default `latest` tag. (This package has no
18+
# publishConfig, so releases go out as `latest`.)
19+
#
20+
# Stages:
21+
# 1. Build - npm ci + npm run build + npm test; publish _build.
22+
# 2. Package - npm pack -> stage .tgz as the 'npm-packages' artifact.
23+
# 3. PublishToNpmViaESRP - validate + hand the .tgz files to EsrpRelease@12.
24+
#
25+
# Trigger: MANUAL only. PR/CI validation lives in azure-pipelines.yml; this
26+
# pipeline never runs on PRs or automatically on push.
27+
#
28+
# Onboarding, required variables, and troubleshooting: see
29+
# docs/esrp-npm-release.md
30+
# =============================================================================
31+
32+
name: $(Date:yyyyMMdd)$(Rev:.r)
33+
34+
trigger: none
35+
pr: none
36+
37+
parameters:
38+
# Safety switch: when true, the pipeline builds and packs the .tgz but SKIPS
39+
# the ESRP publish stage entirely. Use it to validate a release candidate
40+
# without pushing anything to npmjs.org.
41+
- name: dryRun
42+
displayName: 'Dry run (build + pack only; skip ESRP publish)'
43+
type: boolean
44+
default: false
45+
46+
variables:
47+
- name: nodeVersion
48+
value: '16.13.0'
49+
50+
# ---------------------------------------------------------------------------
51+
# ESRP wiring. The per-environment/identity values live in a variable group
52+
# so nothing environment-specific is hard-coded here. Create the group in
53+
# Pipelines > Library named 'esrp-npm-release' with these variables:
54+
# EsrpServiceConnection - name of the ESRP AzureRM (WIF/OIDC) service connection
55+
# EsrpKeyVault - Key Vault holding the ESRP signing certificate
56+
# EsrpSignCert - signing certificate name in that Key Vault
57+
# EsrpClientId - client id of the ESRP-approved publisher identity
58+
# EsrpOwners - comma-separated notification owner emails
59+
# EsrpApprovers - comma-separated notification approver emails
60+
# See docs/esrp-npm-release.md for how to obtain each value.
61+
# ---------------------------------------------------------------------------
62+
- group: esrp-npm-release
63+
64+
# Non-secret ESRP constants for public-npm (OSS) publishing. These are the
65+
# well-known values for the ESRPRELPACMAN OSS publisher; override in the
66+
# variable group only if your ESRP onboarding differs.
67+
- name: EsrpMainPublisher
68+
value: 'ESRPRELPACMAN'
69+
- name: EsrpServiceEndpointUrl
70+
value: 'https://api.esrp.microsoft.com'
71+
# Tenant id for the ESRPRELPACMAN OSS publisher.
72+
- name: EsrpDomainTenantId
73+
value: '975f013f-7f24-47e8-a7d3-abc4752bf346'
74+
75+
# The ESRP release runs in the mseng org and must be produced by the 1ES
76+
# Official pipeline template (same as the CI pipeline in azure-pipelines.yml).
77+
resources:
78+
repositories:
79+
- repository: 1ESPipelineTemplates
80+
type: git
81+
name: 1ESPipelineTemplates/1ESPipelineTemplates
82+
ref: refs/tags/release
83+
84+
extends:
85+
template: v1/1ES.Official.PipelineTemplate.yml@1ESPipelineTemplates
86+
parameters:
87+
settings:
88+
networkIsolationMode: Audit
89+
sdl:
90+
sourceAnalysisPool:
91+
name: 1ES-ABTT-Shared-Pool
92+
image: abtt-windows-2025
93+
os: windows
94+
stages:
95+
96+
# =======================================================================
97+
# Stage 1: Build - install, compile, and test the package.
98+
# Publishes the compiled output (node/_build) so the Package stage can
99+
# pack it without rebuilding.
100+
# =======================================================================
101+
- stage: Build
102+
displayName: Build and test
103+
jobs:
104+
- job: build
105+
displayName: Build & test task-lib
106+
pool:
107+
name: 1ES-ABTT-Shared-Pool
108+
image: abtt-ubuntu-2404
109+
os: linux
110+
templateContext:
111+
outputs:
112+
- output: pipelineArtifact
113+
displayName: 'Publish build output'
114+
targetPath: node/_build
115+
artifactName: build-output
116+
steps:
117+
- task: NodeTool@0
118+
displayName: 'Use Node $(nodeVersion)'
119+
inputs:
120+
versionSpec: $(nodeVersion)
121+
# Authenticate to the internal npm feed referenced by .npmrc so
122+
# `npm ci` can restore dependencies.
123+
- task: NpmAuthenticate@0
124+
displayName: 'Authenticate npm (.npmrc)'
125+
inputs:
126+
workingFile: .npmrc
127+
- script: npm ci
128+
displayName: 'npm ci'
129+
workingDirectory: node
130+
- script: npm run build
131+
displayName: 'npm run build'
132+
workingDirectory: node
133+
- script: npm test
134+
displayName: 'npm test'
135+
workingDirectory: node
136+
137+
# =======================================================================
138+
# Stage 2: Package - produce the .tgz via `npm pack`.
139+
# IMPORTANT: `npm pack` runs HERE, not in ESRP. ESRP only distributes the
140+
# pre-built .tgz produced in this stage.
141+
# =======================================================================
142+
- stage: Package
143+
displayName: Package (npm pack)
144+
dependsOn: Build
145+
jobs:
146+
- job: pack
147+
displayName: Create .tgz via npm pack
148+
pool:
149+
name: 1ES-ABTT-Shared-Pool
150+
image: abtt-ubuntu-2404
151+
os: linux
152+
templateContext:
153+
inputs:
154+
# Download the compiled package output from the Build stage.
155+
# This is the same directory the old pipeline ran `npm publish`
156+
# from (node/_build): it contains package.json and the compiled
157+
# .js/.d.ts, README, LICENSE, and Strings.
158+
- input: pipelineArtifact
159+
artifactName: build-output
160+
targetPath: $(Pipeline.Workspace)/build-output
161+
outputs:
162+
- output: pipelineArtifact
163+
displayName: 'Publish npm package (.tgz)'
164+
targetPath: $(Build.ArtifactStagingDirectory)/npm-packages
165+
artifactName: npm-packages
166+
steps:
167+
- task: NodeTool@0
168+
displayName: 'Use Node $(nodeVersion)'
169+
inputs:
170+
versionSpec: $(nodeVersion)
171+
172+
# Guard: ESRP (and npm) cannot publish a package marked private.
173+
# Fail early and clearly if package.json has "private": true.
174+
- script: |
175+
node -e "const p=require('./package.json'); if (p.private===true){console.error('ERROR: package.json is marked private; refusing to publish.');process.exit(1);} console.log('OK: '+p.name+'@'+p.version+' is publishable (private is not true).');"
176+
displayName: 'Validate package.json is publishable'
177+
workingDirectory: $(Pipeline.Workspace)/build-output
178+
179+
# Produce the .tgz that ESRP will publish. ESRP does not pack; we
180+
# stage the tarball into $(Build.ArtifactStagingDirectory)/npm-packages.
181+
- script: |
182+
set -e
183+
mkdir -p "$(Build.ArtifactStagingDirectory)/npm-packages"
184+
npm pack --pack-destination "$(Build.ArtifactStagingDirectory)/npm-packages"
185+
echo "Packed contents:"
186+
ls -l "$(Build.ArtifactStagingDirectory)/npm-packages"
187+
displayName: 'npm pack -> staging'
188+
workingDirectory: $(Pipeline.Workspace)/build-output
189+
190+
# Fail the release if npm pack produced no package files.
191+
- script: |
192+
count=$(ls -1 "$(Build.ArtifactStagingDirectory)/npm-packages"/*.tgz 2>/dev/null | wc -l)
193+
echo "Found $count .tgz file(s)."
194+
if [ "$count" -eq 0 ]; then
195+
echo "ERROR: npm pack produced no .tgz files; nothing to release."
196+
exit 1
197+
fi
198+
displayName: 'Verify at least one .tgz exists'
199+
200+
# =======================================================================
201+
# Stage 3: PublishToNpmViaESRP - hand the .tgz files to ESRP Release.
202+
# Skipped entirely when the pipeline is queued with dryRun = true.
203+
# =======================================================================
204+
- ${{ if eq(parameters.dryRun, false) }}:
205+
- stage: PublishToNpmViaESRP
206+
displayName: Publish to npm via ESRP
207+
dependsOn: Package
208+
jobs:
209+
- job: esrp
210+
displayName: ESRP Release (npm)
211+
pool:
212+
name: 1ES-ABTT-Shared-Pool
213+
image: abtt-windows-2025
214+
os: windows
215+
templateContext:
216+
# 1ES: mark this as a production release job. Required for a job
217+
# that publishes externally (via ESRP) so 1ES treats it as an
218+
# official, isProduction release.
219+
type: releaseJob
220+
isProduction: true
221+
inputs:
222+
- input: pipelineArtifact
223+
artifactName: npm-packages
224+
targetPath: $(Pipeline.Workspace)/npm-packages
225+
steps:
226+
- task: NodeTool@0
227+
displayName: 'Use Node $(nodeVersion)'
228+
inputs:
229+
versionSpec: $(nodeVersion)
230+
231+
# Authenticate to the internal ADO npm feed defined in .npmrc.
232+
# This agent CANNOT reach the public npm registry, so the
233+
# idempotency check below queries the ADO feed, never npmjs.
234+
- task: NpmAuthenticate@0
235+
displayName: 'Authenticate to ADO npm feed (.npmrc)'
236+
inputs:
237+
workingFile: .npmrc
238+
239+
# Final guard before invoking ESRP: at least one .tgz must be
240+
# present, otherwise fail the release rather than submit an
241+
# empty ESRP request.
242+
- pwsh: |
243+
$pkgs = @(Get-ChildItem "$(Pipeline.Workspace)/npm-packages" -Filter *.tgz -ErrorAction SilentlyContinue)
244+
Write-Host "Found $($pkgs.Count) .tgz file(s) to release:"
245+
$pkgs | ForEach-Object { Write-Host " - $($_.Name)" }
246+
if ($pkgs.Count -eq 0) {
247+
Write-Error "No .tgz package files found; failing the release."
248+
exit 1
249+
}
250+
displayName: 'Verify packages exist before ESRP'
251+
252+
# Idempotency: skip ESRP if this exact name@version is already
253+
# available. Preserves the old pipeline's tolerant
254+
# "npm publish || true" behavior so a re-run for an already-
255+
# published version is a no-op instead of a hard failure.
256+
#
257+
# IMPORTANT: this agent cannot reach the public npm registry, so
258+
# the lookup goes through the internal ADO feed configured in the
259+
# repo .npmrc (which proxies npmjs upstream). Running npm from the
260+
# sources directory makes it pick up that .npmrc for both the
261+
# registry URL and the auth token injected by NpmAuthenticate.
262+
- pwsh: |
263+
$pkg = Get-ChildItem "$(Pipeline.Workspace)/npm-packages" -Filter *.tgz | Select-Object -First 1
264+
# Read name+version straight from the tarball's package.json.
265+
$json = tar -xOf $pkg.FullName package/package.json | ConvertFrom-Json
266+
$id = "$($json.name)@$($json.version)"
267+
Write-Host "Checking ADO feed for $id ..."
268+
$published = & npm view $id version 2>$null
269+
$exit = $LASTEXITCODE
270+
# npm view returns non-zero (E404) when the version is not
271+
# found; reset so this lookup does not fail the step.
272+
$global:LASTEXITCODE = 0
273+
if ($exit -eq 0 -and -not [string]::IsNullOrWhiteSpace($published)) {
274+
Write-Host "$id already available on the ADO feed; skipping ESRP release (idempotent re-run)."
275+
Write-Host "##vso[task.setvariable variable=npmAlreadyPublished]true"
276+
} else {
277+
Write-Host "$id not found on the ADO feed; will publish."
278+
Write-Host "##vso[task.setvariable variable=npmAlreadyPublished]false"
279+
}
280+
workingDirectory: $(Build.SourcesDirectory)
281+
displayName: 'Check whether version already published (ADO feed)'
282+
283+
# ESRP Release: performs the ACTUAL publish to npmjs.org.
284+
# intent=PackageDistribution + contenttype=npm -> npm publish
285+
# contentsource=Folder + folderlocation -> folder of .tgz
286+
# waitforreleasecompletion=true -> block until done
287+
# ESRP does NOT run npm pack; it distributes the .tgz staged above.
288+
# The dist-tag is inferred from publishConfig.tag (default latest).
289+
# NOTE: EsrpRelease@12 also accepts `productstate: <tag>` to pin the
290+
# npm dist-tag explicitly (e.g. 'latest' or 'beta'); we intentionally
291+
# omit it and let publishConfig.tag drive the tag per requirements.
292+
- task: EsrpRelease@12
293+
displayName: 'Publish package to npm via ESRP'
294+
condition: and(succeeded(), ne(variables['npmAlreadyPublished'], 'true'))
295+
inputs:
296+
connectedservicename: '$(EsrpServiceConnection)'
297+
usemanagedidentity: true
298+
keyvaultname: '$(EsrpKeyVault)'
299+
signcertname: '$(EsrpSignCert)'
300+
clientid: '$(EsrpClientId)'
301+
intent: 'PackageDistribution'
302+
contenttype: 'npm'
303+
contentsource: 'Folder'
304+
folderlocation: '$(Pipeline.Workspace)/npm-packages'
305+
waitforreleasecompletion: true
306+
owners: '$(EsrpOwners)'
307+
approvers: '$(EsrpApprovers)'
308+
serviceendpointurl: '$(EsrpServiceEndpointUrl)'
309+
mainpublisher: '$(EsrpMainPublisher)'
310+
domaintenantid: '$(EsrpDomainTenantId)'

azure-pipelines-steps-node.yml

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,19 +20,14 @@ steps:
2020
workingDirectory: node
2121
displayName: (task-lib) npm test
2222

23-
# Only on Linux. For CI runs on master, automatically publish packages
24-
- ${{ if eq(parameters.os, 'Linux') }}:
25-
- bash: |
26-
echo //registry.npmjs.org/:_authToken=\${NPM_TOKEN} > .npmrc
27-
npm publish || true # Ignore publish failures, usually will happen because package already exists
28-
displayName: (task-lib) npm publish
29-
workingDirectory: node/_build
30-
condition: and(succeeded(), in(variables['build.reason'], 'IndividualCI', 'BatchedCI', 'Manual'), in(variables['build.sourcebranch'], 'refs/heads/master'))
31-
env:
32-
NPM_TOKEN: $(npm-automation.token)
33-
34-
# PublishPipelineArtifact step is configured in the base template.
35-
# See the templateContext section in the azure-pipelines.yml file
23+
# NOTE: npm publishing has been removed from this CI template.
24+
# This template is now used for PR/CI validation only (build + test).
25+
# Publishing the package to npmjs.org is handled by the dedicated release
26+
# pipeline (azure-pipelines-release.yml) via the ESRP Release task.
27+
#
28+
# The Linux CI job still publishes the built package as the 'npm-package'
29+
# pipeline artifact through the base template's templateContext section
30+
# (see azure-pipelines.yml).
3631

3732
# Only on Windows. Build VstsTaskSdk for powershell
3833
- ${{ if eq(parameters.os, 'Windows_NT') }}:

azure-pipelines.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ trigger:
77
- releases/*
88

99
variables:
10-
- group: npm-tokens
10+
# npm publishing has moved to the dedicated ESRP release pipeline
11+
# (azure-pipelines-release.yml), so the npm-tokens variable group and the
12+
# npm-automation.token secret are no longer consumed by CI.
1113
- name: nodeVersion
1214
value: '16.13.0'
1315
- name: nodeVersionForPowershell

0 commit comments

Comments
 (0)