|
| 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)' |
0 commit comments