From e1d500234dc4408cec197ac6c7cc912b0be8dfd2 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Wed, 27 May 2026 19:12:53 +0200 Subject: [PATCH 01/25] feat: npm package build and release pipelines --- .azure-pipelines/build-npm-packages.yml | 235 ++++++++--- .azure-pipelines/build-npm-packages.yml.real | 173 +++++++++ .azure-pipelines/release-npm-packages.yml | 364 ++++++++++++++---- .../release-npm-packages.yml.real | 283 ++++++++++++++ 4 files changed, 923 insertions(+), 132 deletions(-) create mode 100644 .azure-pipelines/build-npm-packages.yml.real create mode 100644 .azure-pipelines/release-npm-packages.yml.real diff --git a/.azure-pipelines/build-npm-packages.yml b/.azure-pipelines/build-npm-packages.yml index 2581eccf9..5e56aff42 100644 --- a/.azure-pipelines/build-npm-packages.yml +++ b/.azure-pipelines/build-npm-packages.yml @@ -1,62 +1,173 @@ -# Dummy build pipeline for configuration validation -# Replace with build-npm-packages.yml once ADO pipeline is configured - -trigger: none -pr: none - -parameters: - - name: "debug" - displayName: "Enable debug output" - type: boolean - default: false - -variables: - CDP_DEFINITION_BUILD_COUNT: $[counter('', 0)] - system.debug: ${{ parameters.debug }} - WindowsContainerImage: "onebranch.azurecr.io/windows/ltsc2022/vse2022:latest" - -resources: - repositories: - - repository: templates - type: git - name: OneBranch.Pipelines/GovernedTemplates - ref: refs/heads/main - -extends: - template: v2/OneBranch.Official.CrossPlat.yml@templates - parameters: - cloudvault: - enabled: false - globalSdl: - asyncSdl: - enabled: false - tsa: - enabled: false - credscan: - suppressionsFile: $(Build.SourcesDirectory)/.azure-pipelines/compliance/CredScanSuppressions.json - policheck: - break: true - suppression: - suppressionFile: $(Build.SourcesDirectory)/.config/guardian/.gdnsuppress - codeql: - compiled: - enabled: false - tsaEnabled: false - componentgovernance: - ignoreDirectories: $(Build.SourcesDirectory)/.vscode-test - featureFlags: - linuxEsrpSigning: true - WindowsHostVersion: - Version: 2022 - - stages: - - stage: BuildStage - jobs: - - job: Main - pool: - type: windows - variables: - ob_outputDirectory: '$(Build.ArtifactStagingDirectory)\build' - steps: - - pwsh: Write-Output "Dummy build pipeline - configuration validated successfully" - displayName: "\U00002705 Dummy step" +# Build pipeline for npm packages in the packages/ folder +# Produces .tgz archives that can be published via the npm release pipeline + +trigger: + - next + - main + +# Disable PR trigger +pr: none + +parameters: + - name: 'debug' + displayName: 'Enable debug output' + type: boolean + default: false + - name: isOfficialBuild + displayName: 'Official Build' + type: boolean + default: true + +variables: + CDP_DEFINITION_BUILD_COUNT: $[counter('', 0)] # needed for onebranch.pipeline.version task https://aka.ms/obpipelines/versioning + system.debug: ${{ parameters.debug }} + + WindowsContainerImage: 'onebranch.azurecr.io/windows/ltsc2022/vse2022:latest' # Docker image which is used to build the project https://aka.ms/obpipelines/containers + +resources: + repositories: + - repository: templates + type: git + name: OneBranch.Pipelines/GovernedTemplates + ref: refs/heads/main + +extends: + ${{ if eq(parameters.isOfficialBuild, true) }}: + template: v2/OneBranch.Official.CrossPlat.yml@templates # https://aka.ms/obpipelines/templates + ${{ else }}: + template: v2/OneBranch.NonOfficial.CrossPlat.yml@templates # https://aka.ms/obpipelines/templates + parameters: + cloudvault: # https://aka.ms/obpipelines/cloudvault + enabled: false + globalSdl: # https://aka.ms/obpipelines/sdl + asyncSdl: + enabled: false + tsa: + enabled: false + credscan: + suppressionsFile: $(Build.SourcesDirectory)/.azure-pipelines/compliance/CredScanSuppressions.json + policheck: + break: true + suppression: + suppressionFile: $(Build.SourcesDirectory)/.config/guardian/.gdnsuppress + codeql: + compiled: + enabled: false + tsaEnabled: false + componentgovernance: + ignoreDirectories: $(Build.SourcesDirectory)/.vscode-test + featureFlags: + linuxEsrpSigning: true + WindowsHostVersion: + Version: 2022 + + stages: + - stage: BuildStage + jobs: + - job: Main + pool: + type: windows + variables: + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)\build' # this directory is uploaded to pipeline artifacts. More info at https://aka.ms/obpipelines/artifacts + ob_sdl_codeSignValidation_excludes: '-|**\*.json;-|**\*.js;-|**\node_modules\**;' + steps: + - task: ComponentGovernanceComponentDetection@0 + displayName: 'Component Governance - Component Detection' + + - task: NodeTool@0 + displayName: "\U0001F449 Use Node.js" + inputs: + versionSource: fromFile + versionFilePath: .nvmrc + + - task: npmAuthenticate@0 + displayName: "\U0001F449 Authenticate to npm registry" + inputs: + workingFile: '$(Build.SourcesDirectory)/.azure-pipelines/.npmrc' + + - task: Npm@1 + displayName: "\U0001F449 Install Dependencies" + condition: succeeded() + inputs: + command: custom + customCommand: ci --userconfig $(Build.SourcesDirectory)/.azure-pipelines/.npmrc + workingDir: $(Build.SourcesDirectory) + + - task: Npm@1 + displayName: "\U0001F449 Build Workspace Packages" + condition: succeeded() + inputs: + command: custom + customCommand: run build --workspaces --if-present + workingDir: $(Build.SourcesDirectory) + + - task: Npm@1 + displayName: "\U0001F449 Test Workspace Packages" + condition: succeeded() + inputs: + command: custom + customCommand: run test --workspaces --if-present + workingDir: $(Build.SourcesDirectory) + + - task: PowerShell@2 + displayName: "\U0001F4E6 Pack npm packages" + condition: succeeded() + inputs: + targetType: 'inline' + script: | + $outputDir = "$(ob_outputDirectory)\npm-packages" + New-Item -ItemType Directory -Force -Path $outputDir | Out-Null + + Write-Output "Packing npm workspace packages..." + Write-Output "" + + $packageDirs = Get-ChildItem -Path "$(Build.SourcesDirectory)\packages" -Directory + + foreach ($dir in $packageDirs) { + $packageJsonPath = Join-Path $dir.FullName "package.json" + if (Test-Path $packageJsonPath) { + $packageJson = Get-Content $packageJsonPath | ConvertFrom-Json + $pkgName = $packageJson.name + $pkgVersion = $packageJson.version + + if ($packageJson.private -eq $true) { + Write-Warning "Skipping private package: $pkgName" + continue + } + + Write-Output "Packing $pkgName@$pkgVersion from $($dir.Name)..." + npm pack --pack-destination $outputDir --workspace "packages/$($dir.Name)" + + if ($LASTEXITCODE -ne 0) { + Write-Error "[Error] Failed to pack $pkgName" + exit 1 + } + + Write-Output "✓ Packed $pkgName@$pkgVersion" + Write-Output "" + } + } + + Write-Output "=== Generated .tgz files ===" + Get-ChildItem -Path $outputDir -Filter "*.tgz" | ForEach-Object { + $sizeKB = [math]::Round($_.Length / 1KB, 2) + Write-Output " $($_.Name) (${sizeKB} KB)" + } + + $tgzCount = (Get-ChildItem -Path $outputDir -Filter "*.tgz").Count + Write-Output "" + Write-Output "Total packages: $tgzCount" + + if ($tgzCount -eq 0) { + Write-Error "[Error] No .tgz files were generated" + exit 1 + } + + - task: CopyFiles@2 + displayName: "\U0001F449 Copy package metadata to staging" + condition: succeeded() + inputs: + Contents: | + packages/*/package.json + !**/node_modules/** + TargetFolder: $(ob_outputDirectory) diff --git a/.azure-pipelines/build-npm-packages.yml.real b/.azure-pipelines/build-npm-packages.yml.real new file mode 100644 index 000000000..5e56aff42 --- /dev/null +++ b/.azure-pipelines/build-npm-packages.yml.real @@ -0,0 +1,173 @@ +# Build pipeline for npm packages in the packages/ folder +# Produces .tgz archives that can be published via the npm release pipeline + +trigger: + - next + - main + +# Disable PR trigger +pr: none + +parameters: + - name: 'debug' + displayName: 'Enable debug output' + type: boolean + default: false + - name: isOfficialBuild + displayName: 'Official Build' + type: boolean + default: true + +variables: + CDP_DEFINITION_BUILD_COUNT: $[counter('', 0)] # needed for onebranch.pipeline.version task https://aka.ms/obpipelines/versioning + system.debug: ${{ parameters.debug }} + + WindowsContainerImage: 'onebranch.azurecr.io/windows/ltsc2022/vse2022:latest' # Docker image which is used to build the project https://aka.ms/obpipelines/containers + +resources: + repositories: + - repository: templates + type: git + name: OneBranch.Pipelines/GovernedTemplates + ref: refs/heads/main + +extends: + ${{ if eq(parameters.isOfficialBuild, true) }}: + template: v2/OneBranch.Official.CrossPlat.yml@templates # https://aka.ms/obpipelines/templates + ${{ else }}: + template: v2/OneBranch.NonOfficial.CrossPlat.yml@templates # https://aka.ms/obpipelines/templates + parameters: + cloudvault: # https://aka.ms/obpipelines/cloudvault + enabled: false + globalSdl: # https://aka.ms/obpipelines/sdl + asyncSdl: + enabled: false + tsa: + enabled: false + credscan: + suppressionsFile: $(Build.SourcesDirectory)/.azure-pipelines/compliance/CredScanSuppressions.json + policheck: + break: true + suppression: + suppressionFile: $(Build.SourcesDirectory)/.config/guardian/.gdnsuppress + codeql: + compiled: + enabled: false + tsaEnabled: false + componentgovernance: + ignoreDirectories: $(Build.SourcesDirectory)/.vscode-test + featureFlags: + linuxEsrpSigning: true + WindowsHostVersion: + Version: 2022 + + stages: + - stage: BuildStage + jobs: + - job: Main + pool: + type: windows + variables: + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)\build' # this directory is uploaded to pipeline artifacts. More info at https://aka.ms/obpipelines/artifacts + ob_sdl_codeSignValidation_excludes: '-|**\*.json;-|**\*.js;-|**\node_modules\**;' + steps: + - task: ComponentGovernanceComponentDetection@0 + displayName: 'Component Governance - Component Detection' + + - task: NodeTool@0 + displayName: "\U0001F449 Use Node.js" + inputs: + versionSource: fromFile + versionFilePath: .nvmrc + + - task: npmAuthenticate@0 + displayName: "\U0001F449 Authenticate to npm registry" + inputs: + workingFile: '$(Build.SourcesDirectory)/.azure-pipelines/.npmrc' + + - task: Npm@1 + displayName: "\U0001F449 Install Dependencies" + condition: succeeded() + inputs: + command: custom + customCommand: ci --userconfig $(Build.SourcesDirectory)/.azure-pipelines/.npmrc + workingDir: $(Build.SourcesDirectory) + + - task: Npm@1 + displayName: "\U0001F449 Build Workspace Packages" + condition: succeeded() + inputs: + command: custom + customCommand: run build --workspaces --if-present + workingDir: $(Build.SourcesDirectory) + + - task: Npm@1 + displayName: "\U0001F449 Test Workspace Packages" + condition: succeeded() + inputs: + command: custom + customCommand: run test --workspaces --if-present + workingDir: $(Build.SourcesDirectory) + + - task: PowerShell@2 + displayName: "\U0001F4E6 Pack npm packages" + condition: succeeded() + inputs: + targetType: 'inline' + script: | + $outputDir = "$(ob_outputDirectory)\npm-packages" + New-Item -ItemType Directory -Force -Path $outputDir | Out-Null + + Write-Output "Packing npm workspace packages..." + Write-Output "" + + $packageDirs = Get-ChildItem -Path "$(Build.SourcesDirectory)\packages" -Directory + + foreach ($dir in $packageDirs) { + $packageJsonPath = Join-Path $dir.FullName "package.json" + if (Test-Path $packageJsonPath) { + $packageJson = Get-Content $packageJsonPath | ConvertFrom-Json + $pkgName = $packageJson.name + $pkgVersion = $packageJson.version + + if ($packageJson.private -eq $true) { + Write-Warning "Skipping private package: $pkgName" + continue + } + + Write-Output "Packing $pkgName@$pkgVersion from $($dir.Name)..." + npm pack --pack-destination $outputDir --workspace "packages/$($dir.Name)" + + if ($LASTEXITCODE -ne 0) { + Write-Error "[Error] Failed to pack $pkgName" + exit 1 + } + + Write-Output "✓ Packed $pkgName@$pkgVersion" + Write-Output "" + } + } + + Write-Output "=== Generated .tgz files ===" + Get-ChildItem -Path $outputDir -Filter "*.tgz" | ForEach-Object { + $sizeKB = [math]::Round($_.Length / 1KB, 2) + Write-Output " $($_.Name) (${sizeKB} KB)" + } + + $tgzCount = (Get-ChildItem -Path $outputDir -Filter "*.tgz").Count + Write-Output "" + Write-Output "Total packages: $tgzCount" + + if ($tgzCount -eq 0) { + Write-Error "[Error] No .tgz files were generated" + exit 1 + } + + - task: CopyFiles@2 + displayName: "\U0001F449 Copy package metadata to staging" + condition: succeeded() + inputs: + Contents: | + packages/*/package.json + !**/node_modules/** + TargetFolder: $(ob_outputDirectory) diff --git a/.azure-pipelines/release-npm-packages.yml b/.azure-pipelines/release-npm-packages.yml index 46d1e34cf..bc2ef56bd 100644 --- a/.azure-pipelines/release-npm-packages.yml +++ b/.azure-pipelines/release-npm-packages.yml @@ -1,70 +1,294 @@ -# Dummy release pipeline for configuration validation -# Replace with release-npm-packages.yml once ADO pipeline is configured - -trigger: none -pr: none - -parameters: - - name: "debug" - displayName: "Enable debug output" - type: boolean - default: false - -variables: - CDP_DEFINITION_BUILD_COUNT: $[counter('', 0)] - system.debug: ${{ parameters.debug }} - TeamName: "Desktop Tools" - WindowsContainerImage: "onebranch.azurecr.io/windows/ltsc2022/vse2022:latest" - -resources: - repositories: - - repository: templates - type: git - name: OneBranch.Pipelines/GovernedTemplates - ref: refs/heads/main - -extends: - template: v2/OneBranch.Official.CrossPlat.yml@templates - parameters: - cloudvault: - enabled: false - globalSdl: - asyncSdl: - enabled: false - tsa: - enabled: false - credscan: - suppressionsFile: $(Build.SourcesDirectory)/.azure-pipelines/compliance/CredScanSuppressions.json - policheck: - break: true - suppression: - suppressionFile: $(Build.SourcesDirectory)/.config/guardian/.gdnsuppress - codeql: - compiled: - enabled: false - tsaEnabled: false - componentgovernance: - ignoreDirectories: $(Build.SourcesDirectory)/.vscode-test - featureFlags: - linuxEsrpSigning: true - WindowsHostVersion: - Version: 2022 - - release: - category: NonAzure - - stages: - - stage: Release - displayName: Dummy Release - variables: - - name: ob_release_environment - value: Test - jobs: - - job: Main - pool: - type: release - variables: - ob_outputDirectory: "$(Build.ArtifactStagingDirectory)" - steps: - - pwsh: Write-Output "Dummy release pipeline - configuration validated successfully" - displayName: "\U00002705 Dummy step" +# Release pipeline for npm packages +# Publishes a selected package to npmjs.org via ESRP Release +# +# Prerequisites: +# 1. Install the "ESRP Release" ADO extension in your project +# 2. Configure an ESRP service connection with certificate-based auth +# 3. Set the pipeline variables listed in the TODO comments below +# (or use a variable group linked to this pipeline) + +trigger: none +pr: none + +parameters: + # Which package to publish (directory name under packages/) + - name: packageName + displayName: 'Package to release' + type: string + values: + - documentdb-constants + - schema-analyzer + + # The expected version — must match the version in the package's package.json + - name: publishVersion + displayName: 'Expected package version' + type: string + + # When true, runs validation only without publishing + - name: dryRun + displayName: 'Dry Run (validate without publishing)' + type: boolean + default: true + + - name: 'debug' + displayName: 'Enable debug output' + type: boolean + default: false + +resources: + repositories: + - repository: templates + type: git + name: OneBranch.Pipelines/GovernedTemplates + ref: refs/heads/main + pipelines: + - pipeline: npmBuild + project: 'CosmosDB' + source: '\VSCode Extensions\vscode-documentdb Build npm packages' + +variables: + system.debug: ${{ parameters.debug }} + TeamName: 'Desktop Tools' + WindowsContainerImage: 'onebranch.azurecr.io/windows/ltsc2022/vse2022:latest' + +extends: + template: v2/OneBranch.Official.CrossPlat.yml@templates + + parameters: + cloudvault: + enabled: false + globalSdl: + asyncSdl: + enabled: false + tsa: + enabled: false + credscan: + suppressionsFile: $(Build.SourcesDirectory)/.azure-pipelines/compliance/CredScanSuppressions.json + policheck: + break: true + suppression: + suppressionFile: $(Build.SourcesDirectory)/.config/guardian/.gdnsuppress + codeql: + compiled: + enabled: false + tsaEnabled: false + componentgovernance: + ignoreDirectories: $(Build.SourcesDirectory)/.vscode-test + featureFlags: + linuxEsrpSigning: true + WindowsHostVersion: + Version: 2022 + + release: + category: NonAzure + + stages: + - stage: Release + displayName: Release npm package + variables: + - name: ob_release_environment + value: Production # Options: Test, PPE, Production + jobs: + - job: ReleaseValidation + displayName: "\U00002713 Validate Artifacts" + templateContext: + inputs: + - input: pipelineArtifact + pipeline: npmBuild + targetPath: $(System.DefaultWorkingDirectory) + artifactName: drop_BuildStage_Main + pool: + type: release + variables: + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' + steps: + # Set a descriptive build number + - task: PowerShell@2 + displayName: "\U0001F449 Set build number" + inputs: + targetType: 'inline' + script: | + $packageName = "${{ parameters.packageName }}" + $publishVersion = "${{ parameters.publishVersion }}" + $isDryRun = "${{ parameters.dryRun }}" + $buildId = "$(Build.BuildId)" + + $dryRunSuffix = "" + if ($isDryRun -eq 'True') { + $dryRunSuffix = "-dry" + } + + $newBuildNumber = "npm-${packageName}-${publishVersion}${dryRunSuffix}-${buildId}" + Write-Output "Setting build number to: $newBuildNumber" + Write-Output "##vso[build.updatebuildnumber]$newBuildNumber" + + # Verify the version in package.json matches the intended publish version + - task: PowerShell@2 + displayName: "\U0001F449 Verify package version" + inputs: + targetType: 'inline' + script: | + $packageName = "${{ parameters.packageName }}" + $publishVersion = "${{ parameters.publishVersion }}" + + $packageJsonPath = "$(System.DefaultWorkingDirectory)/packages/$packageName/package.json" + if (-not (Test-Path $packageJsonPath)) { + Write-Error "[Error] package.json not found at $packageJsonPath" + Write-Output "Available files:" + Get-ChildItem -Path "$(System.DefaultWorkingDirectory)" -Recurse -Filter "package.json" | ForEach-Object { Write-Output $_.FullName } + exit 1 + } + + $packageJson = Get-Content $packageJsonPath | ConvertFrom-Json + $actualVersion = $packageJson.version + $fullPackageName = $packageJson.name + + Write-Output "Package: $fullPackageName" + Write-Output "Version in package.json: $actualVersion" + Write-Output "Expected publish version: $publishVersion" + + # Validate semantic version format + $semverPattern = '^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$' + if ($actualVersion -notmatch $semverPattern) { + Write-Error "[Error] Version in package.json ($actualVersion) is not a valid semantic version" + exit 1 + } + if ($publishVersion -notmatch $semverPattern) { + Write-Error "[Error] Publish version ($publishVersion) is not a valid semantic version" + exit 1 + } + + if ($actualVersion -eq $publishVersion) { + Write-Output "[Success] Version matches. Proceeding with release of $fullPackageName@$publishVersion" + } else { + Write-Error "[Error] Publish version '$publishVersion' does not match version in package.json '$actualVersion'. Cancelling release." + exit 1 + } + + # ESRP Release cannot publish private packages + if ($packageJson.private -eq $true) { + Write-Error "[Error] Package $fullPackageName is marked as private in package.json. Cannot publish to npm." + exit 1 + } + + # Find the .tgz file for the selected package + - task: PowerShell@2 + displayName: "\U0001F50D Find .tgz file" + name: findTgzStep + inputs: + targetType: 'inline' + script: | + $packageName = "${{ parameters.packageName }}" + $npmPackagesDir = "$(System.DefaultWorkingDirectory)/npm-packages" + + Write-Output "Searching for .tgz files in: $npmPackagesDir" + + if (-not (Test-Path $npmPackagesDir)) { + Write-Error "[Error] npm-packages directory not found at $npmPackagesDir" + Write-Output "Available directories:" + Get-ChildItem -Path "$(System.DefaultWorkingDirectory)" -Directory | ForEach-Object { Write-Output $_.FullName } + exit 1 + } + + # List all available .tgz files + Write-Output "Available .tgz files:" + Get-ChildItem -Path $npmPackagesDir -Filter "*.tgz" | ForEach-Object { + $sizeKB = [math]::Round($_.Length / 1KB, 2) + Write-Output " $($_.Name) (${sizeKB} KB)" + } + + # Find the .tgz file matching the selected package + # npm pack generates filenames like: vscode-documentdb-{packageName}-{version}.tgz + $tgzFiles = Get-ChildItem -Path $npmPackagesDir -Filter "*$packageName*.tgz" + + if ($tgzFiles.Count -eq 0) { + Write-Error "[Error] No .tgz file found matching package '$packageName'" + exit 1 + } elseif ($tgzFiles.Count -gt 1) { + Write-Error "[Error] Multiple .tgz files found matching package '$packageName': $($tgzFiles.Name -join ', ')" + exit 1 + } + + $tgzFile = $tgzFiles[0] + $tgzSize = [math]::Round($tgzFile.Length / 1KB, 2) + Write-Output "" + Write-Output "[Success] Found .tgz file: $($tgzFile.Name) (${tgzSize} KB)" + Write-Output "##vso[task.setvariable variable=tgzFileName;isOutput=true]$($tgzFile.Name)" + + - job: PublishPackage + displayName: "\U00002713 Publish to npm" + condition: and(succeeded(), eq('${{ parameters.dryRun }}', false)) + dependsOn: ReleaseValidation + pool: + type: release + variables: + tgzFileName: $[ dependencies.ReleaseValidation.outputs['findTgzStep.tgzFileName'] ] + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' + templateContext: + inputs: + - input: pipelineArtifact + pipeline: npmBuild + targetPath: $(System.DefaultWorkingDirectory) + artifactName: drop_BuildStage_Main + steps: + # Copy only the selected .tgz to an isolated folder for ESRP + - task: PowerShell@2 + displayName: "\U0001F449 Prepare package for publishing" + inputs: + targetType: 'inline' + script: | + $tgzFileName = "$(tgzFileName)" + $npmPackagesDir = "$(System.DefaultWorkingDirectory)\npm-packages" + $publishDir = "$(Build.ArtifactStagingDirectory)\npm-publish" + + New-Item -ItemType Directory -Force -Path $publishDir | Out-Null + + $sourcePath = Join-Path $npmPackagesDir $tgzFileName + if (-not (Test-Path $sourcePath)) { + Write-Error "[Error] .tgz file not found: $sourcePath" + exit 1 + } + + Copy-Item -Path $sourcePath -Destination $publishDir + Write-Output "Prepared for publishing:" + Get-ChildItem -Path $publishDir | ForEach-Object { + Write-Output " $($_.Name) ($([math]::Round($_.Length / 1KB, 2)) KB)" + } + + # Publish to npmjs.org via ESRP Release + # Docs: https://microsoft.sharepoint.com/teams/prss/release/ + # + # TODO: Uncomment the EsrpRelease@7 task below once the ESRP service connection + # and pipeline variables are configured. Required variables: + # - EsrpServiceConnection : ESRP ADO service connection name + # - EsrpKeyVaultName : Azure Key Vault with ESRP certs + # - EsrpAuthCertName : Auth certificate name in Key Vault + # - EsrpSignCertName : Sign certificate name in Key Vault + # - EsrpClientId : AAD app registration client ID + # - EsrpOwners : Owner email(s) for the release (e.g., alias@microsoft.com) + # - EsrpApprovers : Approver email(s) for the release (e.g., alias@microsoft.com) + - task: PowerShell@2 + displayName: "\U0001F680 Publish to npmjs.org via ESRP (placeholder)" + inputs: + targetType: 'inline' + script: | + Write-Error "[Error] EsrpRelease@7 task is not yet configured. Set up the ESRP service connection and uncomment the task in release-npm-packages.yml." + exit 1 + # - task: EsrpRelease@7 + # displayName: "\U0001F680 Publish to npmjs.org via ESRP" + # inputs: + # connectedservicename: '$(EsrpServiceConnection)' + # keyvaultname: '$(EsrpKeyVaultName)' + # authcertname: '$(EsrpAuthCertName)' + # signcertname: '$(EsrpSignCertName)' + # clientid: '$(EsrpClientId)' + # intent: 'PackageDistribution' + # contenttype: 'npm' + # contentsource: 'Folder' + # folderlocation: '$(Build.ArtifactStagingDirectory)/npm-publish' + # waitforreleasecompletion: true + # owners: '$(EsrpOwners)' + # approvers: '$(EsrpApprovers)' + # serviceendpointurl: 'https://api.esrp.microsoft.com' + # mainpublisher: 'ESRPRELPACMAN' + # domaintenantid: '72f988bf-86f1-41af-91ab-2d7cd011db47' diff --git a/.azure-pipelines/release-npm-packages.yml.real b/.azure-pipelines/release-npm-packages.yml.real new file mode 100644 index 000000000..2ee908ee8 --- /dev/null +++ b/.azure-pipelines/release-npm-packages.yml.real @@ -0,0 +1,283 @@ +# Release pipeline for npm packages +# Publishes a selected package to npmjs.org via ESRP Release +# +# Prerequisites: +# 1. Install the "ESRP Release" ADO extension in your project +# 2. Configure an ESRP service connection with certificate-based auth +# 3. Set the pipeline variables listed in the TODO comments below +# (or use a variable group linked to this pipeline) + +parameters: + # Which package to publish (directory name under packages/) + - name: packageName + displayName: 'Package to release' + type: string + values: + - documentdb-constants + - schema-analyzer + + # The expected version — must match the version in the package's package.json + - name: publishVersion + displayName: 'Expected package version' + type: string + + # When true, runs validation only without publishing + - name: dryRun + displayName: 'Dry Run (validate without publishing)' + type: boolean + default: true + + - name: 'debug' + displayName: 'Enable debug output' + type: boolean + default: false + +resources: + repositories: + - repository: templates + type: git + name: OneBranch.Pipelines/GovernedTemplates + ref: refs/heads/main + pipelines: + - pipeline: npmBuild + project: 'CosmosDB' + source: '\VSCode Extensions\vscode-documentdb Build NPM Packages' # TODO: Update to match the actual build pipeline name in ADO + +variables: + system.debug: ${{ parameters.debug }} + TeamName: 'Desktop Tools' + WindowsContainerImage: 'onebranch.azurecr.io/windows/ltsc2022/vse2022:latest' + +extends: + template: v2/OneBranch.Official.CrossPlat.yml@templates + + parameters: + cloudvault: + enabled: false + globalSdl: + asyncSdl: + enabled: false + tsa: + enabled: false + credscan: + suppressionsFile: $(Build.SourcesDirectory)/.azure-pipelines/compliance/CredScanSuppressions.json + policheck: + break: true + suppression: + suppressionFile: $(Build.SourcesDirectory)/.config/guardian/.gdnsuppress + codeql: + compiled: + enabled: false + tsaEnabled: false + componentgovernance: + ignoreDirectories: $(Build.SourcesDirectory)/.vscode-test + featureFlags: + linuxEsrpSigning: true + WindowsHostVersion: + Version: 2022 + + release: + category: NonAzure + + stages: + - stage: Release + displayName: Release npm package + variables: + - name: ob_release_environment + value: Production # Options: Test, PPE, Production + jobs: + - job: ReleaseValidation + displayName: "\U00002713 Validate Artifacts" + templateContext: + inputs: + - input: pipelineArtifact + pipeline: npmBuild + targetPath: $(System.DefaultWorkingDirectory) + artifactName: drop_BuildStage_Main + pool: + type: release + variables: + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' + steps: + # Set a descriptive build number + - task: PowerShell@2 + displayName: "\U0001F449 Set build number" + inputs: + targetType: 'inline' + script: | + $packageName = "${{ parameters.packageName }}" + $publishVersion = "${{ parameters.publishVersion }}" + $isDryRun = "${{ parameters.dryRun }}" + $buildId = "$(Build.BuildId)" + + $dryRunSuffix = "" + if ($isDryRun -eq 'True') { + $dryRunSuffix = "-dry" + } + + $newBuildNumber = "npm-${packageName}-${publishVersion}${dryRunSuffix}-${buildId}" + Write-Output "Setting build number to: $newBuildNumber" + Write-Output "##vso[build.updatebuildnumber]$newBuildNumber" + + # Verify the version in package.json matches the intended publish version + - task: PowerShell@2 + displayName: "\U0001F449 Verify package version" + inputs: + targetType: 'inline' + script: | + $packageName = "${{ parameters.packageName }}" + $publishVersion = "${{ parameters.publishVersion }}" + + $packageJsonPath = "$(System.DefaultWorkingDirectory)/packages/$packageName/package.json" + if (-not (Test-Path $packageJsonPath)) { + Write-Error "[Error] package.json not found at $packageJsonPath" + Write-Output "Available files:" + Get-ChildItem -Path "$(System.DefaultWorkingDirectory)" -Recurse -Filter "package.json" | ForEach-Object { Write-Output $_.FullName } + exit 1 + } + + $packageJson = Get-Content $packageJsonPath | ConvertFrom-Json + $actualVersion = $packageJson.version + $fullPackageName = $packageJson.name + + Write-Output "Package: $fullPackageName" + Write-Output "Version in package.json: $actualVersion" + Write-Output "Expected publish version: $publishVersion" + + # Validate semantic version format + $semverPattern = '^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$' + if ($actualVersion -notmatch $semverPattern) { + Write-Error "[Error] Version in package.json ($actualVersion) is not a valid semantic version" + exit 1 + } + if ($publishVersion -notmatch $semverPattern) { + Write-Error "[Error] Publish version ($publishVersion) is not a valid semantic version" + exit 1 + } + + if ($actualVersion -eq $publishVersion) { + Write-Output "[Success] Version matches. Proceeding with release of $fullPackageName@$publishVersion" + } else { + Write-Error "[Error] Publish version '$publishVersion' does not match version in package.json '$actualVersion'. Cancelling release." + exit 1 + } + + # ESRP Release cannot publish private packages + if ($packageJson.private -eq $true) { + Write-Error "[Error] Package $fullPackageName is marked as private in package.json. Cannot publish to npm." + exit 1 + } + + # Find the .tgz file for the selected package + - task: PowerShell@2 + displayName: "\U0001F50D Find .tgz file" + name: findTgzStep + inputs: + targetType: 'inline' + script: | + $packageName = "${{ parameters.packageName }}" + $npmPackagesDir = "$(System.DefaultWorkingDirectory)/npm-packages" + + Write-Output "Searching for .tgz files in: $npmPackagesDir" + + if (-not (Test-Path $npmPackagesDir)) { + Write-Error "[Error] npm-packages directory not found at $npmPackagesDir" + Write-Output "Available directories:" + Get-ChildItem -Path "$(System.DefaultWorkingDirectory)" -Directory | ForEach-Object { Write-Output $_.FullName } + exit 1 + } + + # List all available .tgz files + Write-Output "Available .tgz files:" + Get-ChildItem -Path $npmPackagesDir -Filter "*.tgz" | ForEach-Object { + $sizeKB = [math]::Round($_.Length / 1KB, 2) + Write-Output " $($_.Name) (${sizeKB} KB)" + } + + # Find the .tgz file matching the selected package + # npm pack generates filenames like: vscode-documentdb-{packageName}-{version}.tgz + $tgzFiles = Get-ChildItem -Path $npmPackagesDir -Filter "*$packageName*.tgz" + + if ($tgzFiles.Count -eq 0) { + Write-Error "[Error] No .tgz file found matching package '$packageName'" + exit 1 + } elseif ($tgzFiles.Count -gt 1) { + Write-Error "[Error] Multiple .tgz files found matching package '$packageName': $($tgzFiles.Name -join ', ')" + exit 1 + } + + $tgzFile = $tgzFiles[0] + $tgzSize = [math]::Round($tgzFile.Length / 1KB, 2) + Write-Output "" + Write-Output "[Success] Found .tgz file: $($tgzFile.Name) (${tgzSize} KB)" + Write-Output "##vso[task.setvariable variable=tgzFileName;isOutput=true]$($tgzFile.Name)" + + - job: PublishPackage + displayName: "\U00002713 Publish to npm" + condition: and(succeeded(), eq('${{ parameters.dryRun }}', false)) + dependsOn: ReleaseValidation + pool: + type: windows + variables: + tgzFileName: $[ dependencies.ReleaseValidation.outputs['findTgzStep.tgzFileName'] ] + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' + templateContext: + inputs: + - input: pipelineArtifact + pipeline: npmBuild + targetPath: $(System.DefaultWorkingDirectory) + artifactName: drop_BuildStage_Main + steps: + # Copy only the selected .tgz to an isolated folder for ESRP + - task: PowerShell@2 + displayName: "\U0001F449 Prepare package for publishing" + inputs: + targetType: 'inline' + script: | + $tgzFileName = "$(tgzFileName)" + $npmPackagesDir = "$(System.DefaultWorkingDirectory)\npm-packages" + $publishDir = "$(Build.ArtifactStagingDirectory)\npm-publish" + + New-Item -ItemType Directory -Force -Path $publishDir | Out-Null + + $sourcePath = Join-Path $npmPackagesDir $tgzFileName + if (-not (Test-Path $sourcePath)) { + Write-Error "[Error] .tgz file not found: $sourcePath" + exit 1 + } + + Copy-Item -Path $sourcePath -Destination $publishDir + Write-Output "Prepared for publishing:" + Get-ChildItem -Path $publishDir | ForEach-Object { + Write-Output " $($_.Name) ($([math]::Round($_.Length / 1KB, 2)) KB)" + } + + # Publish to npmjs.org via ESRP Release + # Docs: https://microsoft.sharepoint.com/teams/prss/release/ + # + # TODO: Configure these pipeline variables (or use a variable group): + # - EsrpServiceConnection : ESRP ADO service connection name + # - EsrpKeyVaultName : Azure Key Vault with ESRP certs + # - EsrpAuthCertName : Auth certificate name in Key Vault + # - EsrpSignCertName : Sign certificate name in Key Vault + # - EsrpClientId : AAD app registration client ID + # - EsrpOwners : Owner email(s) for the release (e.g., alias@microsoft.com) + # - EsrpApprovers : Approver email(s) for the release (e.g., alias@microsoft.com) + - task: EsrpRelease@7 + displayName: "\U0001F680 Publish to npmjs.org via ESRP" + inputs: + connectedservicename: '$(EsrpServiceConnection)' + keyvaultname: '$(EsrpKeyVaultName)' + authcertname: '$(EsrpAuthCertName)' + signcertname: '$(EsrpSignCertName)' + clientid: '$(EsrpClientId)' + intent: 'PackageDistribution' + contenttype: 'npm' + contentsource: 'Folder' + folderlocation: '$(Build.ArtifactStagingDirectory)/npm-publish' + waitforreleasecompletion: true + owners: '$(EsrpOwners)' + approvers: '$(EsrpApprovers)' + serviceendpointurl: 'https://api.esrp.microsoft.com' + mainpublisher: 'ESRPRELPACMAN' + domaintenantid: '72f988bf-86f1-41af-91ab-2d7cd011db47' From aca95e6ef1f4d24683008502e832c8c2c0dcec83 Mon Sep 17 00:00:00 2001 From: Guanzhou Song Date: Wed, 10 Jun 2026 10:23:10 -0400 Subject: [PATCH 02/25] fix: finalize npm package build and release pipelines Refine the npm build/release pipelines for ESRP-based publishing of @microsoft/vscode-ext-react-webview to npmjs.org. Build pipeline: - Pack only the release-bound @microsoft package; still build/test all workspaces so the monorepo stays healthy. Release pipeline: - Replace dryRun bool with a three-way mode (validate-only / test-esrp-auth / publish); validate-only excludes the publish job at compile time. - Use EsrpRelease@11 with managed-identity auth, signing cert from Key Vault, and the ESRPRELPACMAN OSS publisher. - test-esrp-auth submits with contenttype=Maven to smoke-test the full ESRP auth/publisher path without publishing anything. - Restrict the package picklist to vscode-ext-react-webview; bake in the non-secret ESRP config (KV name, sign cert name, publisher client id). - Add source-build provenance and .tgz content-safety inspection steps. - Remove the stray *.real reference files and emoji from task display names. Verified end-to-end via test-esrp-auth: auth, Key Vault, upload, publisher authorization (ESRPRELPACMAN), and the content-validation stage all pass; the smoke test correctly stops at content validation without publishing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .azure-pipelines/build-npm-packages.yml | 78 ++-- .azure-pipelines/build-npm-packages.yml.real | 173 -------- .azure-pipelines/release-npm-packages.yml | 377 +++++++++++++----- .../release-npm-packages.yml.real | 283 ------------- 4 files changed, 323 insertions(+), 588 deletions(-) delete mode 100644 .azure-pipelines/build-npm-packages.yml.real delete mode 100644 .azure-pipelines/release-npm-packages.yml.real diff --git a/.azure-pipelines/build-npm-packages.yml b/.azure-pipelines/build-npm-packages.yml index 5e56aff42..0da237ec4 100644 --- a/.azure-pipelines/build-npm-packages.yml +++ b/.azure-pipelines/build-npm-packages.yml @@ -1,5 +1,12 @@ # Build pipeline for npm packages in the packages/ folder -# Produces .tgz archives that can be published via the npm release pipeline +# Produces .tgz archives that can be published via the npm release pipeline. +# +# Scope: only packages in $publishablePackages (below) are packed and shipped +# as release artifacts. Today this is just packages/vscode-ext-react-webview +# (which publishes to the @microsoft scope on npm). The other workspace +# packages are still built and tested by 'npm run build/test --workspaces' +# so the monorepo stays healthy, but they are NOT packed - they are local- +# only deps and not released through this pipeline. trigger: - next @@ -75,18 +82,18 @@ extends: displayName: 'Component Governance - Component Detection' - task: NodeTool@0 - displayName: "\U0001F449 Use Node.js" + displayName: "Use Node.js" inputs: versionSource: fromFile versionFilePath: .nvmrc - task: npmAuthenticate@0 - displayName: "\U0001F449 Authenticate to npm registry" + displayName: "Authenticate to npm registry" inputs: workingFile: '$(Build.SourcesDirectory)/.azure-pipelines/.npmrc' - task: Npm@1 - displayName: "\U0001F449 Install Dependencies" + displayName: "Install Dependencies" condition: succeeded() inputs: command: custom @@ -94,7 +101,7 @@ extends: workingDir: $(Build.SourcesDirectory) - task: Npm@1 - displayName: "\U0001F449 Build Workspace Packages" + displayName: "Build Workspace Packages" condition: succeeded() inputs: command: custom @@ -102,7 +109,7 @@ extends: workingDir: $(Build.SourcesDirectory) - task: Npm@1 - displayName: "\U0001F449 Test Workspace Packages" + displayName: "Test Workspace Packages" condition: succeeded() inputs: command: custom @@ -110,7 +117,7 @@ extends: workingDir: $(Build.SourcesDirectory) - task: PowerShell@2 - displayName: "\U0001F4E6 Pack npm packages" + displayName: "Pack npm packages" condition: succeeded() inputs: targetType: 'inline' @@ -118,34 +125,43 @@ extends: $outputDir = "$(ob_outputDirectory)\npm-packages" New-Item -ItemType Directory -Force -Path $outputDir | Out-Null - Write-Output "Packing npm workspace packages..." + # Only pack the packages that we actually release via ESRP. + # See release-npm-packages.yml for the matching picklist. + $publishablePackages = @( + 'vscode-ext-react-webview' + ) + + Write-Output "Packing release-bound workspace packages..." Write-Output "" - $packageDirs = Get-ChildItem -Path "$(Build.SourcesDirectory)\packages" -Directory + foreach ($pkgDirName in $publishablePackages) { + $packageDir = Join-Path "$(Build.SourcesDirectory)\packages" $pkgDirName + $packageJsonPath = Join-Path $packageDir "package.json" - foreach ($dir in $packageDirs) { - $packageJsonPath = Join-Path $dir.FullName "package.json" - if (Test-Path $packageJsonPath) { - $packageJson = Get-Content $packageJsonPath | ConvertFrom-Json - $pkgName = $packageJson.name - $pkgVersion = $packageJson.version + if (-not (Test-Path $packageJsonPath)) { + Write-Error "[Error] Configured publishable package '$pkgDirName' not found at $packageJsonPath" + exit 1 + } - if ($packageJson.private -eq $true) { - Write-Warning "Skipping private package: $pkgName" - continue - } + $packageJson = Get-Content $packageJsonPath | ConvertFrom-Json + $pkgName = $packageJson.name + $pkgVersion = $packageJson.version - Write-Output "Packing $pkgName@$pkgVersion from $($dir.Name)..." - npm pack --pack-destination $outputDir --workspace "packages/$($dir.Name)" + if ($packageJson.private -eq $true) { + Write-Error "[Error] Configured publishable package '$pkgName' is marked private in package.json" + exit 1 + } - if ($LASTEXITCODE -ne 0) { - Write-Error "[Error] Failed to pack $pkgName" - exit 1 - } + Write-Output "Packing $pkgName@$pkgVersion from $pkgDirName..." + npm pack --pack-destination $outputDir --workspace "packages/$pkgDirName" - Write-Output "✓ Packed $pkgName@$pkgVersion" - Write-Output "" + if ($LASTEXITCODE -ne 0) { + Write-Error "[Error] Failed to pack $pkgName" + exit 1 } + + Write-Output "[OK] Packed $pkgName@$pkgVersion" + Write-Output "" } Write-Output "=== Generated .tgz files ===" @@ -158,16 +174,16 @@ extends: Write-Output "" Write-Output "Total packages: $tgzCount" - if ($tgzCount -eq 0) { - Write-Error "[Error] No .tgz files were generated" + if ($tgzCount -ne $publishablePackages.Count) { + Write-Error "[Error] Expected $($publishablePackages.Count) .tgz file(s), found $tgzCount" exit 1 } - task: CopyFiles@2 - displayName: "\U0001F449 Copy package metadata to staging" + displayName: "Copy package metadata to staging" condition: succeeded() inputs: Contents: | - packages/*/package.json + packages/vscode-ext-react-webview/package.json !**/node_modules/** TargetFolder: $(ob_outputDirectory) diff --git a/.azure-pipelines/build-npm-packages.yml.real b/.azure-pipelines/build-npm-packages.yml.real deleted file mode 100644 index 5e56aff42..000000000 --- a/.azure-pipelines/build-npm-packages.yml.real +++ /dev/null @@ -1,173 +0,0 @@ -# Build pipeline for npm packages in the packages/ folder -# Produces .tgz archives that can be published via the npm release pipeline - -trigger: - - next - - main - -# Disable PR trigger -pr: none - -parameters: - - name: 'debug' - displayName: 'Enable debug output' - type: boolean - default: false - - name: isOfficialBuild - displayName: 'Official Build' - type: boolean - default: true - -variables: - CDP_DEFINITION_BUILD_COUNT: $[counter('', 0)] # needed for onebranch.pipeline.version task https://aka.ms/obpipelines/versioning - system.debug: ${{ parameters.debug }} - - WindowsContainerImage: 'onebranch.azurecr.io/windows/ltsc2022/vse2022:latest' # Docker image which is used to build the project https://aka.ms/obpipelines/containers - -resources: - repositories: - - repository: templates - type: git - name: OneBranch.Pipelines/GovernedTemplates - ref: refs/heads/main - -extends: - ${{ if eq(parameters.isOfficialBuild, true) }}: - template: v2/OneBranch.Official.CrossPlat.yml@templates # https://aka.ms/obpipelines/templates - ${{ else }}: - template: v2/OneBranch.NonOfficial.CrossPlat.yml@templates # https://aka.ms/obpipelines/templates - parameters: - cloudvault: # https://aka.ms/obpipelines/cloudvault - enabled: false - globalSdl: # https://aka.ms/obpipelines/sdl - asyncSdl: - enabled: false - tsa: - enabled: false - credscan: - suppressionsFile: $(Build.SourcesDirectory)/.azure-pipelines/compliance/CredScanSuppressions.json - policheck: - break: true - suppression: - suppressionFile: $(Build.SourcesDirectory)/.config/guardian/.gdnsuppress - codeql: - compiled: - enabled: false - tsaEnabled: false - componentgovernance: - ignoreDirectories: $(Build.SourcesDirectory)/.vscode-test - featureFlags: - linuxEsrpSigning: true - WindowsHostVersion: - Version: 2022 - - stages: - - stage: BuildStage - jobs: - - job: Main - pool: - type: windows - variables: - ob_outputDirectory: '$(Build.ArtifactStagingDirectory)\build' # this directory is uploaded to pipeline artifacts. More info at https://aka.ms/obpipelines/artifacts - ob_sdl_codeSignValidation_excludes: '-|**\*.json;-|**\*.js;-|**\node_modules\**;' - steps: - - task: ComponentGovernanceComponentDetection@0 - displayName: 'Component Governance - Component Detection' - - - task: NodeTool@0 - displayName: "\U0001F449 Use Node.js" - inputs: - versionSource: fromFile - versionFilePath: .nvmrc - - - task: npmAuthenticate@0 - displayName: "\U0001F449 Authenticate to npm registry" - inputs: - workingFile: '$(Build.SourcesDirectory)/.azure-pipelines/.npmrc' - - - task: Npm@1 - displayName: "\U0001F449 Install Dependencies" - condition: succeeded() - inputs: - command: custom - customCommand: ci --userconfig $(Build.SourcesDirectory)/.azure-pipelines/.npmrc - workingDir: $(Build.SourcesDirectory) - - - task: Npm@1 - displayName: "\U0001F449 Build Workspace Packages" - condition: succeeded() - inputs: - command: custom - customCommand: run build --workspaces --if-present - workingDir: $(Build.SourcesDirectory) - - - task: Npm@1 - displayName: "\U0001F449 Test Workspace Packages" - condition: succeeded() - inputs: - command: custom - customCommand: run test --workspaces --if-present - workingDir: $(Build.SourcesDirectory) - - - task: PowerShell@2 - displayName: "\U0001F4E6 Pack npm packages" - condition: succeeded() - inputs: - targetType: 'inline' - script: | - $outputDir = "$(ob_outputDirectory)\npm-packages" - New-Item -ItemType Directory -Force -Path $outputDir | Out-Null - - Write-Output "Packing npm workspace packages..." - Write-Output "" - - $packageDirs = Get-ChildItem -Path "$(Build.SourcesDirectory)\packages" -Directory - - foreach ($dir in $packageDirs) { - $packageJsonPath = Join-Path $dir.FullName "package.json" - if (Test-Path $packageJsonPath) { - $packageJson = Get-Content $packageJsonPath | ConvertFrom-Json - $pkgName = $packageJson.name - $pkgVersion = $packageJson.version - - if ($packageJson.private -eq $true) { - Write-Warning "Skipping private package: $pkgName" - continue - } - - Write-Output "Packing $pkgName@$pkgVersion from $($dir.Name)..." - npm pack --pack-destination $outputDir --workspace "packages/$($dir.Name)" - - if ($LASTEXITCODE -ne 0) { - Write-Error "[Error] Failed to pack $pkgName" - exit 1 - } - - Write-Output "✓ Packed $pkgName@$pkgVersion" - Write-Output "" - } - } - - Write-Output "=== Generated .tgz files ===" - Get-ChildItem -Path $outputDir -Filter "*.tgz" | ForEach-Object { - $sizeKB = [math]::Round($_.Length / 1KB, 2) - Write-Output " $($_.Name) (${sizeKB} KB)" - } - - $tgzCount = (Get-ChildItem -Path $outputDir -Filter "*.tgz").Count - Write-Output "" - Write-Output "Total packages: $tgzCount" - - if ($tgzCount -eq 0) { - Write-Error "[Error] No .tgz files were generated" - exit 1 - } - - - task: CopyFiles@2 - displayName: "\U0001F449 Copy package metadata to staging" - condition: succeeded() - inputs: - Contents: | - packages/*/package.json - !**/node_modules/** - TargetFolder: $(ob_outputDirectory) diff --git a/.azure-pipelines/release-npm-packages.yml b/.azure-pipelines/release-npm-packages.yml index bc2ef56bd..fdd01c2b5 100644 --- a/.azure-pipelines/release-npm-packages.yml +++ b/.azure-pipelines/release-npm-packages.yml @@ -3,32 +3,81 @@ # # Prerequisites: # 1. Install the "ESRP Release" ADO extension in your project -# 2. Configure an ESRP service connection with certificate-based auth -# 3. Set the pipeline variables listed in the TODO comments below -# (or use a variable group linked to this pipeline) +# (request access via esrprelpm@microsoft.com if not already installed). +# 2. Have an Azure Resource Manager service connection configured with +# Workload Identity Federation (OIDC). The managed identity backing +# the connection must: +# - be registered as your ESRP publisher identity, AND +# - have "Key Vault Certificate User" on the Key Vault that holds +# your ESRP signing (TSS) certificate. +# (This pipeline uses 'DOCDBEXT_ESRP_RELEASE'.) +# 3. The ESRP wiring (Key Vault, cert, client id) is baked in as YAML +# variables below. To override without editing this file, set the +# same-named variables in ADO -> pipeline -> Variables. +# +# Why signing cert + KV are still needed even with WIF: +# - The service connection (WIF) handles AUTH to Azure / Key Vault. +# - ESRP additionally requires the release REQUEST PAYLOAD itself to be +# cryptographically signed by a registered publisher cert (TSS cert). +# This is a non-negotiable ESRP requirement, independent of how the +# pipeline authenticates to Azure. The cert lives in Key Vault so it +# can be rotated without touching the service connection. +# - In the older pattern (usemanagedidentity: false), a SECOND "auth" +# cert was also needed; with usemanagedidentity: true, WIF replaces +# the auth cert and only the signing cert remains. +# +# IMPORTANT: ESRP only has publish rights for a fixed list of npm scopes +# (see https://eng.ms/.../npmjs.md). At the time of writing, the list +# includes @microsoft, @azure, @vscode, @fluentui, ... but NOT +# @documentdb-js. Packages outside the allow-list will fail to publish +# until ESRP is configured to support that scope (contact esrprelpm@). trigger: none pr: none parameters: - # Which package to publish (directory name under packages/) + # Which package to publish (directory name under packages/). + # Only @microsoft-scoped packages are publishable today - ESRP's publisher + # allow-list covers @microsoft (and several other Microsoft-owned scopes) + # but NOT @documentdb-js. The other workspace packages under packages/ + # are intentionally excluded from this picklist. - name: packageName displayName: 'Package to release' type: string + default: vscode-ext-react-webview values: - - documentdb-constants - - schema-analyzer + - vscode-ext-react-webview - # The expected version — must match the version in the package's package.json + # The expected version - must match the version in the package's package.json - name: publishVersion displayName: 'Expected package version' type: string - # When true, runs validation only without publishing - - name: dryRun - displayName: 'Dry Run (validate without publishing)' - type: boolean - default: true + # Run mode controls how far the pipeline goes. Default is the safest option. + # validate-only : Validate the artifact only. Skips ESRP entirely. No risk of publishing. + # test-esrp-auth : Smoke-test the full ESRP auth path (service connection -> managed + # identity -> Key Vault -> signing cert -> ESRP) by submitting with + # contenttype='Maven' instead of 'npm'. ESRP authenticates and accepts + # the request, then fails at the final content-validation step because + # the payload is not a Maven artifact. NOTHING IS PUBLISHED. This trick + # is documented by the ESRP team: + # https://eng.ms/docs/.../release-onboarding (Usage FAQ) + # + # NOTE: A failed run in this mode does NOT, by itself, prove the auth + # path worked - it could also fail on bad SC, KV permissions, cert + # names, tenant, or client id. After running, inspect the ESRP task + # logs (and the ESRP submission at aka.ms/releaseui if an operation + # id was issued) to confirm the failure was the expected content-type + # rejection, not an auth/cert failure. + # publish : REAL release. Publishes the .tgz to npmjs.org via ESRP. + - name: mode + displayName: 'Run mode' + type: string + default: 'validate-only' + values: + - validate-only + - test-esrp-auth + - publish - name: 'debug' displayName: 'Enable debug output' @@ -51,6 +100,24 @@ variables: TeamName: 'Desktop Tools' WindowsContainerImage: 'onebranch.azurecr.io/windows/ltsc2022/vse2022:latest' + # ESRP wiring - defaults are baked in so the pipeline works out of the box. + # To override (e.g. for a different KV or cert), set a same-named variable + # in ADO under "Variables" - the ADO value wins over the YAML default. + # + # None of these are secrets: + # - Key Vault name and cert name are resource identifiers + # - Client ID is a public Entra identifier; the federated credential + # (configured on the managed identity in Azure) is what authenticates + EsrpKeyVaultName: 'vscode-ext-release-akv' + EsrpSignCertName: 'DocDBEXT-CERT' + # This MUST be the app id that is REGISTERED AND APPROVED with ESRP as the + # publisher (ESRP Portal -> Onboarding -> Release = Approved), NOT the app + # that merely backs the ADO service connection. They must be the same app: + # the service connection 'DOCDBEXT_ESRP_RELEASE' must federate to this id and + # this id must have 'Key Vault Certificate User' on EsrpKeyVaultName. + # ESRP-approved publisher app: DocumentDB-VSC-Extension-ESRP-Release (AME). + EsrpClientId: '8ba27e59-eace-4759-a6dd-36962b0e55d0' + extends: template: v2/OneBranch.Official.CrossPlat.yml@templates @@ -87,10 +154,15 @@ extends: displayName: Release npm package variables: - name: ob_release_environment - value: Production # Options: Test, PPE, Production + # Only use the Production environment (and its stricter approval gate) + # for an actual publish. Validation and smoke-test runs use Test. + ${{ if eq(parameters.mode, 'publish') }}: + value: Production + ${{ else }}: + value: Test jobs: - job: ReleaseValidation - displayName: "\U00002713 Validate Artifacts" + displayName: "Validate Artifacts" templateContext: inputs: - input: pipelineArtifact @@ -102,29 +174,47 @@ extends: variables: ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' steps: + # Show which build run we are about to release from. The operator should + # confirm this matches the intended source commit/branch before approving. + - task: PowerShell@2 + displayName: "Show source build metadata" + inputs: + targetType: 'inline' + script: | + Write-Output "Releasing from npmBuild pipeline run:" + Write-Output " Pipeline : $(resources.pipeline.npmBuild.pipelineName)" + Write-Output " Run id : $(resources.pipeline.npmBuild.runID)" + Write-Output " Run name : $(resources.pipeline.npmBuild.runName)" + Write-Output " Source branch : $(resources.pipeline.npmBuild.sourceBranch)" + Write-Output " Source commit : $(resources.pipeline.npmBuild.sourceCommit)" + Write-Output " Source repository : $(resources.pipeline.npmBuild.sourceProvider)" + # Set a descriptive build number - task: PowerShell@2 - displayName: "\U0001F449 Set build number" + displayName: "Set build number" inputs: targetType: 'inline' script: | $packageName = "${{ parameters.packageName }}" $publishVersion = "${{ parameters.publishVersion }}" - $isDryRun = "${{ parameters.dryRun }}" + $mode = "${{ parameters.mode }}" $buildId = "$(Build.BuildId)" - $dryRunSuffix = "" - if ($isDryRun -eq 'True') { - $dryRunSuffix = "-dry" + $modeSuffix = switch ($mode) { + 'validate-only' { '-validate' } + 'test-esrp-auth' { '-esrptest' } + 'publish' { '' } + default { "-$mode" } } - $newBuildNumber = "npm-${packageName}-${publishVersion}${dryRunSuffix}-${buildId}" + $newBuildNumber = "npm-${packageName}-${publishVersion}${modeSuffix}-${buildId}" + Write-Output "Mode: $mode" Write-Output "Setting build number to: $newBuildNumber" Write-Output "##vso[build.updatebuildnumber]$newBuildNumber" # Verify the version in package.json matches the intended publish version - task: PowerShell@2 - displayName: "\U0001F449 Verify package version" + displayName: "Verify package version" inputs: targetType: 'inline' script: | @@ -173,7 +263,7 @@ extends: # Find the .tgz file for the selected package - task: PowerShell@2 - displayName: "\U0001F50D Find .tgz file" + displayName: "Find .tgz file" name: findTgzStep inputs: targetType: 'inline' @@ -197,8 +287,12 @@ extends: Write-Output " $($_.Name) (${sizeKB} KB)" } - # Find the .tgz file matching the selected package - # npm pack generates filenames like: vscode-documentdb-{packageName}-{version}.tgz + # Find the .tgz file matching the selected package. + # npm pack writes scope-flattened filenames, e.g.: + # documentdb-js-schema-analyzer-0.8.1.tgz + # microsoft-vscode-ext-react-webview-0.8.0-preview.tgz + # The packageName parameter matches the folder name under packages/, + # which appears in the .tgz filename for all packages here. $tgzFiles = Get-ChildItem -Path $npmPackagesDir -Filter "*$packageName*.tgz" if ($tgzFiles.Count -eq 0) { @@ -215,80 +309,161 @@ extends: Write-Output "[Success] Found .tgz file: $($tgzFile.Name) (${tgzSize} KB)" Write-Output "##vso[task.setvariable variable=tgzFileName;isOutput=true]$($tgzFile.Name)" - - job: PublishPackage - displayName: "\U00002713 Publish to npm" - condition: and(succeeded(), eq('${{ parameters.dryRun }}', false)) - dependsOn: ReleaseValidation - pool: - type: release - variables: - tgzFileName: $[ dependencies.ReleaseValidation.outputs['findTgzStep.tgzFileName'] ] - ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' - templateContext: - inputs: - - input: pipelineArtifact - pipeline: npmBuild - targetPath: $(System.DefaultWorkingDirectory) - artifactName: drop_BuildStage_Main - steps: - # Copy only the selected .tgz to an isolated folder for ESRP - - task: PowerShell@2 - displayName: "\U0001F449 Prepare package for publishing" - inputs: - targetType: 'inline' - script: | - $tgzFileName = "$(tgzFileName)" - $npmPackagesDir = "$(System.DefaultWorkingDirectory)\npm-packages" - $publishDir = "$(Build.ArtifactStagingDirectory)\npm-publish" - - New-Item -ItemType Directory -Force -Path $publishDir | Out-Null - - $sourcePath = Join-Path $npmPackagesDir $tgzFileName - if (-not (Test-Path $sourcePath)) { - Write-Error "[Error] .tgz file not found: $sourcePath" - exit 1 - } - - Copy-Item -Path $sourcePath -Destination $publishDir - Write-Output "Prepared for publishing:" - Get-ChildItem -Path $publishDir | ForEach-Object { - Write-Output " $($_.Name) ($([math]::Round($_.Length / 1KB, 2)) KB)" - } - - # Publish to npmjs.org via ESRP Release - # Docs: https://microsoft.sharepoint.com/teams/prss/release/ - # - # TODO: Uncomment the EsrpRelease@7 task below once the ESRP service connection - # and pipeline variables are configured. Required variables: - # - EsrpServiceConnection : ESRP ADO service connection name - # - EsrpKeyVaultName : Azure Key Vault with ESRP certs - # - EsrpAuthCertName : Auth certificate name in Key Vault - # - EsrpSignCertName : Sign certificate name in Key Vault - # - EsrpClientId : AAD app registration client ID - # - EsrpOwners : Owner email(s) for the release (e.g., alias@microsoft.com) - # - EsrpApprovers : Approver email(s) for the release (e.g., alias@microsoft.com) - - task: PowerShell@2 - displayName: "\U0001F680 Publish to npmjs.org via ESRP (placeholder)" - inputs: - targetType: 'inline' - script: | - Write-Error "[Error] EsrpRelease@7 task is not yet configured. Set up the ESRP service connection and uncomment the task in release-npm-packages.yml." - exit 1 - # - task: EsrpRelease@7 - # displayName: "\U0001F680 Publish to npmjs.org via ESRP" - # inputs: - # connectedservicename: '$(EsrpServiceConnection)' - # keyvaultname: '$(EsrpKeyVaultName)' - # authcertname: '$(EsrpAuthCertName)' - # signcertname: '$(EsrpSignCertName)' - # clientid: '$(EsrpClientId)' - # intent: 'PackageDistribution' - # contenttype: 'npm' - # contentsource: 'Folder' - # folderlocation: '$(Build.ArtifactStagingDirectory)/npm-publish' - # waitforreleasecompletion: true - # owners: '$(EsrpOwners)' - # approvers: '$(EsrpApprovers)' - # serviceendpointurl: 'https://api.esrp.microsoft.com' - # mainpublisher: 'ESRPRELPACMAN' - # domaintenantid: '72f988bf-86f1-41af-91ab-2d7cd011db47' + # Use compile-time inclusion so the publish job does NOT exist in the + # compiled pipeline graph for 'validate-only'. This is stronger than a + # runtime condition: even a future regression that drops the condition + # cannot accidentally publish in validate-only mode. + - ${{ if or(eq(parameters.mode, 'publish'), eq(parameters.mode, 'test-esrp-auth')) }}: + - job: PublishPackage + displayName: ${{ format('Publish ({0})', parameters.mode) }} + dependsOn: ReleaseValidation + pool: + type: release + variables: + tgzFileName: $[ dependencies.ReleaseValidation.outputs['findTgzStep.tgzFileName'] ] + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' + templateContext: + inputs: + - input: pipelineArtifact + pipeline: npmBuild + targetPath: $(System.DefaultWorkingDirectory) + artifactName: drop_BuildStage_Main + steps: + # Loud banner for the smoke-test mode so logs are unambiguous + - ${{ if eq(parameters.mode, 'test-esrp-auth') }}: + - task: PowerShell@2 + displayName: "SMOKE TEST MODE - not publishing" + inputs: + targetType: 'inline' + script: | + Write-Host "##[warning]Running in 'test-esrp-auth' mode." + Write-Host "##[warning]Submitting to ESRP with contenttype='Maven' to validate auth + cert + KV wiring." + Write-Host "##[warning]ESRP is EXPECTED TO FAIL at the final content-validation step." + Write-Host "##[warning]Nothing will be published to npmjs.org." + Write-Host "##[warning]After the run, inspect the ESRP task logs to confirm the failure" + Write-Host "##[warning]is the expected Maven content-type rejection, NOT an auth/cert error." + + # Copy only the selected .tgz to an isolated folder for ESRP + - task: PowerShell@2 + displayName: "Prepare package for publishing" + inputs: + targetType: 'inline' + script: | + $tgzFileName = "$(tgzFileName)" + $npmPackagesDir = "$(System.DefaultWorkingDirectory)\npm-packages" + $publishDir = "$(Build.ArtifactStagingDirectory)\npm-publish" + + New-Item -ItemType Directory -Force -Path $publishDir | Out-Null + + $sourcePath = Join-Path $npmPackagesDir $tgzFileName + if (-not (Test-Path $sourcePath)) { + Write-Error "[Error] .tgz file not found: $sourcePath" + exit 1 + } + + Copy-Item -Path $sourcePath -Destination $publishDir + Write-Output "Prepared for publishing:" + Get-ChildItem -Path $publishDir | ForEach-Object { + Write-Output " $($_.Name) ($([math]::Round($_.Length / 1KB, 2)) KB)" + } + + # Inspect the .tgz contents and fail on obviously sensitive files. + # This is a safety net (CredScan runs earlier, but the .tgz is the + # actual public artifact - worth double-checking right before upload). + - task: PowerShell@2 + displayName: "Inspect .tgz contents" + inputs: + targetType: 'inline' + script: | + $ErrorActionPreference = "Stop" + $publishDir = "$(Build.ArtifactStagingDirectory)\npm-publish" + $tgzPath = Get-ChildItem -Path $publishDir -Filter "*.tgz" | Select-Object -First 1 -ExpandProperty FullName + if (-not $tgzPath) { + Write-Error "[Error] No .tgz found in $publishDir" + exit 1 + } + + Write-Output "Listing contents of $tgzPath ..." + $entries = & tar -tzf $tgzPath + if ($LASTEXITCODE -ne 0) { + Write-Error "[Error] Failed to list tarball contents" + exit 1 + } + + $entries | ForEach-Object { Write-Output " $_" } + + # Patterns that should never appear in a published package. + $forbiddenPatterns = @( + '\.env(\..*)?$', + '(^|/)\.npmrc$', + '(^|/)\.netrc$', + '(^|/)id_rsa(\.pub)?$', + '(^|/)\.ssh/', + '\.pem$', + '\.pfx$', + '\.p12$', + '\.key$', + '(^|/)secrets?(\.json|\.yaml|\.yml|\.txt)$', + '(^|/)credentials?(\.json|\.yaml|\.yml|\.txt)$' + ) + + $violations = @() + foreach ($entry in $entries) { + foreach ($pattern in $forbiddenPatterns) { + if ($entry -match $pattern) { + $violations += " $entry (matched: $pattern)" + } + } + } + + if ($violations.Count -gt 0) { + Write-Error "[Error] Forbidden file(s) detected in the .tgz:" + $violations | ForEach-Object { Write-Error $_ } + exit 1 + } + + Write-Output "" + Write-Output "[Success] No forbidden files detected ($($entries.Count) entries scanned)" + + # Publish to npmjs.org via ESRP Release + # Docs: https://eng.ms/docs/microsoft-security/identity/trust-and-security-services/tss-release-distribute/tss-release-esrp-parent/release-onboarding + # + # Authentication: the Azure RM service connection 'DOCDBEXT_ESRP_RELEASE' + # uses Workload Identity Federation. Its managed identity is granted + # 'Key Vault Certificate User' on the Key Vault that holds the ESRP + # auth + sign certs. The task reads those certs from the vault and + # uses them to sign the release request to ESRP. + # + # See the file header for the pipeline variables required. + # + # When 'mode == test-esrp-auth', contenttype is overridden to 'Maven' + # so ESRP exercises the full auth/cert/KV path then rejects the + # payload at content validation. NOTHING is published in that mode. + - task: EsrpRelease@11 + displayName: ${{ format('ESRP Release ({0})', parameters.mode) }} + inputs: + connectedservicename: 'DOCDBEXT_ESRP_RELEASE' + usemanagedidentity: true + keyvaultname: '$(EsrpKeyVaultName)' + signcertname: '$(EsrpSignCertName)' + clientid: '$(EsrpClientId)' + intent: 'PackageDistribution' + ${{ if eq(parameters.mode, 'test-esrp-auth') }}: + contenttype: 'Maven' + ${{ else }}: + contenttype: 'npm' + contentsource: 'Folder' + folderlocation: '$(Build.ArtifactStagingDirectory)/npm-publish' + waitforreleasecompletion: true + owners: 'guanzhousong@microsoft.com' + approvers: 'guanzhousong@microsoft.com' + serviceendpointurl: 'https://api.esrp.microsoft.com' + mainpublisher: 'ESRPRELPACMAN' + # domaintenantid for the ESRPRELPACMAN OSS publisher. + # ESRP OSS docs (npm/PyPI/Maven/Crates/MCP/WinGet) all use + # the PME tenant 975f013f for this shared publisher. The + # ErrorCode 2252 "Tenant does not have access to the given + # Main Publisher" is resolved by ESRP mapping our client id + # to ESRPRELPACMAN (done) combined with using the publisher's + # tenant id here. + domaintenantid: '975f013f-7f24-47e8-a7d3-abc4752bf346' diff --git a/.azure-pipelines/release-npm-packages.yml.real b/.azure-pipelines/release-npm-packages.yml.real deleted file mode 100644 index 2ee908ee8..000000000 --- a/.azure-pipelines/release-npm-packages.yml.real +++ /dev/null @@ -1,283 +0,0 @@ -# Release pipeline for npm packages -# Publishes a selected package to npmjs.org via ESRP Release -# -# Prerequisites: -# 1. Install the "ESRP Release" ADO extension in your project -# 2. Configure an ESRP service connection with certificate-based auth -# 3. Set the pipeline variables listed in the TODO comments below -# (or use a variable group linked to this pipeline) - -parameters: - # Which package to publish (directory name under packages/) - - name: packageName - displayName: 'Package to release' - type: string - values: - - documentdb-constants - - schema-analyzer - - # The expected version — must match the version in the package's package.json - - name: publishVersion - displayName: 'Expected package version' - type: string - - # When true, runs validation only without publishing - - name: dryRun - displayName: 'Dry Run (validate without publishing)' - type: boolean - default: true - - - name: 'debug' - displayName: 'Enable debug output' - type: boolean - default: false - -resources: - repositories: - - repository: templates - type: git - name: OneBranch.Pipelines/GovernedTemplates - ref: refs/heads/main - pipelines: - - pipeline: npmBuild - project: 'CosmosDB' - source: '\VSCode Extensions\vscode-documentdb Build NPM Packages' # TODO: Update to match the actual build pipeline name in ADO - -variables: - system.debug: ${{ parameters.debug }} - TeamName: 'Desktop Tools' - WindowsContainerImage: 'onebranch.azurecr.io/windows/ltsc2022/vse2022:latest' - -extends: - template: v2/OneBranch.Official.CrossPlat.yml@templates - - parameters: - cloudvault: - enabled: false - globalSdl: - asyncSdl: - enabled: false - tsa: - enabled: false - credscan: - suppressionsFile: $(Build.SourcesDirectory)/.azure-pipelines/compliance/CredScanSuppressions.json - policheck: - break: true - suppression: - suppressionFile: $(Build.SourcesDirectory)/.config/guardian/.gdnsuppress - codeql: - compiled: - enabled: false - tsaEnabled: false - componentgovernance: - ignoreDirectories: $(Build.SourcesDirectory)/.vscode-test - featureFlags: - linuxEsrpSigning: true - WindowsHostVersion: - Version: 2022 - - release: - category: NonAzure - - stages: - - stage: Release - displayName: Release npm package - variables: - - name: ob_release_environment - value: Production # Options: Test, PPE, Production - jobs: - - job: ReleaseValidation - displayName: "\U00002713 Validate Artifacts" - templateContext: - inputs: - - input: pipelineArtifact - pipeline: npmBuild - targetPath: $(System.DefaultWorkingDirectory) - artifactName: drop_BuildStage_Main - pool: - type: release - variables: - ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' - steps: - # Set a descriptive build number - - task: PowerShell@2 - displayName: "\U0001F449 Set build number" - inputs: - targetType: 'inline' - script: | - $packageName = "${{ parameters.packageName }}" - $publishVersion = "${{ parameters.publishVersion }}" - $isDryRun = "${{ parameters.dryRun }}" - $buildId = "$(Build.BuildId)" - - $dryRunSuffix = "" - if ($isDryRun -eq 'True') { - $dryRunSuffix = "-dry" - } - - $newBuildNumber = "npm-${packageName}-${publishVersion}${dryRunSuffix}-${buildId}" - Write-Output "Setting build number to: $newBuildNumber" - Write-Output "##vso[build.updatebuildnumber]$newBuildNumber" - - # Verify the version in package.json matches the intended publish version - - task: PowerShell@2 - displayName: "\U0001F449 Verify package version" - inputs: - targetType: 'inline' - script: | - $packageName = "${{ parameters.packageName }}" - $publishVersion = "${{ parameters.publishVersion }}" - - $packageJsonPath = "$(System.DefaultWorkingDirectory)/packages/$packageName/package.json" - if (-not (Test-Path $packageJsonPath)) { - Write-Error "[Error] package.json not found at $packageJsonPath" - Write-Output "Available files:" - Get-ChildItem -Path "$(System.DefaultWorkingDirectory)" -Recurse -Filter "package.json" | ForEach-Object { Write-Output $_.FullName } - exit 1 - } - - $packageJson = Get-Content $packageJsonPath | ConvertFrom-Json - $actualVersion = $packageJson.version - $fullPackageName = $packageJson.name - - Write-Output "Package: $fullPackageName" - Write-Output "Version in package.json: $actualVersion" - Write-Output "Expected publish version: $publishVersion" - - # Validate semantic version format - $semverPattern = '^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$' - if ($actualVersion -notmatch $semverPattern) { - Write-Error "[Error] Version in package.json ($actualVersion) is not a valid semantic version" - exit 1 - } - if ($publishVersion -notmatch $semverPattern) { - Write-Error "[Error] Publish version ($publishVersion) is not a valid semantic version" - exit 1 - } - - if ($actualVersion -eq $publishVersion) { - Write-Output "[Success] Version matches. Proceeding with release of $fullPackageName@$publishVersion" - } else { - Write-Error "[Error] Publish version '$publishVersion' does not match version in package.json '$actualVersion'. Cancelling release." - exit 1 - } - - # ESRP Release cannot publish private packages - if ($packageJson.private -eq $true) { - Write-Error "[Error] Package $fullPackageName is marked as private in package.json. Cannot publish to npm." - exit 1 - } - - # Find the .tgz file for the selected package - - task: PowerShell@2 - displayName: "\U0001F50D Find .tgz file" - name: findTgzStep - inputs: - targetType: 'inline' - script: | - $packageName = "${{ parameters.packageName }}" - $npmPackagesDir = "$(System.DefaultWorkingDirectory)/npm-packages" - - Write-Output "Searching for .tgz files in: $npmPackagesDir" - - if (-not (Test-Path $npmPackagesDir)) { - Write-Error "[Error] npm-packages directory not found at $npmPackagesDir" - Write-Output "Available directories:" - Get-ChildItem -Path "$(System.DefaultWorkingDirectory)" -Directory | ForEach-Object { Write-Output $_.FullName } - exit 1 - } - - # List all available .tgz files - Write-Output "Available .tgz files:" - Get-ChildItem -Path $npmPackagesDir -Filter "*.tgz" | ForEach-Object { - $sizeKB = [math]::Round($_.Length / 1KB, 2) - Write-Output " $($_.Name) (${sizeKB} KB)" - } - - # Find the .tgz file matching the selected package - # npm pack generates filenames like: vscode-documentdb-{packageName}-{version}.tgz - $tgzFiles = Get-ChildItem -Path $npmPackagesDir -Filter "*$packageName*.tgz" - - if ($tgzFiles.Count -eq 0) { - Write-Error "[Error] No .tgz file found matching package '$packageName'" - exit 1 - } elseif ($tgzFiles.Count -gt 1) { - Write-Error "[Error] Multiple .tgz files found matching package '$packageName': $($tgzFiles.Name -join ', ')" - exit 1 - } - - $tgzFile = $tgzFiles[0] - $tgzSize = [math]::Round($tgzFile.Length / 1KB, 2) - Write-Output "" - Write-Output "[Success] Found .tgz file: $($tgzFile.Name) (${tgzSize} KB)" - Write-Output "##vso[task.setvariable variable=tgzFileName;isOutput=true]$($tgzFile.Name)" - - - job: PublishPackage - displayName: "\U00002713 Publish to npm" - condition: and(succeeded(), eq('${{ parameters.dryRun }}', false)) - dependsOn: ReleaseValidation - pool: - type: windows - variables: - tgzFileName: $[ dependencies.ReleaseValidation.outputs['findTgzStep.tgzFileName'] ] - ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' - templateContext: - inputs: - - input: pipelineArtifact - pipeline: npmBuild - targetPath: $(System.DefaultWorkingDirectory) - artifactName: drop_BuildStage_Main - steps: - # Copy only the selected .tgz to an isolated folder for ESRP - - task: PowerShell@2 - displayName: "\U0001F449 Prepare package for publishing" - inputs: - targetType: 'inline' - script: | - $tgzFileName = "$(tgzFileName)" - $npmPackagesDir = "$(System.DefaultWorkingDirectory)\npm-packages" - $publishDir = "$(Build.ArtifactStagingDirectory)\npm-publish" - - New-Item -ItemType Directory -Force -Path $publishDir | Out-Null - - $sourcePath = Join-Path $npmPackagesDir $tgzFileName - if (-not (Test-Path $sourcePath)) { - Write-Error "[Error] .tgz file not found: $sourcePath" - exit 1 - } - - Copy-Item -Path $sourcePath -Destination $publishDir - Write-Output "Prepared for publishing:" - Get-ChildItem -Path $publishDir | ForEach-Object { - Write-Output " $($_.Name) ($([math]::Round($_.Length / 1KB, 2)) KB)" - } - - # Publish to npmjs.org via ESRP Release - # Docs: https://microsoft.sharepoint.com/teams/prss/release/ - # - # TODO: Configure these pipeline variables (or use a variable group): - # - EsrpServiceConnection : ESRP ADO service connection name - # - EsrpKeyVaultName : Azure Key Vault with ESRP certs - # - EsrpAuthCertName : Auth certificate name in Key Vault - # - EsrpSignCertName : Sign certificate name in Key Vault - # - EsrpClientId : AAD app registration client ID - # - EsrpOwners : Owner email(s) for the release (e.g., alias@microsoft.com) - # - EsrpApprovers : Approver email(s) for the release (e.g., alias@microsoft.com) - - task: EsrpRelease@7 - displayName: "\U0001F680 Publish to npmjs.org via ESRP" - inputs: - connectedservicename: '$(EsrpServiceConnection)' - keyvaultname: '$(EsrpKeyVaultName)' - authcertname: '$(EsrpAuthCertName)' - signcertname: '$(EsrpSignCertName)' - clientid: '$(EsrpClientId)' - intent: 'PackageDistribution' - contenttype: 'npm' - contentsource: 'Folder' - folderlocation: '$(Build.ArtifactStagingDirectory)/npm-publish' - waitforreleasecompletion: true - owners: '$(EsrpOwners)' - approvers: '$(EsrpApprovers)' - serviceendpointurl: 'https://api.esrp.microsoft.com' - mainpublisher: 'ESRPRELPACMAN' - domaintenantid: '72f988bf-86f1-41af-91ab-2d7cd011db47' From b2d0226e40145ffbb4abcc0a96c9ef6879c632c8 Mon Sep 17 00:00:00 2001 From: Guanzhou Song Date: Wed, 10 Jun 2026 11:05:04 -0400 Subject: [PATCH 03/25] docs: address review feedback on npm release pipeline - Relabel the source-build banner field "Source repository" -> "Source provider"; it prints sourceProvider (the SCM provider), not the repo name. - Correct the ESRP auth comment: with usemanagedidentity:true, WIF handles auth to Azure/Key Vault and only the signing cert is read (no auth cert). - Move ESRP owners/approvers into overridable EsrpReleaseOwners / EsrpReleaseApprovers variables (default unchanged) so releases can target a team security group instead of a single personal alias. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Guanzhou Song --- .azure-pipelines/release-npm-packages.yml | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/.azure-pipelines/release-npm-packages.yml b/.azure-pipelines/release-npm-packages.yml index fdd01c2b5..7400e2ffe 100644 --- a/.azure-pipelines/release-npm-packages.yml +++ b/.azure-pipelines/release-npm-packages.yml @@ -118,6 +118,12 @@ variables: # ESRP-approved publisher app: DocumentDB-VSC-Extension-ESRP-Release (AME). EsrpClientId: '8ba27e59-eace-4759-a6dd-36962b0e55d0' + # ESRP release notification / approval contacts. Defaults to the current + # release owner, but prefer a TEAM security group or distribution list so + # releases are not tied to a single person. Override in ADO -> Variables. + EsrpReleaseOwners: 'guanzhousong@microsoft.com' + EsrpReleaseApprovers: 'guanzhousong@microsoft.com' + extends: template: v2/OneBranch.Official.CrossPlat.yml@templates @@ -187,7 +193,7 @@ extends: Write-Output " Run name : $(resources.pipeline.npmBuild.runName)" Write-Output " Source branch : $(resources.pipeline.npmBuild.sourceBranch)" Write-Output " Source commit : $(resources.pipeline.npmBuild.sourceCommit)" - Write-Output " Source repository : $(resources.pipeline.npmBuild.sourceProvider)" + Write-Output " Source provider : $(resources.pipeline.npmBuild.sourceProvider)" # Set a descriptive build number - task: PowerShell@2 @@ -429,10 +435,11 @@ extends: # Docs: https://eng.ms/docs/microsoft-security/identity/trust-and-security-services/tss-release-distribute/tss-release-esrp-parent/release-onboarding # # Authentication: the Azure RM service connection 'DOCDBEXT_ESRP_RELEASE' - # uses Workload Identity Federation. Its managed identity is granted - # 'Key Vault Certificate User' on the Key Vault that holds the ESRP - # auth + sign certs. The task reads those certs from the vault and - # uses them to sign the release request to ESRP. + # uses Workload Identity Federation, which handles auth to Azure / Key + # Vault (so no separate auth cert is needed). Its managed identity is + # granted 'Key Vault Certificate User' on the Key Vault that holds the + # ESRP signing cert. The task reads that signing cert from the vault and + # uses it to sign the release request to ESRP. # # See the file header for the pipeline variables required. # @@ -455,8 +462,8 @@ extends: contentsource: 'Folder' folderlocation: '$(Build.ArtifactStagingDirectory)/npm-publish' waitforreleasecompletion: true - owners: 'guanzhousong@microsoft.com' - approvers: 'guanzhousong@microsoft.com' + owners: '$(EsrpReleaseOwners)' + approvers: '$(EsrpReleaseApprovers)' serviceendpointurl: 'https://api.esrp.microsoft.com' mainpublisher: 'ESRPRELPACMAN' # domaintenantid for the ESRPRELPACMAN OSS publisher. From 1daba4fcec77b04193b078031033423d31dc5996 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 6 Jul 2026 13:14:14 +0000 Subject: [PATCH 04/25] ci: make npm build pipeline manual-only (trigger: none) The build/test work is already covered on every push by GitHub Actions CI and the main extension ADO build pipeline. The only unique output of this pipeline is the packed .tgz release artifact, consumed solely by release-npm-packages.yml, so it does not need to run continuously. --- .azure-pipelines/build-npm-packages.yml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.azure-pipelines/build-npm-packages.yml b/.azure-pipelines/build-npm-packages.yml index 0da237ec4..23268b6b6 100644 --- a/.azure-pipelines/build-npm-packages.yml +++ b/.azure-pipelines/build-npm-packages.yml @@ -8,9 +8,17 @@ # so the monorepo stays healthy, but they are NOT packed - they are local- # only deps and not released through this pipeline. -trigger: - - next - - main +# Manual-only build. The build/test work here is already covered on every push +# by the GitHub Actions CI (.github/workflows/main.yml) and the main extension +# ADO pipeline (.azure-pipelines/build.yml, which also runs Component Governance +# / CredScan / PoliCheck over the same monorepo). The ONLY unique output of this +# pipeline is the packed .tgz release artifact, which is only ever consumed by +# release-npm-packages.yml. So there is no reason to run it continuously. +# +# Release procedure: queue this pipeline manually, wait for it to succeed, then +# queue release-npm-packages.yml (which picks up the latest successful run of +# this pipeline via its 'npmBuild' pipeline resource). +trigger: none # Disable PR trigger pr: none From 6a987256c5bdd2a33604d83add3ed7a0f39de963 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 6 Jul 2026 13:15:16 +0000 Subject: [PATCH 05/25] ci: update release package to @microsoft/vscode-ext-webview The old packages/vscode-ext-react-webview was replaced by packages/vscode-ext-webview (published as @microsoft/vscode-ext-webview). Update the pack list, release picklist, staging copy path, and example filenames in both npm pipelines accordingly. --- .azure-pipelines/build-npm-packages.yml | 6 +++--- .azure-pipelines/release-npm-packages.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.azure-pipelines/build-npm-packages.yml b/.azure-pipelines/build-npm-packages.yml index 23268b6b6..9bd629a23 100644 --- a/.azure-pipelines/build-npm-packages.yml +++ b/.azure-pipelines/build-npm-packages.yml @@ -2,7 +2,7 @@ # Produces .tgz archives that can be published via the npm release pipeline. # # Scope: only packages in $publishablePackages (below) are packed and shipped -# as release artifacts. Today this is just packages/vscode-ext-react-webview +# as release artifacts. Today this is just packages/vscode-ext-webview # (which publishes to the @microsoft scope on npm). The other workspace # packages are still built and tested by 'npm run build/test --workspaces' # so the monorepo stays healthy, but they are NOT packed - they are local- @@ -136,7 +136,7 @@ extends: # Only pack the packages that we actually release via ESRP. # See release-npm-packages.yml for the matching picklist. $publishablePackages = @( - 'vscode-ext-react-webview' + 'vscode-ext-webview' ) Write-Output "Packing release-bound workspace packages..." @@ -192,6 +192,6 @@ extends: condition: succeeded() inputs: Contents: | - packages/vscode-ext-react-webview/package.json + packages/vscode-ext-webview/package.json !**/node_modules/** TargetFolder: $(ob_outputDirectory) diff --git a/.azure-pipelines/release-npm-packages.yml b/.azure-pipelines/release-npm-packages.yml index 7400e2ffe..95ab0f7f8 100644 --- a/.azure-pipelines/release-npm-packages.yml +++ b/.azure-pipelines/release-npm-packages.yml @@ -44,9 +44,9 @@ parameters: - name: packageName displayName: 'Package to release' type: string - default: vscode-ext-react-webview + default: vscode-ext-webview values: - - vscode-ext-react-webview + - vscode-ext-webview # The expected version - must match the version in the package's package.json - name: publishVersion @@ -296,7 +296,7 @@ extends: # Find the .tgz file matching the selected package. # npm pack writes scope-flattened filenames, e.g.: # documentdb-js-schema-analyzer-0.8.1.tgz - # microsoft-vscode-ext-react-webview-0.8.0-preview.tgz + # microsoft-vscode-ext-webview-0.9.0-preview.tgz # The packageName parameter matches the folder name under packages/, # which appears in the .tgz filename for all packages here. $tgzFiles = Get-ChildItem -Path $npmPackagesDir -Filter "*$packageName*.tgz" From 38a5f5ed8698adfbfa9eb4795dd973e1bf52e8de Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 6 Jul 2026 13:16:02 +0000 Subject: [PATCH 06/25] ci: add loud banner for real publish mode in release pipeline Previously only the 'test-esrp-auth' smoke-test path printed a warning banner; the real publish path was silent up to the EsrpRelease task. Add a symmetric '##[warning]' banner for 'publish' mode announcing the exact package@version being pushed to npmjs.org. --- .azure-pipelines/release-npm-packages.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.azure-pipelines/release-npm-packages.yml b/.azure-pipelines/release-npm-packages.yml index 95ab0f7f8..68277763e 100644 --- a/.azure-pipelines/release-npm-packages.yml +++ b/.azure-pipelines/release-npm-packages.yml @@ -349,6 +349,18 @@ extends: Write-Host "##[warning]After the run, inspect the ESRP task logs to confirm the failure" Write-Host "##[warning]is the expected Maven content-type rejection, NOT an auth/cert error." + # Loud banner for the REAL publish mode so the log is unambiguous + # about the fact that this run WILL push to npmjs.org. + - ${{ if eq(parameters.mode, 'publish') }}: + - task: PowerShell@2 + displayName: "REAL PUBLISH MODE - will push to npmjs.org" + inputs: + targetType: 'inline' + script: | + Write-Host "##[warning]Running in 'publish' mode - this is a REAL RELEASE." + Write-Host "##[warning]Publishing ${{ parameters.packageName }}@${{ parameters.publishVersion }} to npmjs.org via ESRP." + Write-Host "##[warning]This action is PUBLIC and cannot be undone (npm unpublish is heavily restricted)." + # Copy only the selected .tgz to an isolated folder for ESRP - task: PowerShell@2 displayName: "Prepare package for publishing" From 843d18d0a92882e76c72360babab34ed31351c8f Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 6 Jul 2026 13:16:22 +0000 Subject: [PATCH 07/25] ci: cap PublishPackage job with a 120-minute timeout EsrpRelease uses waitforreleasecompletion: true, which blocks on the ESRP submission. Add timeoutInMinutes so a stalled ESRP approval/submission cannot hold a release agent indefinitely. --- .azure-pipelines/release-npm-packages.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.azure-pipelines/release-npm-packages.yml b/.azure-pipelines/release-npm-packages.yml index 68277763e..2c9a598f5 100644 --- a/.azure-pipelines/release-npm-packages.yml +++ b/.azure-pipelines/release-npm-packages.yml @@ -323,6 +323,10 @@ extends: - job: PublishPackage displayName: ${{ format('Publish ({0})', parameters.mode) }} dependsOn: ReleaseValidation + # EsrpRelease runs with waitforreleasecompletion: true, which blocks + # on the ESRP submission. Cap the job so a stalled ESRP approval or + # submission cannot pin a release agent indefinitely. + timeoutInMinutes: 120 pool: type: release variables: From 12cfe478709c85f9a5cd27b0e87885f90a389d63 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 6 Jul 2026 13:16:54 +0000 Subject: [PATCH 08/25] ci: include run mode in release stage display name Surface the selected mode (validate-only / test-esrp-auth / publish) in the ADO stage list itself, so a run's intent is visible without opening logs or reading the build number. --- .azure-pipelines/release-npm-packages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.azure-pipelines/release-npm-packages.yml b/.azure-pipelines/release-npm-packages.yml index 2c9a598f5..7f0860e76 100644 --- a/.azure-pipelines/release-npm-packages.yml +++ b/.azure-pipelines/release-npm-packages.yml @@ -157,7 +157,7 @@ extends: stages: - stage: Release - displayName: Release npm package + displayName: ${{ format('Release npm package ({0})', parameters.mode) }} variables: - name: ob_release_environment # Only use the Production environment (and its stricter approval gate) From 6b3e5ad4072b009cc25ca84a6efd8703c6a1fe8d Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 6 Jul 2026 13:27:10 +0000 Subject: [PATCH 09/25] ci: add ADO run-summary boxes to npm build and release pipelines Surface key run info at the top of the ADO run (Extensions tab) via ##vso[task.uploadsummary] so it is visible without opening step logs: - build: table of packed packages (name, version, .tgz filename, size) - release (validation): package/version/mode/source-build intent summary for approvers - release (publish): ESRP publish outcome summary; runs with always() so a failure (expected in test-esrp-auth mode) is still reported clearly --- .azure-pipelines/build-npm-packages.yml | 19 ++++++ .azure-pipelines/release-npm-packages.yml | 72 +++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/.azure-pipelines/build-npm-packages.yml b/.azure-pipelines/build-npm-packages.yml index 9bd629a23..609618217 100644 --- a/.azure-pipelines/build-npm-packages.yml +++ b/.azure-pipelines/build-npm-packages.yml @@ -187,6 +187,25 @@ extends: exit 1 } + # Emit a run summary box (rendered at the top of the ADO run, + # Extensions tab) so the packed artifacts are visible without + # opening the step log. + $summaryLines = @() + $summaryLines += "## 📦 Packed npm packages" + $summaryLines += "" + $summaryLines += "| Package | Version | .tgz file | Size |" + $summaryLines += "| --- | --- | --- | --- |" + foreach ($pkgDirName in $publishablePackages) { + $packageJson = Get-Content (Join-Path "$(Build.SourcesDirectory)\packages\$pkgDirName" "package.json") | ConvertFrom-Json + $tgz = Get-ChildItem -Path $outputDir -Filter "*$pkgDirName*.tgz" | Select-Object -First 1 + $sizeKB = if ($tgz) { [math]::Round($tgz.Length / 1KB, 2) } else { 'n/a' } + $tgzName = if ($tgz) { $tgz.Name } else { 'MISSING' } + $summaryLines += "| ``$($packageJson.name)`` | $($packageJson.version) | ``$tgzName`` | ${sizeKB} KB |" + } + $summaryPath = "$(Build.ArtifactStagingDirectory)\pack-summary.md" + $summaryLines -join "`n" | Out-File -FilePath $summaryPath -Encoding utf8 + Write-Host "##vso[task.uploadsummary]$summaryPath" + - task: CopyFiles@2 displayName: "Copy package metadata to staging" condition: succeeded() diff --git a/.azure-pipelines/release-npm-packages.yml b/.azure-pipelines/release-npm-packages.yml index 7f0860e76..4a08402d1 100644 --- a/.azure-pipelines/release-npm-packages.yml +++ b/.azure-pipelines/release-npm-packages.yml @@ -315,6 +315,42 @@ extends: Write-Output "[Success] Found .tgz file: $($tgzFile.Name) (${tgzSize} KB)" Write-Output "##vso[task.setvariable variable=tgzFileName;isOutput=true]$($tgzFile.Name)" + # Emit a run summary box (rendered at the top of the ADO run) so the + # release intent - package, version, mode, and source build - is + # visible to approvers without digging through step logs. + - task: PowerShell@2 + displayName: "Publish run summary" + condition: succeeded() + inputs: + targetType: 'inline' + script: | + $mode = "${{ parameters.mode }}" + $modeNote = switch ($mode) { + 'validate-only' { 'Validation only - ESRP is NOT contacted and nothing is published.' } + 'test-esrp-auth' { 'Smoke test - exercises ESRP auth then fails at content validation. Nothing is published.' } + 'publish' { '**REAL PUBLISH** - this run pushes the package to npmjs.org.' } + default { "Unknown mode: $mode" } + } + + $lines = @() + $lines += "## 🚀 npm release run" + $lines += "" + $lines += "| Field | Value |" + $lines += "| --- | --- |" + $lines += "| Package | ``${{ parameters.packageName }}`` |" + $lines += "| Version | ``${{ parameters.publishVersion }}`` |" + $lines += "| Mode | ``$mode`` |" + $lines += "| .tgz | ``$(findTgzStep.tgzFileName)`` |" + $lines += "| Source build | $(resources.pipeline.npmBuild.runName) (run $(resources.pipeline.npmBuild.runID)) |" + $lines += "| Source branch | $(resources.pipeline.npmBuild.sourceBranch) |" + $lines += "| Source commit | $(resources.pipeline.npmBuild.sourceCommit) |" + $lines += "" + $lines += "> $modeNote" + + $summaryPath = "$(Build.ArtifactStagingDirectory)\release-summary.md" + $lines -join "`n" | Out-File -FilePath $summaryPath -Encoding utf8 + Write-Host "##vso[task.uploadsummary]$summaryPath" + # Use compile-time inclusion so the publish job does NOT exist in the # compiled pipeline graph for 'validate-only'. This is stronger than a # runtime condition: even a future regression that drops the condition @@ -490,3 +526,39 @@ extends: # to ESRPRELPACMAN (done) combined with using the publisher's # tenant id here. domaintenantid: '975f013f-7f24-47e8-a7d3-abc4752bf346' + + # Emit a run summary box describing the outcome. Runs with always() + # so the summary is present whether the ESRP task succeeded or failed + # (a failure in test-esrp-auth mode is the EXPECTED result). + - task: PowerShell@2 + displayName: "Publish outcome summary" + condition: always() + inputs: + targetType: 'inline' + script: | + $mode = "${{ parameters.mode }}" + $esrpResult = "$(Agent.JobStatus)" # Succeeded / SucceededWithIssues / Failed / Canceled + + $lines = @() + $lines += "## 📤 ESRP publish outcome" + $lines += "" + $lines += "| Field | Value |" + $lines += "| --- | --- |" + $lines += "| Package | ``${{ parameters.packageName }}@${{ parameters.publishVersion }}`` |" + $lines += "| Mode | ``$mode`` |" + $lines += "| Job status | ``$esrpResult`` |" + $lines += "" + + if ($mode -eq 'test-esrp-auth') { + $lines += "> Smoke test: NOTHING was published. A **failure** here is expected (ESRP rejects the Maven content-type at validation). Inspect the ESRP task log to confirm the failure was the content-type rejection and NOT an auth/cert/Key Vault error." + } elseif ($mode -eq 'publish') { + if ($esrpResult -eq 'Succeeded') { + $lines += "> ✅ Published ``${{ parameters.packageName }}@${{ parameters.publishVersion }}`` to npmjs.org." + } else { + $lines += "> ❌ Publish did NOT complete successfully (status: $esrpResult). Check the ESRP task log." + } + } + + $summaryPath = "$(Build.ArtifactStagingDirectory)\publish-outcome-summary.md" + $lines -join "`n" | Out-File -FilePath $summaryPath -Encoding utf8 + Write-Host "##vso[task.uploadsummary]$summaryPath" From fe9344c407ee6cafc9e88a007cd3d19081e2924c Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 6 Jul 2026 13:40:07 +0000 Subject: [PATCH 10/25] ci: extract inline pipeline PowerShell into .azure-pipelines/scripts/ Move the large inline PowerShell blocks out of the npm build/release YAML into standalone, param()-based .ps1 files under .azure-pipelines/scripts/. Pipeline values are passed via the task 'arguments' field instead of macro substitution inside the script body, so the scripts are readable, lintable and testable outside YAML indentation. Scripts: - pack-npm-packages.ps1 - show-source-build-metadata.ps1 - set-release-build-number.ps1 - verify-package-version.ps1 - find-tgz.ps1 - write-release-summary.ps1 - prepare-package.ps1 - inspect-tgz.ps1 - write-publish-outcome-summary.ps1 The two tiny compile-time-guarded mode banners (test-esrp-auth / publish) are left inline as they are trivial Write-Host statements. Logic is unchanged; all scripts pass the PowerShell language parser. --- .azure-pipelines/build-npm-packages.yml | 83 +---- .azure-pipelines/release-npm-packages.yml | 296 ++++-------------- .azure-pipelines/scripts/find-tgz.ps1 | 53 ++++ .azure-pipelines/scripts/inspect-tgz.ps1 | 64 ++++ .../scripts/pack-npm-packages.ps1 | 101 ++++++ .azure-pipelines/scripts/prepare-package.ps1 | 30 ++ .../scripts/set-release-build-number.ps1 | 26 ++ .../scripts/show-source-build-metadata.ps1 | 24 ++ .../scripts/verify-package-version.ps1 | 54 ++++ .../scripts/write-publish-outcome-summary.ps1 | 43 +++ .../scripts/write-release-summary.ps1 | 44 +++ 11 files changed, 502 insertions(+), 316 deletions(-) create mode 100644 .azure-pipelines/scripts/find-tgz.ps1 create mode 100644 .azure-pipelines/scripts/inspect-tgz.ps1 create mode 100644 .azure-pipelines/scripts/pack-npm-packages.ps1 create mode 100644 .azure-pipelines/scripts/prepare-package.ps1 create mode 100644 .azure-pipelines/scripts/set-release-build-number.ps1 create mode 100644 .azure-pipelines/scripts/show-source-build-metadata.ps1 create mode 100644 .azure-pipelines/scripts/verify-package-version.ps1 create mode 100644 .azure-pipelines/scripts/write-publish-outcome-summary.ps1 create mode 100644 .azure-pipelines/scripts/write-release-summary.ps1 diff --git a/.azure-pipelines/build-npm-packages.yml b/.azure-pipelines/build-npm-packages.yml index 609618217..e150d984f 100644 --- a/.azure-pipelines/build-npm-packages.yml +++ b/.azure-pipelines/build-npm-packages.yml @@ -128,83 +128,12 @@ extends: displayName: "Pack npm packages" condition: succeeded() inputs: - targetType: 'inline' - script: | - $outputDir = "$(ob_outputDirectory)\npm-packages" - New-Item -ItemType Directory -Force -Path $outputDir | Out-Null - - # Only pack the packages that we actually release via ESRP. - # See release-npm-packages.yml for the matching picklist. - $publishablePackages = @( - 'vscode-ext-webview' - ) - - Write-Output "Packing release-bound workspace packages..." - Write-Output "" - - foreach ($pkgDirName in $publishablePackages) { - $packageDir = Join-Path "$(Build.SourcesDirectory)\packages" $pkgDirName - $packageJsonPath = Join-Path $packageDir "package.json" - - if (-not (Test-Path $packageJsonPath)) { - Write-Error "[Error] Configured publishable package '$pkgDirName' not found at $packageJsonPath" - exit 1 - } - - $packageJson = Get-Content $packageJsonPath | ConvertFrom-Json - $pkgName = $packageJson.name - $pkgVersion = $packageJson.version - - if ($packageJson.private -eq $true) { - Write-Error "[Error] Configured publishable package '$pkgName' is marked private in package.json" - exit 1 - } - - Write-Output "Packing $pkgName@$pkgVersion from $pkgDirName..." - npm pack --pack-destination $outputDir --workspace "packages/$pkgDirName" - - if ($LASTEXITCODE -ne 0) { - Write-Error "[Error] Failed to pack $pkgName" - exit 1 - } - - Write-Output "[OK] Packed $pkgName@$pkgVersion" - Write-Output "" - } - - Write-Output "=== Generated .tgz files ===" - Get-ChildItem -Path $outputDir -Filter "*.tgz" | ForEach-Object { - $sizeKB = [math]::Round($_.Length / 1KB, 2) - Write-Output " $($_.Name) (${sizeKB} KB)" - } - - $tgzCount = (Get-ChildItem -Path $outputDir -Filter "*.tgz").Count - Write-Output "" - Write-Output "Total packages: $tgzCount" - - if ($tgzCount -ne $publishablePackages.Count) { - Write-Error "[Error] Expected $($publishablePackages.Count) .tgz file(s), found $tgzCount" - exit 1 - } - - # Emit a run summary box (rendered at the top of the ADO run, - # Extensions tab) so the packed artifacts are visible without - # opening the step log. - $summaryLines = @() - $summaryLines += "## 📦 Packed npm packages" - $summaryLines += "" - $summaryLines += "| Package | Version | .tgz file | Size |" - $summaryLines += "| --- | --- | --- | --- |" - foreach ($pkgDirName in $publishablePackages) { - $packageJson = Get-Content (Join-Path "$(Build.SourcesDirectory)\packages\$pkgDirName" "package.json") | ConvertFrom-Json - $tgz = Get-ChildItem -Path $outputDir -Filter "*$pkgDirName*.tgz" | Select-Object -First 1 - $sizeKB = if ($tgz) { [math]::Round($tgz.Length / 1KB, 2) } else { 'n/a' } - $tgzName = if ($tgz) { $tgz.Name } else { 'MISSING' } - $summaryLines += "| ``$($packageJson.name)`` | $($packageJson.version) | ``$tgzName`` | ${sizeKB} KB |" - } - $summaryPath = "$(Build.ArtifactStagingDirectory)\pack-summary.md" - $summaryLines -join "`n" | Out-File -FilePath $summaryPath -Encoding utf8 - Write-Host "##vso[task.uploadsummary]$summaryPath" + targetType: 'filePath' + filePath: '$(Build.SourcesDirectory)/.azure-pipelines/scripts/pack-npm-packages.ps1' + arguments: >- + -SourcesDirectory '$(Build.SourcesDirectory)' + -OutputDirectory '$(ob_outputDirectory)' + -SummaryPath '$(Build.ArtifactStagingDirectory)\pack-summary.md' - task: CopyFiles@2 displayName: "Copy package metadata to staging" diff --git a/.azure-pipelines/release-npm-packages.yml b/.azure-pipelines/release-npm-packages.yml index 4a08402d1..ffc79ee39 100644 --- a/.azure-pipelines/release-npm-packages.yml +++ b/.azure-pipelines/release-npm-packages.yml @@ -185,135 +185,49 @@ extends: - task: PowerShell@2 displayName: "Show source build metadata" inputs: - targetType: 'inline' - script: | - Write-Output "Releasing from npmBuild pipeline run:" - Write-Output " Pipeline : $(resources.pipeline.npmBuild.pipelineName)" - Write-Output " Run id : $(resources.pipeline.npmBuild.runID)" - Write-Output " Run name : $(resources.pipeline.npmBuild.runName)" - Write-Output " Source branch : $(resources.pipeline.npmBuild.sourceBranch)" - Write-Output " Source commit : $(resources.pipeline.npmBuild.sourceCommit)" - Write-Output " Source provider : $(resources.pipeline.npmBuild.sourceProvider)" + targetType: 'filePath' + filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/show-source-build-metadata.ps1' + arguments: >- + -PipelineName '$(resources.pipeline.npmBuild.pipelineName)' + -RunId '$(resources.pipeline.npmBuild.runID)' + -RunName '$(resources.pipeline.npmBuild.runName)' + -SourceBranch '$(resources.pipeline.npmBuild.sourceBranch)' + -SourceCommit '$(resources.pipeline.npmBuild.sourceCommit)' + -SourceProvider '$(resources.pipeline.npmBuild.sourceProvider)' # Set a descriptive build number - task: PowerShell@2 displayName: "Set build number" inputs: - targetType: 'inline' - script: | - $packageName = "${{ parameters.packageName }}" - $publishVersion = "${{ parameters.publishVersion }}" - $mode = "${{ parameters.mode }}" - $buildId = "$(Build.BuildId)" - - $modeSuffix = switch ($mode) { - 'validate-only' { '-validate' } - 'test-esrp-auth' { '-esrptest' } - 'publish' { '' } - default { "-$mode" } - } - - $newBuildNumber = "npm-${packageName}-${publishVersion}${modeSuffix}-${buildId}" - Write-Output "Mode: $mode" - Write-Output "Setting build number to: $newBuildNumber" - Write-Output "##vso[build.updatebuildnumber]$newBuildNumber" + targetType: 'filePath' + filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/set-release-build-number.ps1' + arguments: >- + -PackageName '${{ parameters.packageName }}' + -PublishVersion '${{ parameters.publishVersion }}' + -Mode '${{ parameters.mode }}' + -BuildId '$(Build.BuildId)' # Verify the version in package.json matches the intended publish version - task: PowerShell@2 displayName: "Verify package version" inputs: - targetType: 'inline' - script: | - $packageName = "${{ parameters.packageName }}" - $publishVersion = "${{ parameters.publishVersion }}" - - $packageJsonPath = "$(System.DefaultWorkingDirectory)/packages/$packageName/package.json" - if (-not (Test-Path $packageJsonPath)) { - Write-Error "[Error] package.json not found at $packageJsonPath" - Write-Output "Available files:" - Get-ChildItem -Path "$(System.DefaultWorkingDirectory)" -Recurse -Filter "package.json" | ForEach-Object { Write-Output $_.FullName } - exit 1 - } - - $packageJson = Get-Content $packageJsonPath | ConvertFrom-Json - $actualVersion = $packageJson.version - $fullPackageName = $packageJson.name - - Write-Output "Package: $fullPackageName" - Write-Output "Version in package.json: $actualVersion" - Write-Output "Expected publish version: $publishVersion" - - # Validate semantic version format - $semverPattern = '^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$' - if ($actualVersion -notmatch $semverPattern) { - Write-Error "[Error] Version in package.json ($actualVersion) is not a valid semantic version" - exit 1 - } - if ($publishVersion -notmatch $semverPattern) { - Write-Error "[Error] Publish version ($publishVersion) is not a valid semantic version" - exit 1 - } - - if ($actualVersion -eq $publishVersion) { - Write-Output "[Success] Version matches. Proceeding with release of $fullPackageName@$publishVersion" - } else { - Write-Error "[Error] Publish version '$publishVersion' does not match version in package.json '$actualVersion'. Cancelling release." - exit 1 - } - - # ESRP Release cannot publish private packages - if ($packageJson.private -eq $true) { - Write-Error "[Error] Package $fullPackageName is marked as private in package.json. Cannot publish to npm." - exit 1 - } + targetType: 'filePath' + filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/verify-package-version.ps1' + arguments: >- + -PackageName '${{ parameters.packageName }}' + -PublishVersion '${{ parameters.publishVersion }}' + -WorkingDirectory '$(System.DefaultWorkingDirectory)' # Find the .tgz file for the selected package - task: PowerShell@2 displayName: "Find .tgz file" name: findTgzStep inputs: - targetType: 'inline' - script: | - $packageName = "${{ parameters.packageName }}" - $npmPackagesDir = "$(System.DefaultWorkingDirectory)/npm-packages" - - Write-Output "Searching for .tgz files in: $npmPackagesDir" - - if (-not (Test-Path $npmPackagesDir)) { - Write-Error "[Error] npm-packages directory not found at $npmPackagesDir" - Write-Output "Available directories:" - Get-ChildItem -Path "$(System.DefaultWorkingDirectory)" -Directory | ForEach-Object { Write-Output $_.FullName } - exit 1 - } - - # List all available .tgz files - Write-Output "Available .tgz files:" - Get-ChildItem -Path $npmPackagesDir -Filter "*.tgz" | ForEach-Object { - $sizeKB = [math]::Round($_.Length / 1KB, 2) - Write-Output " $($_.Name) (${sizeKB} KB)" - } - - # Find the .tgz file matching the selected package. - # npm pack writes scope-flattened filenames, e.g.: - # documentdb-js-schema-analyzer-0.8.1.tgz - # microsoft-vscode-ext-webview-0.9.0-preview.tgz - # The packageName parameter matches the folder name under packages/, - # which appears in the .tgz filename for all packages here. - $tgzFiles = Get-ChildItem -Path $npmPackagesDir -Filter "*$packageName*.tgz" - - if ($tgzFiles.Count -eq 0) { - Write-Error "[Error] No .tgz file found matching package '$packageName'" - exit 1 - } elseif ($tgzFiles.Count -gt 1) { - Write-Error "[Error] Multiple .tgz files found matching package '$packageName': $($tgzFiles.Name -join ', ')" - exit 1 - } - - $tgzFile = $tgzFiles[0] - $tgzSize = [math]::Round($tgzFile.Length / 1KB, 2) - Write-Output "" - Write-Output "[Success] Found .tgz file: $($tgzFile.Name) (${tgzSize} KB)" - Write-Output "##vso[task.setvariable variable=tgzFileName;isOutput=true]$($tgzFile.Name)" + targetType: 'filePath' + filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/find-tgz.ps1' + arguments: >- + -PackageName '${{ parameters.packageName }}' + -WorkingDirectory '$(System.DefaultWorkingDirectory)' # Emit a run summary box (rendered at the top of the ADO run) so the # release intent - package, version, mode, and source build - is @@ -322,34 +236,18 @@ extends: displayName: "Publish run summary" condition: succeeded() inputs: - targetType: 'inline' - script: | - $mode = "${{ parameters.mode }}" - $modeNote = switch ($mode) { - 'validate-only' { 'Validation only - ESRP is NOT contacted and nothing is published.' } - 'test-esrp-auth' { 'Smoke test - exercises ESRP auth then fails at content validation. Nothing is published.' } - 'publish' { '**REAL PUBLISH** - this run pushes the package to npmjs.org.' } - default { "Unknown mode: $mode" } - } - - $lines = @() - $lines += "## 🚀 npm release run" - $lines += "" - $lines += "| Field | Value |" - $lines += "| --- | --- |" - $lines += "| Package | ``${{ parameters.packageName }}`` |" - $lines += "| Version | ``${{ parameters.publishVersion }}`` |" - $lines += "| Mode | ``$mode`` |" - $lines += "| .tgz | ``$(findTgzStep.tgzFileName)`` |" - $lines += "| Source build | $(resources.pipeline.npmBuild.runName) (run $(resources.pipeline.npmBuild.runID)) |" - $lines += "| Source branch | $(resources.pipeline.npmBuild.sourceBranch) |" - $lines += "| Source commit | $(resources.pipeline.npmBuild.sourceCommit) |" - $lines += "" - $lines += "> $modeNote" - - $summaryPath = "$(Build.ArtifactStagingDirectory)\release-summary.md" - $lines -join "`n" | Out-File -FilePath $summaryPath -Encoding utf8 - Write-Host "##vso[task.uploadsummary]$summaryPath" + targetType: 'filePath' + filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/write-release-summary.ps1' + arguments: >- + -PackageName '${{ parameters.packageName }}' + -PublishVersion '${{ parameters.publishVersion }}' + -Mode '${{ parameters.mode }}' + -TgzFileName '$(findTgzStep.tgzFileName)' + -SourceRunName '$(resources.pipeline.npmBuild.runName)' + -SourceRunId '$(resources.pipeline.npmBuild.runID)' + -SourceBranch '$(resources.pipeline.npmBuild.sourceBranch)' + -SourceCommit '$(resources.pipeline.npmBuild.sourceCommit)' + -SummaryPath '$(Build.ArtifactStagingDirectory)\release-summary.md' # Use compile-time inclusion so the publish job does NOT exist in the # compiled pipeline graph for 'validate-only'. This is stronger than a @@ -405,25 +303,12 @@ extends: - task: PowerShell@2 displayName: "Prepare package for publishing" inputs: - targetType: 'inline' - script: | - $tgzFileName = "$(tgzFileName)" - $npmPackagesDir = "$(System.DefaultWorkingDirectory)\npm-packages" - $publishDir = "$(Build.ArtifactStagingDirectory)\npm-publish" - - New-Item -ItemType Directory -Force -Path $publishDir | Out-Null - - $sourcePath = Join-Path $npmPackagesDir $tgzFileName - if (-not (Test-Path $sourcePath)) { - Write-Error "[Error] .tgz file not found: $sourcePath" - exit 1 - } - - Copy-Item -Path $sourcePath -Destination $publishDir - Write-Output "Prepared for publishing:" - Get-ChildItem -Path $publishDir | ForEach-Object { - Write-Output " $($_.Name) ($([math]::Round($_.Length / 1KB, 2)) KB)" - } + targetType: 'filePath' + filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/prepare-package.ps1' + arguments: >- + -TgzFileName '$(tgzFileName)' + -WorkingDirectory '$(System.DefaultWorkingDirectory)' + -PublishDir '$(Build.ArtifactStagingDirectory)\npm-publish' # Inspect the .tgz contents and fail on obviously sensitive files. # This is a safety net (CredScan runs earlier, but the .tgz is the @@ -431,57 +316,10 @@ extends: - task: PowerShell@2 displayName: "Inspect .tgz contents" inputs: - targetType: 'inline' - script: | - $ErrorActionPreference = "Stop" - $publishDir = "$(Build.ArtifactStagingDirectory)\npm-publish" - $tgzPath = Get-ChildItem -Path $publishDir -Filter "*.tgz" | Select-Object -First 1 -ExpandProperty FullName - if (-not $tgzPath) { - Write-Error "[Error] No .tgz found in $publishDir" - exit 1 - } - - Write-Output "Listing contents of $tgzPath ..." - $entries = & tar -tzf $tgzPath - if ($LASTEXITCODE -ne 0) { - Write-Error "[Error] Failed to list tarball contents" - exit 1 - } - - $entries | ForEach-Object { Write-Output " $_" } - - # Patterns that should never appear in a published package. - $forbiddenPatterns = @( - '\.env(\..*)?$', - '(^|/)\.npmrc$', - '(^|/)\.netrc$', - '(^|/)id_rsa(\.pub)?$', - '(^|/)\.ssh/', - '\.pem$', - '\.pfx$', - '\.p12$', - '\.key$', - '(^|/)secrets?(\.json|\.yaml|\.yml|\.txt)$', - '(^|/)credentials?(\.json|\.yaml|\.yml|\.txt)$' - ) - - $violations = @() - foreach ($entry in $entries) { - foreach ($pattern in $forbiddenPatterns) { - if ($entry -match $pattern) { - $violations += " $entry (matched: $pattern)" - } - } - } - - if ($violations.Count -gt 0) { - Write-Error "[Error] Forbidden file(s) detected in the .tgz:" - $violations | ForEach-Object { Write-Error $_ } - exit 1 - } - - Write-Output "" - Write-Output "[Success] No forbidden files detected ($($entries.Count) entries scanned)" + targetType: 'filePath' + filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/inspect-tgz.ps1' + arguments: >- + -PublishDir '$(Build.ArtifactStagingDirectory)\npm-publish' # Publish to npmjs.org via ESRP Release # Docs: https://eng.ms/docs/microsoft-security/identity/trust-and-security-services/tss-release-distribute/tss-release-esrp-parent/release-onboarding @@ -534,31 +372,11 @@ extends: displayName: "Publish outcome summary" condition: always() inputs: - targetType: 'inline' - script: | - $mode = "${{ parameters.mode }}" - $esrpResult = "$(Agent.JobStatus)" # Succeeded / SucceededWithIssues / Failed / Canceled - - $lines = @() - $lines += "## 📤 ESRP publish outcome" - $lines += "" - $lines += "| Field | Value |" - $lines += "| --- | --- |" - $lines += "| Package | ``${{ parameters.packageName }}@${{ parameters.publishVersion }}`` |" - $lines += "| Mode | ``$mode`` |" - $lines += "| Job status | ``$esrpResult`` |" - $lines += "" - - if ($mode -eq 'test-esrp-auth') { - $lines += "> Smoke test: NOTHING was published. A **failure** here is expected (ESRP rejects the Maven content-type at validation). Inspect the ESRP task log to confirm the failure was the content-type rejection and NOT an auth/cert/Key Vault error." - } elseif ($mode -eq 'publish') { - if ($esrpResult -eq 'Succeeded') { - $lines += "> ✅ Published ``${{ parameters.packageName }}@${{ parameters.publishVersion }}`` to npmjs.org." - } else { - $lines += "> ❌ Publish did NOT complete successfully (status: $esrpResult). Check the ESRP task log." - } - } - - $summaryPath = "$(Build.ArtifactStagingDirectory)\publish-outcome-summary.md" - $lines -join "`n" | Out-File -FilePath $summaryPath -Encoding utf8 - Write-Host "##vso[task.uploadsummary]$summaryPath" + targetType: 'filePath' + filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/write-publish-outcome-summary.ps1' + arguments: >- + -PackageName '${{ parameters.packageName }}' + -PublishVersion '${{ parameters.publishVersion }}' + -Mode '${{ parameters.mode }}' + -JobStatus '$(Agent.JobStatus)' + -SummaryPath '$(Build.ArtifactStagingDirectory)\publish-outcome-summary.md' diff --git a/.azure-pipelines/scripts/find-tgz.ps1 b/.azure-pipelines/scripts/find-tgz.ps1 new file mode 100644 index 000000000..69e4ab3db --- /dev/null +++ b/.azure-pipelines/scripts/find-tgz.ps1 @@ -0,0 +1,53 @@ +<# +.SYNOPSIS + Finds the single .tgz produced for the selected package and exposes its + filename as the output variable 'tgzFileName'. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] [string]$PackageName, + # System.DefaultWorkingDirectory (where the build artifact was downloaded). + [Parameter(Mandatory = $true)] [string]$WorkingDirectory +) + +$ErrorActionPreference = 'Stop' + +$npmPackagesDir = Join-Path $WorkingDirectory 'npm-packages' + +Write-Output "Searching for .tgz files in: $npmPackagesDir" + +if (-not (Test-Path $npmPackagesDir)) { + Write-Error "[Error] npm-packages directory not found at $npmPackagesDir" + Write-Output "Available directories:" + Get-ChildItem -Path $WorkingDirectory -Directory | ForEach-Object { Write-Output $_.FullName } + exit 1 +} + +# List all available .tgz files +Write-Output "Available .tgz files:" +Get-ChildItem -Path $npmPackagesDir -Filter "*.tgz" | ForEach-Object { + $sizeKB = [math]::Round($_.Length / 1KB, 2) + Write-Output " $($_.Name) (${sizeKB} KB)" +} + +# Find the .tgz file matching the selected package. +# npm pack writes scope-flattened filenames, e.g.: +# documentdb-js-schema-analyzer-0.8.1.tgz +# microsoft-vscode-ext-webview-0.9.0-preview.tgz +# The PackageName parameter matches the folder name under packages/, +# which appears in the .tgz filename for all packages here. +$tgzFiles = Get-ChildItem -Path $npmPackagesDir -Filter "*$PackageName*.tgz" + +if ($tgzFiles.Count -eq 0) { + Write-Error "[Error] No .tgz file found matching package '$PackageName'" + exit 1 +} elseif ($tgzFiles.Count -gt 1) { + Write-Error "[Error] Multiple .tgz files found matching package '$PackageName': $($tgzFiles.Name -join ', ')" + exit 1 +} + +$tgzFile = $tgzFiles[0] +$tgzSize = [math]::Round($tgzFile.Length / 1KB, 2) +Write-Output "" +Write-Output "[Success] Found .tgz file: $($tgzFile.Name) (${tgzSize} KB)" +Write-Output "##vso[task.setvariable variable=tgzFileName;isOutput=true]$($tgzFile.Name)" diff --git a/.azure-pipelines/scripts/inspect-tgz.ps1 b/.azure-pipelines/scripts/inspect-tgz.ps1 new file mode 100644 index 000000000..9629a5f62 --- /dev/null +++ b/.azure-pipelines/scripts/inspect-tgz.ps1 @@ -0,0 +1,64 @@ +<# +.SYNOPSIS + Inspects the .tgz contents and fails on obviously sensitive files. + +.DESCRIPTION + Safety net that runs right before the artifact is uploaded to ESRP. + CredScan runs earlier in the build, but the .tgz is the actual public + artifact, so it is worth double-checking its exact contents here. +#> +[CmdletBinding()] +param( + # Folder containing the single .tgz prepared for publishing. + [Parameter(Mandatory = $true)] [string]$PublishDir +) + +$ErrorActionPreference = 'Stop' + +$tgzPath = Get-ChildItem -Path $PublishDir -Filter "*.tgz" | Select-Object -First 1 -ExpandProperty FullName +if (-not $tgzPath) { + Write-Error "[Error] No .tgz found in $PublishDir" + exit 1 +} + +Write-Output "Listing contents of $tgzPath ..." +$entries = & tar -tzf $tgzPath +if ($LASTEXITCODE -ne 0) { + Write-Error "[Error] Failed to list tarball contents" + exit 1 +} + +$entries | ForEach-Object { Write-Output " $_" } + +# Patterns that should never appear in a published package. +$forbiddenPatterns = @( + '\.env(\..*)?$', + '(^|/)\.npmrc$', + '(^|/)\.netrc$', + '(^|/)id_rsa(\.pub)?$', + '(^|/)\.ssh/', + '\.pem$', + '\.pfx$', + '\.p12$', + '\.key$', + '(^|/)secrets?(\.json|\.yaml|\.yml|\.txt)$', + '(^|/)credentials?(\.json|\.yaml|\.yml|\.txt)$' +) + +$violations = @() +foreach ($entry in $entries) { + foreach ($pattern in $forbiddenPatterns) { + if ($entry -match $pattern) { + $violations += " $entry (matched: $pattern)" + } + } +} + +if ($violations.Count -gt 0) { + Write-Error "[Error] Forbidden file(s) detected in the .tgz:" + $violations | ForEach-Object { Write-Error $_ } + exit 1 +} + +Write-Output "" +Write-Output "[Success] No forbidden files detected ($($entries.Count) entries scanned)" diff --git a/.azure-pipelines/scripts/pack-npm-packages.ps1 b/.azure-pipelines/scripts/pack-npm-packages.ps1 new file mode 100644 index 000000000..a347d3874 --- /dev/null +++ b/.azure-pipelines/scripts/pack-npm-packages.ps1 @@ -0,0 +1,101 @@ +<# +.SYNOPSIS + Packs the release-bound workspace packages into .tgz archives and emits an + ADO run-summary box listing what was packed. + +.DESCRIPTION + Used by .azure-pipelines/build-npm-packages.yml. Only the packages listed in + $publishablePackages are packed and shipped as release artifacts. The list + MUST stay in sync with the release picklist in release-npm-packages.yml. +#> +[CmdletBinding()] +param( + # Repository root (Build.SourcesDirectory). + [Parameter(Mandatory = $true)] + [string]$SourcesDirectory, + + # Base output directory (ob_outputDirectory). The .tgz files are written to + # \npm-packages. + [Parameter(Mandatory = $true)] + [string]$OutputDirectory, + + # Path to write the run-summary markdown file that is uploaded to the ADO run. + [Parameter(Mandatory = $true)] + [string]$SummaryPath +) + +$ErrorActionPreference = 'Stop' + +# Only pack the packages that we actually release via ESRP. +# See release-npm-packages.yml for the matching picklist. +$publishablePackages = @( + 'vscode-ext-webview' +) + +$npmPackagesDir = Join-Path $OutputDirectory 'npm-packages' +New-Item -ItemType Directory -Force -Path $npmPackagesDir | Out-Null + +Write-Output "Packing release-bound workspace packages..." +Write-Output "" + +foreach ($pkgDirName in $publishablePackages) { + $packageDir = Join-Path (Join-Path $SourcesDirectory 'packages') $pkgDirName + $packageJsonPath = Join-Path $packageDir 'package.json' + + if (-not (Test-Path $packageJsonPath)) { + Write-Error "[Error] Configured publishable package '$pkgDirName' not found at $packageJsonPath" + exit 1 + } + + $packageJson = Get-Content $packageJsonPath | ConvertFrom-Json + $pkgName = $packageJson.name + $pkgVersion = $packageJson.version + + if ($packageJson.private -eq $true) { + Write-Error "[Error] Configured publishable package '$pkgName' is marked private in package.json" + exit 1 + } + + Write-Output "Packing $pkgName@$pkgVersion from $pkgDirName..." + npm pack --pack-destination $npmPackagesDir --workspace "packages/$pkgDirName" + + if ($LASTEXITCODE -ne 0) { + Write-Error "[Error] Failed to pack $pkgName" + exit 1 + } + + Write-Output "[OK] Packed $pkgName@$pkgVersion" + Write-Output "" +} + +Write-Output "=== Generated .tgz files ===" +Get-ChildItem -Path $npmPackagesDir -Filter "*.tgz" | ForEach-Object { + $sizeKB = [math]::Round($_.Length / 1KB, 2) + Write-Output " $($_.Name) (${sizeKB} KB)" +} + +$tgzCount = (Get-ChildItem -Path $npmPackagesDir -Filter "*.tgz").Count +Write-Output "" +Write-Output "Total packages: $tgzCount" + +if ($tgzCount -ne $publishablePackages.Count) { + Write-Error "[Error] Expected $($publishablePackages.Count) .tgz file(s), found $tgzCount" + exit 1 +} + +# Emit a run summary box (rendered at the top of the ADO run, Extensions tab) +# so the packed artifacts are visible without opening the step log. +$summaryLines = @() +$summaryLines += "## 📦 Packed npm packages" +$summaryLines += "" +$summaryLines += "| Package | Version | .tgz file | Size |" +$summaryLines += "| --- | --- | --- | --- |" +foreach ($pkgDirName in $publishablePackages) { + $packageJson = Get-Content (Join-Path (Join-Path $SourcesDirectory 'packages') "$pkgDirName\package.json") | ConvertFrom-Json + $tgz = Get-ChildItem -Path $npmPackagesDir -Filter "*$pkgDirName*.tgz" | Select-Object -First 1 + $sizeKB = if ($tgz) { [math]::Round($tgz.Length / 1KB, 2) } else { 'n/a' } + $tgzName = if ($tgz) { $tgz.Name } else { 'MISSING' } + $summaryLines += "| ``$($packageJson.name)`` | $($packageJson.version) | ``$tgzName`` | ${sizeKB} KB |" +} +$summaryLines -join "`n" | Out-File -FilePath $SummaryPath -Encoding utf8 +Write-Host "##vso[task.uploadsummary]$SummaryPath" diff --git a/.azure-pipelines/scripts/prepare-package.ps1 b/.azure-pipelines/scripts/prepare-package.ps1 new file mode 100644 index 000000000..4531add0e --- /dev/null +++ b/.azure-pipelines/scripts/prepare-package.ps1 @@ -0,0 +1,30 @@ +<# +.SYNOPSIS + Copies only the selected .tgz into an isolated folder so ESRP publishes + exactly one artifact and nothing else. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] [string]$TgzFileName, + # System.DefaultWorkingDirectory (where the build artifact was downloaded). + [Parameter(Mandatory = $true)] [string]$WorkingDirectory, + # Folder to place the single .tgz into (handed to ESRP as folderlocation). + [Parameter(Mandatory = $true)] [string]$PublishDir +) + +$ErrorActionPreference = 'Stop' + +$npmPackagesDir = Join-Path $WorkingDirectory 'npm-packages' +New-Item -ItemType Directory -Force -Path $PublishDir | Out-Null + +$sourcePath = Join-Path $npmPackagesDir $TgzFileName +if (-not (Test-Path $sourcePath)) { + Write-Error "[Error] .tgz file not found: $sourcePath" + exit 1 +} + +Copy-Item -Path $sourcePath -Destination $PublishDir +Write-Output "Prepared for publishing:" +Get-ChildItem -Path $PublishDir | ForEach-Object { + Write-Output " $($_.Name) ($([math]::Round($_.Length / 1KB, 2)) KB)" +} diff --git a/.azure-pipelines/scripts/set-release-build-number.ps1 b/.azure-pipelines/scripts/set-release-build-number.ps1 new file mode 100644 index 000000000..9b5bc8a18 --- /dev/null +++ b/.azure-pipelines/scripts/set-release-build-number.ps1 @@ -0,0 +1,26 @@ +<# +.SYNOPSIS + Sets a descriptive ADO run (build) number that encodes the package, version + and run mode, e.g. npm-vscode-ext-webview-0.9.0-preview-esrptest-12345. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] [string]$PackageName, + [Parameter(Mandatory = $true)] [string]$PublishVersion, + [Parameter(Mandatory = $true)] [string]$Mode, + [Parameter(Mandatory = $true)] [string]$BuildId +) + +$ErrorActionPreference = 'Stop' + +$modeSuffix = switch ($Mode) { + 'validate-only' { '-validate' } + 'test-esrp-auth' { '-esrptest' } + 'publish' { '' } + default { "-$Mode" } +} + +$newBuildNumber = "npm-${PackageName}-${PublishVersion}${modeSuffix}-${BuildId}" +Write-Output "Mode: $Mode" +Write-Output "Setting build number to: $newBuildNumber" +Write-Output "##vso[build.updatebuildnumber]$newBuildNumber" diff --git a/.azure-pipelines/scripts/show-source-build-metadata.ps1 b/.azure-pipelines/scripts/show-source-build-metadata.ps1 new file mode 100644 index 000000000..42ecdfc04 --- /dev/null +++ b/.azure-pipelines/scripts/show-source-build-metadata.ps1 @@ -0,0 +1,24 @@ +<# +.SYNOPSIS + Prints the source build (npmBuild pipeline resource) metadata so an operator + can confirm the release is coming from the intended commit/branch. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] [string]$PipelineName, + [Parameter(Mandatory = $true)] [string]$RunId, + [Parameter(Mandatory = $true)] [string]$RunName, + [Parameter(Mandatory = $true)] [string]$SourceBranch, + [Parameter(Mandatory = $true)] [string]$SourceCommit, + [Parameter(Mandatory = $true)] [string]$SourceProvider +) + +$ErrorActionPreference = 'Stop' + +Write-Output "Releasing from npmBuild pipeline run:" +Write-Output " Pipeline : $PipelineName" +Write-Output " Run id : $RunId" +Write-Output " Run name : $RunName" +Write-Output " Source branch : $SourceBranch" +Write-Output " Source commit : $SourceCommit" +Write-Output " Source provider : $SourceProvider" diff --git a/.azure-pipelines/scripts/verify-package-version.ps1 b/.azure-pipelines/scripts/verify-package-version.ps1 new file mode 100644 index 000000000..4e144d6e3 --- /dev/null +++ b/.azure-pipelines/scripts/verify-package-version.ps1 @@ -0,0 +1,54 @@ +<# +.SYNOPSIS + Verifies the packed package.json version matches the intended publish + version, validates semver, and refuses private packages. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] [string]$PackageName, + [Parameter(Mandatory = $true)] [string]$PublishVersion, + # System.DefaultWorkingDirectory (where the build artifact was downloaded). + [Parameter(Mandatory = $true)] [string]$WorkingDirectory +) + +$ErrorActionPreference = 'Stop' + +$packageJsonPath = Join-Path $WorkingDirectory "packages/$PackageName/package.json" +if (-not (Test-Path $packageJsonPath)) { + Write-Error "[Error] package.json not found at $packageJsonPath" + Write-Output "Available files:" + Get-ChildItem -Path $WorkingDirectory -Recurse -Filter "package.json" | ForEach-Object { Write-Output $_.FullName } + exit 1 +} + +$packageJson = Get-Content $packageJsonPath | ConvertFrom-Json +$actualVersion = $packageJson.version +$fullPackageName = $packageJson.name + +Write-Output "Package: $fullPackageName" +Write-Output "Version in package.json: $actualVersion" +Write-Output "Expected publish version: $PublishVersion" + +# Validate semantic version format +$semverPattern = '^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$' +if ($actualVersion -notmatch $semverPattern) { + Write-Error "[Error] Version in package.json ($actualVersion) is not a valid semantic version" + exit 1 +} +if ($PublishVersion -notmatch $semverPattern) { + Write-Error "[Error] Publish version ($PublishVersion) is not a valid semantic version" + exit 1 +} + +if ($actualVersion -eq $PublishVersion) { + Write-Output "[Success] Version matches. Proceeding with release of $fullPackageName@$PublishVersion" +} else { + Write-Error "[Error] Publish version '$PublishVersion' does not match version in package.json '$actualVersion'. Cancelling release." + exit 1 +} + +# ESRP Release cannot publish private packages +if ($packageJson.private -eq $true) { + Write-Error "[Error] Package $fullPackageName is marked as private in package.json. Cannot publish to npm." + exit 1 +} diff --git a/.azure-pipelines/scripts/write-publish-outcome-summary.ps1 b/.azure-pipelines/scripts/write-publish-outcome-summary.ps1 new file mode 100644 index 000000000..72b195db8 --- /dev/null +++ b/.azure-pipelines/scripts/write-publish-outcome-summary.ps1 @@ -0,0 +1,43 @@ +<# +.SYNOPSIS + Emits the post-publish run-summary box describing the ESRP outcome. + +.DESCRIPTION + Invoked with condition: always() so the summary is present whether the ESRP + task succeeded or failed. In 'test-esrp-auth' mode a failure is the EXPECTED + result (ESRP rejects the Maven content-type), so it is framed accordingly. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] [string]$PackageName, + [Parameter(Mandatory = $true)] [string]$PublishVersion, + [Parameter(Mandatory = $true)] [string]$Mode, + # Agent.JobStatus: Succeeded / SucceededWithIssues / Failed / Canceled. + [Parameter(Mandatory = $true)] [string]$JobStatus, + [Parameter(Mandatory = $true)] [string]$SummaryPath +) + +$ErrorActionPreference = 'Stop' + +$lines = @() +$lines += "## 📤 ESRP publish outcome" +$lines += "" +$lines += "| Field | Value |" +$lines += "| --- | --- |" +$lines += "| Package | ``$PackageName@$PublishVersion`` |" +$lines += "| Mode | ``$Mode`` |" +$lines += "| Job status | ``$JobStatus`` |" +$lines += "" + +if ($Mode -eq 'test-esrp-auth') { + $lines += "> Smoke test: NOTHING was published. A **failure** here is expected (ESRP rejects the Maven content-type at validation). Inspect the ESRP task log to confirm the failure was the content-type rejection and NOT an auth/cert/Key Vault error." +} elseif ($Mode -eq 'publish') { + if ($JobStatus -eq 'Succeeded') { + $lines += "> ✅ Published ``$PackageName@$PublishVersion`` to npmjs.org." + } else { + $lines += "> ❌ Publish did NOT complete successfully (status: $JobStatus). Check the ESRP task log." + } +} + +$lines -join "`n" | Out-File -FilePath $SummaryPath -Encoding utf8 +Write-Host "##vso[task.uploadsummary]$SummaryPath" diff --git a/.azure-pipelines/scripts/write-release-summary.ps1 b/.azure-pipelines/scripts/write-release-summary.ps1 new file mode 100644 index 000000000..76a2b2ebb --- /dev/null +++ b/.azure-pipelines/scripts/write-release-summary.ps1 @@ -0,0 +1,44 @@ +<# +.SYNOPSIS + Emits the pre-publish run-summary box describing the release intent + (package, version, mode, source build) for approvers. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] [string]$PackageName, + [Parameter(Mandatory = $true)] [string]$PublishVersion, + [Parameter(Mandatory = $true)] [string]$Mode, + [Parameter(Mandatory = $true)] [string]$TgzFileName, + [Parameter(Mandatory = $true)] [string]$SourceRunName, + [Parameter(Mandatory = $true)] [string]$SourceRunId, + [Parameter(Mandatory = $true)] [string]$SourceBranch, + [Parameter(Mandatory = $true)] [string]$SourceCommit, + [Parameter(Mandatory = $true)] [string]$SummaryPath +) + +$ErrorActionPreference = 'Stop' + +$modeNote = switch ($Mode) { + 'validate-only' { 'Validation only - ESRP is NOT contacted and nothing is published.' } + 'test-esrp-auth' { 'Smoke test - exercises ESRP auth then fails at content validation. Nothing is published.' } + 'publish' { '**REAL PUBLISH** - this run pushes the package to npmjs.org.' } + default { "Unknown mode: $Mode" } +} + +$lines = @() +$lines += "## 🚀 npm release run" +$lines += "" +$lines += "| Field | Value |" +$lines += "| --- | --- |" +$lines += "| Package | ``$PackageName`` |" +$lines += "| Version | ``$PublishVersion`` |" +$lines += "| Mode | ``$Mode`` |" +$lines += "| .tgz | ``$TgzFileName`` |" +$lines += "| Source build | $SourceRunName (run $SourceRunId) |" +$lines += "| Source branch | $SourceBranch |" +$lines += "| Source commit | $SourceCommit |" +$lines += "" +$lines += "> $modeNote" + +$lines -join "`n" | Out-File -FilePath $SummaryPath -Encoding utf8 +Write-Host "##vso[task.uploadsummary]$SummaryPath" From 1ecf150d356b0c2e7eb02408c8c567013e75377b Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 6 Jul 2026 14:07:34 +0000 Subject: [PATCH 11/25] fix(ci): remove emoji from pipeline .ps1 scripts (Windows PS 5.1 encoding) The ADO Windows agent runs Windows PowerShell 5.1, which reads .ps1 files without a BOM as ANSI/Windows-1252. The UTF-8 emoji bytes in the summary markdown headers were misread and corrupted string parsing, failing the 'Pack npm packages' step with 'Missing expression after unary operator'. (The pre-extraction inline scripts worked because the PowerShell@2 task writes inline scripts to a temp file WITH a BOM; our checked-out files have none.) Keep pipeline scripts ASCII-only. Summary boxes render fine without the emoji. --- .azure-pipelines/scripts/pack-npm-packages.ps1 | 2 +- .azure-pipelines/scripts/write-publish-outcome-summary.ps1 | 6 +++--- .azure-pipelines/scripts/write-release-summary.ps1 | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.azure-pipelines/scripts/pack-npm-packages.ps1 b/.azure-pipelines/scripts/pack-npm-packages.ps1 index a347d3874..e5d3b00c2 100644 --- a/.azure-pipelines/scripts/pack-npm-packages.ps1 +++ b/.azure-pipelines/scripts/pack-npm-packages.ps1 @@ -86,7 +86,7 @@ if ($tgzCount -ne $publishablePackages.Count) { # Emit a run summary box (rendered at the top of the ADO run, Extensions tab) # so the packed artifacts are visible without opening the step log. $summaryLines = @() -$summaryLines += "## 📦 Packed npm packages" +$summaryLines += "## Packed npm packages" $summaryLines += "" $summaryLines += "| Package | Version | .tgz file | Size |" $summaryLines += "| --- | --- | --- | --- |" diff --git a/.azure-pipelines/scripts/write-publish-outcome-summary.ps1 b/.azure-pipelines/scripts/write-publish-outcome-summary.ps1 index 72b195db8..e989bb3e4 100644 --- a/.azure-pipelines/scripts/write-publish-outcome-summary.ps1 +++ b/.azure-pipelines/scripts/write-publish-outcome-summary.ps1 @@ -20,7 +20,7 @@ param( $ErrorActionPreference = 'Stop' $lines = @() -$lines += "## 📤 ESRP publish outcome" +$lines += "## ESRP publish outcome" $lines += "" $lines += "| Field | Value |" $lines += "| --- | --- |" @@ -33,9 +33,9 @@ if ($Mode -eq 'test-esrp-auth') { $lines += "> Smoke test: NOTHING was published. A **failure** here is expected (ESRP rejects the Maven content-type at validation). Inspect the ESRP task log to confirm the failure was the content-type rejection and NOT an auth/cert/Key Vault error." } elseif ($Mode -eq 'publish') { if ($JobStatus -eq 'Succeeded') { - $lines += "> ✅ Published ``$PackageName@$PublishVersion`` to npmjs.org." + $lines += "> [OK] Published ``$PackageName@$PublishVersion`` to npmjs.org." } else { - $lines += "> ❌ Publish did NOT complete successfully (status: $JobStatus). Check the ESRP task log." + $lines += "> [FAILED] Publish did NOT complete successfully (status: $JobStatus). Check the ESRP task log." } } diff --git a/.azure-pipelines/scripts/write-release-summary.ps1 b/.azure-pipelines/scripts/write-release-summary.ps1 index 76a2b2ebb..5a2bb6a82 100644 --- a/.azure-pipelines/scripts/write-release-summary.ps1 +++ b/.azure-pipelines/scripts/write-release-summary.ps1 @@ -26,7 +26,7 @@ $modeNote = switch ($Mode) { } $lines = @() -$lines += "## 🚀 npm release run" +$lines += "## npm release run" $lines += "" $lines += "| Field | Value |" $lines += "| --- | --- |" From 689688184c3531ce2bae015602c0d57298e96fad Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 6 Jul 2026 14:09:49 +0000 Subject: [PATCH 12/25] ci: set descriptive ADO run names for npm pipelines Add a root 'name:' (npm-build-$(Date).$(Rev) / npm-release-$(Date).$(Rev)) and appendCommitMessageToRunName: false to both npm pipelines, matching the style of build.yml so the ADO run list is readable. The release pipeline's 'Set build number' step still refines its run name at runtime. --- .azure-pipelines/build-npm-packages.yml | 5 +++++ .azure-pipelines/release-npm-packages.yml | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/.azure-pipelines/build-npm-packages.yml b/.azure-pipelines/build-npm-packages.yml index e150d984f..1df612365 100644 --- a/.azure-pipelines/build-npm-packages.yml +++ b/.azure-pipelines/build-npm-packages.yml @@ -8,6 +8,11 @@ # so the monorepo stays healthy, but they are NOT packed - they are local- # only deps and not released through this pipeline. +# Run name shown in ADO (e.g. npm-build-2026-07-06.1). Keeps the run list +# readable and stops the commit subject being appended to the run name. +name: npm-build-$(Date:yyyy-MM-dd).$(Rev:r) +appendCommitMessageToRunName: false + # Manual-only build. The build/test work here is already covered on every push # by the GitHub Actions CI (.github/workflows/main.yml) and the main extension # ADO pipeline (.azure-pipelines/build.yml, which also runs Component Governance diff --git a/.azure-pipelines/release-npm-packages.yml b/.azure-pipelines/release-npm-packages.yml index ffc79ee39..a3c0a5a9d 100644 --- a/.azure-pipelines/release-npm-packages.yml +++ b/.azure-pipelines/release-npm-packages.yml @@ -32,6 +32,13 @@ # @documentdb-js. Packages outside the allow-list will fail to publish # until ESRP is configured to support that scope (contact esrprelpm@). +# Initial run name shown in ADO. The "Set build number" step refines this at +# runtime to npm----; this is just the value +# used before that step runs. appendCommitMessageToRunName keeps the run name +# clean (no commit subject appended). +name: npm-release-$(Date:yyyy-MM-dd).$(Rev:r) +appendCommitMessageToRunName: false + trigger: none pr: none From 874b04129d5f224335d543b694a8169cb7ea9bae Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 6 Jul 2026 14:11:05 +0000 Subject: [PATCH 13/25] fix(ci): standardize display names in YAML pipeline files --- .azure-pipelines/build-npm-packages.yml | 16 +- .azure-pipelines/release-npm-packages.yml | 256 +++++++++++----------- 2 files changed, 136 insertions(+), 136 deletions(-) diff --git a/.azure-pipelines/build-npm-packages.yml b/.azure-pipelines/build-npm-packages.yml index 1df612365..6fd2405bb 100644 --- a/.azure-pipelines/build-npm-packages.yml +++ b/.azure-pipelines/build-npm-packages.yml @@ -10,7 +10,7 @@ # Run name shown in ADO (e.g. npm-build-2026-07-06.1). Keeps the run list # readable and stops the commit subject being appended to the run name. -name: npm-build-$(Date:yyyy-MM-dd).$(Rev:r) +name: vscode-documentdb-npm-build-$(Date:yyyy-MM-dd).$(Rev:r) appendCommitMessageToRunName: false # Manual-only build. The build/test work here is already covered on every push @@ -95,18 +95,18 @@ extends: displayName: 'Component Governance - Component Detection' - task: NodeTool@0 - displayName: "Use Node.js" + displayName: 'Use Node.js' inputs: versionSource: fromFile versionFilePath: .nvmrc - task: npmAuthenticate@0 - displayName: "Authenticate to npm registry" + displayName: 'Authenticate to npm registry' inputs: workingFile: '$(Build.SourcesDirectory)/.azure-pipelines/.npmrc' - task: Npm@1 - displayName: "Install Dependencies" + displayName: 'Install Dependencies' condition: succeeded() inputs: command: custom @@ -114,7 +114,7 @@ extends: workingDir: $(Build.SourcesDirectory) - task: Npm@1 - displayName: "Build Workspace Packages" + displayName: 'Build Workspace Packages' condition: succeeded() inputs: command: custom @@ -122,7 +122,7 @@ extends: workingDir: $(Build.SourcesDirectory) - task: Npm@1 - displayName: "Test Workspace Packages" + displayName: 'Test Workspace Packages' condition: succeeded() inputs: command: custom @@ -130,7 +130,7 @@ extends: workingDir: $(Build.SourcesDirectory) - task: PowerShell@2 - displayName: "Pack npm packages" + displayName: 'Pack npm packages' condition: succeeded() inputs: targetType: 'filePath' @@ -141,7 +141,7 @@ extends: -SummaryPath '$(Build.ArtifactStagingDirectory)\pack-summary.md' - task: CopyFiles@2 - displayName: "Copy package metadata to staging" + displayName: 'Copy package metadata to staging' condition: succeeded() inputs: Contents: | diff --git a/.azure-pipelines/release-npm-packages.yml b/.azure-pipelines/release-npm-packages.yml index a3c0a5a9d..52b0d9a5a 100644 --- a/.azure-pipelines/release-npm-packages.yml +++ b/.azure-pipelines/release-npm-packages.yml @@ -36,7 +36,7 @@ # runtime to npm----; this is just the value # used before that step runs. appendCommitMessageToRunName keeps the run name # clean (no commit subject appended). -name: npm-release-$(Date:yyyy-MM-dd).$(Rev:r) +name: vscode-documentdb-npm-release-$(Date:yyyy-MM-dd).$(Rev:r) appendCommitMessageToRunName: false trigger: none @@ -175,7 +175,7 @@ extends: value: Test jobs: - job: ReleaseValidation - displayName: "Validate Artifacts" + displayName: 'Validate Artifacts' templateContext: inputs: - input: pipelineArtifact @@ -190,7 +190,7 @@ extends: # Show which build run we are about to release from. The operator should # confirm this matches the intended source commit/branch before approving. - task: PowerShell@2 - displayName: "Show source build metadata" + displayName: 'Show source build metadata' inputs: targetType: 'filePath' filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/show-source-build-metadata.ps1' @@ -204,7 +204,7 @@ extends: # Set a descriptive build number - task: PowerShell@2 - displayName: "Set build number" + displayName: 'Set build number' inputs: targetType: 'filePath' filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/set-release-build-number.ps1' @@ -216,7 +216,7 @@ extends: # Verify the version in package.json matches the intended publish version - task: PowerShell@2 - displayName: "Verify package version" + displayName: 'Verify package version' inputs: targetType: 'filePath' filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/verify-package-version.ps1' @@ -227,7 +227,7 @@ extends: # Find the .tgz file for the selected package - task: PowerShell@2 - displayName: "Find .tgz file" + displayName: 'Find .tgz file' name: findTgzStep inputs: targetType: 'filePath' @@ -240,7 +240,7 @@ extends: # release intent - package, version, mode, and source build - is # visible to approvers without digging through step logs. - task: PowerShell@2 - displayName: "Publish run summary" + displayName: 'Publish run summary' condition: succeeded() inputs: targetType: 'filePath' @@ -261,129 +261,129 @@ extends: # runtime condition: even a future regression that drops the condition # cannot accidentally publish in validate-only mode. - ${{ if or(eq(parameters.mode, 'publish'), eq(parameters.mode, 'test-esrp-auth')) }}: - - job: PublishPackage - displayName: ${{ format('Publish ({0})', parameters.mode) }} - dependsOn: ReleaseValidation - # EsrpRelease runs with waitforreleasecompletion: true, which blocks - # on the ESRP submission. Cap the job so a stalled ESRP approval or - # submission cannot pin a release agent indefinitely. - timeoutInMinutes: 120 - pool: - type: release - variables: - tgzFileName: $[ dependencies.ReleaseValidation.outputs['findTgzStep.tgzFileName'] ] - ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' - templateContext: - inputs: - - input: pipelineArtifact - pipeline: npmBuild - targetPath: $(System.DefaultWorkingDirectory) - artifactName: drop_BuildStage_Main - steps: - # Loud banner for the smoke-test mode so logs are unambiguous - - ${{ if eq(parameters.mode, 'test-esrp-auth') }}: - - task: PowerShell@2 - displayName: "SMOKE TEST MODE - not publishing" - inputs: - targetType: 'inline' - script: | - Write-Host "##[warning]Running in 'test-esrp-auth' mode." - Write-Host "##[warning]Submitting to ESRP with contenttype='Maven' to validate auth + cert + KV wiring." - Write-Host "##[warning]ESRP is EXPECTED TO FAIL at the final content-validation step." - Write-Host "##[warning]Nothing will be published to npmjs.org." - Write-Host "##[warning]After the run, inspect the ESRP task logs to confirm the failure" - Write-Host "##[warning]is the expected Maven content-type rejection, NOT an auth/cert error." + - job: PublishPackage + displayName: ${{ format('Publish ({0})', parameters.mode) }} + dependsOn: ReleaseValidation + # EsrpRelease runs with waitforreleasecompletion: true, which blocks + # on the ESRP submission. Cap the job so a stalled ESRP approval or + # submission cannot pin a release agent indefinitely. + timeoutInMinutes: 120 + pool: + type: release + variables: + tgzFileName: $[ dependencies.ReleaseValidation.outputs['findTgzStep.tgzFileName'] ] + ob_outputDirectory: '$(Build.ArtifactStagingDirectory)' + templateContext: + inputs: + - input: pipelineArtifact + pipeline: npmBuild + targetPath: $(System.DefaultWorkingDirectory) + artifactName: drop_BuildStage_Main + steps: + # Loud banner for the smoke-test mode so logs are unambiguous + - ${{ if eq(parameters.mode, 'test-esrp-auth') }}: + - task: PowerShell@2 + displayName: 'SMOKE TEST MODE - not publishing' + inputs: + targetType: 'inline' + script: | + Write-Host "##[warning]Running in 'test-esrp-auth' mode." + Write-Host "##[warning]Submitting to ESRP with contenttype='Maven' to validate auth + cert + KV wiring." + Write-Host "##[warning]ESRP is EXPECTED TO FAIL at the final content-validation step." + Write-Host "##[warning]Nothing will be published to npmjs.org." + Write-Host "##[warning]After the run, inspect the ESRP task logs to confirm the failure" + Write-Host "##[warning]is the expected Maven content-type rejection, NOT an auth/cert error." - # Loud banner for the REAL publish mode so the log is unambiguous - # about the fact that this run WILL push to npmjs.org. - - ${{ if eq(parameters.mode, 'publish') }}: - - task: PowerShell@2 - displayName: "REAL PUBLISH MODE - will push to npmjs.org" - inputs: - targetType: 'inline' - script: | - Write-Host "##[warning]Running in 'publish' mode - this is a REAL RELEASE." - Write-Host "##[warning]Publishing ${{ parameters.packageName }}@${{ parameters.publishVersion }} to npmjs.org via ESRP." - Write-Host "##[warning]This action is PUBLIC and cannot be undone (npm unpublish is heavily restricted)." + # Loud banner for the REAL publish mode so the log is unambiguous + # about the fact that this run WILL push to npmjs.org. + - ${{ if eq(parameters.mode, 'publish') }}: + - task: PowerShell@2 + displayName: 'REAL PUBLISH MODE - will push to npmjs.org' + inputs: + targetType: 'inline' + script: | + Write-Host "##[warning]Running in 'publish' mode - this is a REAL RELEASE." + Write-Host "##[warning]Publishing ${{ parameters.packageName }}@${{ parameters.publishVersion }} to npmjs.org via ESRP." + Write-Host "##[warning]This action is PUBLIC and cannot be undone (npm unpublish is heavily restricted)." - # Copy only the selected .tgz to an isolated folder for ESRP - - task: PowerShell@2 - displayName: "Prepare package for publishing" - inputs: - targetType: 'filePath' - filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/prepare-package.ps1' - arguments: >- - -TgzFileName '$(tgzFileName)' - -WorkingDirectory '$(System.DefaultWorkingDirectory)' - -PublishDir '$(Build.ArtifactStagingDirectory)\npm-publish' + # Copy only the selected .tgz to an isolated folder for ESRP + - task: PowerShell@2 + displayName: 'Prepare package for publishing' + inputs: + targetType: 'filePath' + filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/prepare-package.ps1' + arguments: >- + -TgzFileName '$(tgzFileName)' + -WorkingDirectory '$(System.DefaultWorkingDirectory)' + -PublishDir '$(Build.ArtifactStagingDirectory)\npm-publish' - # Inspect the .tgz contents and fail on obviously sensitive files. - # This is a safety net (CredScan runs earlier, but the .tgz is the - # actual public artifact - worth double-checking right before upload). - - task: PowerShell@2 - displayName: "Inspect .tgz contents" - inputs: - targetType: 'filePath' - filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/inspect-tgz.ps1' - arguments: >- - -PublishDir '$(Build.ArtifactStagingDirectory)\npm-publish' + # Inspect the .tgz contents and fail on obviously sensitive files. + # This is a safety net (CredScan runs earlier, but the .tgz is the + # actual public artifact - worth double-checking right before upload). + - task: PowerShell@2 + displayName: 'Inspect .tgz contents' + inputs: + targetType: 'filePath' + filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/inspect-tgz.ps1' + arguments: >- + -PublishDir '$(Build.ArtifactStagingDirectory)\npm-publish' - # Publish to npmjs.org via ESRP Release - # Docs: https://eng.ms/docs/microsoft-security/identity/trust-and-security-services/tss-release-distribute/tss-release-esrp-parent/release-onboarding - # - # Authentication: the Azure RM service connection 'DOCDBEXT_ESRP_RELEASE' - # uses Workload Identity Federation, which handles auth to Azure / Key - # Vault (so no separate auth cert is needed). Its managed identity is - # granted 'Key Vault Certificate User' on the Key Vault that holds the - # ESRP signing cert. The task reads that signing cert from the vault and - # uses it to sign the release request to ESRP. - # - # See the file header for the pipeline variables required. - # - # When 'mode == test-esrp-auth', contenttype is overridden to 'Maven' - # so ESRP exercises the full auth/cert/KV path then rejects the - # payload at content validation. NOTHING is published in that mode. - - task: EsrpRelease@11 - displayName: ${{ format('ESRP Release ({0})', parameters.mode) }} - inputs: - connectedservicename: 'DOCDBEXT_ESRP_RELEASE' - usemanagedidentity: true - keyvaultname: '$(EsrpKeyVaultName)' - signcertname: '$(EsrpSignCertName)' - clientid: '$(EsrpClientId)' - intent: 'PackageDistribution' - ${{ if eq(parameters.mode, 'test-esrp-auth') }}: - contenttype: 'Maven' - ${{ else }}: - contenttype: 'npm' - contentsource: 'Folder' - folderlocation: '$(Build.ArtifactStagingDirectory)/npm-publish' - waitforreleasecompletion: true - owners: '$(EsrpReleaseOwners)' - approvers: '$(EsrpReleaseApprovers)' - serviceendpointurl: 'https://api.esrp.microsoft.com' - mainpublisher: 'ESRPRELPACMAN' - # domaintenantid for the ESRPRELPACMAN OSS publisher. - # ESRP OSS docs (npm/PyPI/Maven/Crates/MCP/WinGet) all use - # the PME tenant 975f013f for this shared publisher. The - # ErrorCode 2252 "Tenant does not have access to the given - # Main Publisher" is resolved by ESRP mapping our client id - # to ESRPRELPACMAN (done) combined with using the publisher's - # tenant id here. - domaintenantid: '975f013f-7f24-47e8-a7d3-abc4752bf346' + # Publish to npmjs.org via ESRP Release + # Docs: https://eng.ms/docs/microsoft-security/identity/trust-and-security-services/tss-release-distribute/tss-release-esrp-parent/release-onboarding + # + # Authentication: the Azure RM service connection 'DOCDBEXT_ESRP_RELEASE' + # uses Workload Identity Federation, which handles auth to Azure / Key + # Vault (so no separate auth cert is needed). Its managed identity is + # granted 'Key Vault Certificate User' on the Key Vault that holds the + # ESRP signing cert. The task reads that signing cert from the vault and + # uses it to sign the release request to ESRP. + # + # See the file header for the pipeline variables required. + # + # When 'mode == test-esrp-auth', contenttype is overridden to 'Maven' + # so ESRP exercises the full auth/cert/KV path then rejects the + # payload at content validation. NOTHING is published in that mode. + - task: EsrpRelease@11 + displayName: ${{ format('ESRP Release ({0})', parameters.mode) }} + inputs: + connectedservicename: 'DOCDBEXT_ESRP_RELEASE' + usemanagedidentity: true + keyvaultname: '$(EsrpKeyVaultName)' + signcertname: '$(EsrpSignCertName)' + clientid: '$(EsrpClientId)' + intent: 'PackageDistribution' + ${{ if eq(parameters.mode, 'test-esrp-auth') }}: + contenttype: 'Maven' + ${{ else }}: + contenttype: 'npm' + contentsource: 'Folder' + folderlocation: '$(Build.ArtifactStagingDirectory)/npm-publish' + waitforreleasecompletion: true + owners: '$(EsrpReleaseOwners)' + approvers: '$(EsrpReleaseApprovers)' + serviceendpointurl: 'https://api.esrp.microsoft.com' + mainpublisher: 'ESRPRELPACMAN' + # domaintenantid for the ESRPRELPACMAN OSS publisher. + # ESRP OSS docs (npm/PyPI/Maven/Crates/MCP/WinGet) all use + # the PME tenant 975f013f for this shared publisher. The + # ErrorCode 2252 "Tenant does not have access to the given + # Main Publisher" is resolved by ESRP mapping our client id + # to ESRPRELPACMAN (done) combined with using the publisher's + # tenant id here. + domaintenantid: '975f013f-7f24-47e8-a7d3-abc4752bf346' - # Emit a run summary box describing the outcome. Runs with always() - # so the summary is present whether the ESRP task succeeded or failed - # (a failure in test-esrp-auth mode is the EXPECTED result). - - task: PowerShell@2 - displayName: "Publish outcome summary" - condition: always() - inputs: - targetType: 'filePath' - filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/write-publish-outcome-summary.ps1' - arguments: >- - -PackageName '${{ parameters.packageName }}' - -PublishVersion '${{ parameters.publishVersion }}' - -Mode '${{ parameters.mode }}' - -JobStatus '$(Agent.JobStatus)' - -SummaryPath '$(Build.ArtifactStagingDirectory)\publish-outcome-summary.md' + # Emit a run summary box describing the outcome. Runs with always() + # so the summary is present whether the ESRP task succeeded or failed + # (a failure in test-esrp-auth mode is the EXPECTED result). + - task: PowerShell@2 + displayName: 'Publish outcome summary' + condition: always() + inputs: + targetType: 'filePath' + filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/write-publish-outcome-summary.ps1' + arguments: >- + -PackageName '${{ parameters.packageName }}' + -PublishVersion '${{ parameters.publishVersion }}' + -Mode '${{ parameters.mode }}' + -JobStatus '$(Agent.JobStatus)' + -SummaryPath '$(Build.ArtifactStagingDirectory)\publish-outcome-summary.md' From 4d3be21eb1e45a6daaf1fb6744f9891d5ce28b15 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 6 Jul 2026 14:30:55 +0000 Subject: [PATCH 14/25] ci: print run info to log instead of task.uploadsummary (policy) ##vso[task.uploadsummary] is blocked by OneBranch policy, so remove it from the pipeline scripts. The pack/release/publish summary content is now written to the step log via Write-Output instead of an uploaded summary box: - pack-npm-packages.ps1: drop the summary table + SummaryPath param (the .tgz list is already logged above) - write-release-summary.ps1 / write-publish-outcome-summary.ps1: log the same fields as plain text; drop SummaryPath param Update the task 'arguments' and comments accordingly. --- .azure-pipelines/build-npm-packages.yml | 1 - .azure-pipelines/release-npm-packages.yml | 15 ++++--- .../scripts/pack-npm-packages.ps1 | 23 +---------- .../scripts/write-publish-outcome-summary.ps1 | 35 ++++++++-------- .../scripts/write-release-summary.ps1 | 41 +++++++++---------- 5 files changed, 43 insertions(+), 72 deletions(-) diff --git a/.azure-pipelines/build-npm-packages.yml b/.azure-pipelines/build-npm-packages.yml index 6fd2405bb..4a3377601 100644 --- a/.azure-pipelines/build-npm-packages.yml +++ b/.azure-pipelines/build-npm-packages.yml @@ -138,7 +138,6 @@ extends: arguments: >- -SourcesDirectory '$(Build.SourcesDirectory)' -OutputDirectory '$(ob_outputDirectory)' - -SummaryPath '$(Build.ArtifactStagingDirectory)\pack-summary.md' - task: CopyFiles@2 displayName: 'Copy package metadata to staging' diff --git a/.azure-pipelines/release-npm-packages.yml b/.azure-pipelines/release-npm-packages.yml index 52b0d9a5a..11c80190b 100644 --- a/.azure-pipelines/release-npm-packages.yml +++ b/.azure-pipelines/release-npm-packages.yml @@ -236,9 +236,10 @@ extends: -PackageName '${{ parameters.packageName }}' -WorkingDirectory '$(System.DefaultWorkingDirectory)' - # Emit a run summary box (rendered at the top of the ADO run) so the - # release intent - package, version, mode, and source build - is - # visible to approvers without digging through step logs. + # Print the release intent - package, version, mode, and source + # build - to the step log so it is visible to approvers without + # digging through the other steps. (An ADO run-summary box would be + # nicer, but the task.uploadsummary command is blocked by OneBranch policy.) - task: PowerShell@2 displayName: 'Publish run summary' condition: succeeded() @@ -254,7 +255,6 @@ extends: -SourceRunId '$(resources.pipeline.npmBuild.runID)' -SourceBranch '$(resources.pipeline.npmBuild.sourceBranch)' -SourceCommit '$(resources.pipeline.npmBuild.sourceCommit)' - -SummaryPath '$(Build.ArtifactStagingDirectory)\release-summary.md' # Use compile-time inclusion so the publish job does NOT exist in the # compiled pipeline graph for 'validate-only'. This is stronger than a @@ -372,9 +372,9 @@ extends: # tenant id here. domaintenantid: '975f013f-7f24-47e8-a7d3-abc4752bf346' - # Emit a run summary box describing the outcome. Runs with always() - # so the summary is present whether the ESRP task succeeded or failed - # (a failure in test-esrp-auth mode is the EXPECTED result). + # Print the ESRP outcome to the step log. Runs with always() + # so the outcome is reported whether the ESRP task succeeded or + # failed (a failure in test-esrp-auth mode is the EXPECTED result). - task: PowerShell@2 displayName: 'Publish outcome summary' condition: always() @@ -386,4 +386,3 @@ extends: -PublishVersion '${{ parameters.publishVersion }}' -Mode '${{ parameters.mode }}' -JobStatus '$(Agent.JobStatus)' - -SummaryPath '$(Build.ArtifactStagingDirectory)\publish-outcome-summary.md' diff --git a/.azure-pipelines/scripts/pack-npm-packages.ps1 b/.azure-pipelines/scripts/pack-npm-packages.ps1 index e5d3b00c2..d92854ca7 100644 --- a/.azure-pipelines/scripts/pack-npm-packages.ps1 +++ b/.azure-pipelines/scripts/pack-npm-packages.ps1 @@ -17,11 +17,7 @@ param( # Base output directory (ob_outputDirectory). The .tgz files are written to # \npm-packages. [Parameter(Mandatory = $true)] - [string]$OutputDirectory, - - # Path to write the run-summary markdown file that is uploaded to the ADO run. - [Parameter(Mandatory = $true)] - [string]$SummaryPath + [string]$OutputDirectory ) $ErrorActionPreference = 'Stop' @@ -82,20 +78,3 @@ if ($tgzCount -ne $publishablePackages.Count) { Write-Error "[Error] Expected $($publishablePackages.Count) .tgz file(s), found $tgzCount" exit 1 } - -# Emit a run summary box (rendered at the top of the ADO run, Extensions tab) -# so the packed artifacts are visible without opening the step log. -$summaryLines = @() -$summaryLines += "## Packed npm packages" -$summaryLines += "" -$summaryLines += "| Package | Version | .tgz file | Size |" -$summaryLines += "| --- | --- | --- | --- |" -foreach ($pkgDirName in $publishablePackages) { - $packageJson = Get-Content (Join-Path (Join-Path $SourcesDirectory 'packages') "$pkgDirName\package.json") | ConvertFrom-Json - $tgz = Get-ChildItem -Path $npmPackagesDir -Filter "*$pkgDirName*.tgz" | Select-Object -First 1 - $sizeKB = if ($tgz) { [math]::Round($tgz.Length / 1KB, 2) } else { 'n/a' } - $tgzName = if ($tgz) { $tgz.Name } else { 'MISSING' } - $summaryLines += "| ``$($packageJson.name)`` | $($packageJson.version) | ``$tgzName`` | ${sizeKB} KB |" -} -$summaryLines -join "`n" | Out-File -FilePath $SummaryPath -Encoding utf8 -Write-Host "##vso[task.uploadsummary]$SummaryPath" diff --git a/.azure-pipelines/scripts/write-publish-outcome-summary.ps1 b/.azure-pipelines/scripts/write-publish-outcome-summary.ps1 index e989bb3e4..d8246a2dd 100644 --- a/.azure-pipelines/scripts/write-publish-outcome-summary.ps1 +++ b/.azure-pipelines/scripts/write-publish-outcome-summary.ps1 @@ -1,11 +1,16 @@ <# .SYNOPSIS - Emits the post-publish run-summary box describing the ESRP outcome. + Prints the ESRP publish outcome to the step log. .DESCRIPTION - Invoked with condition: always() so the summary is present whether the ESRP + Invoked with condition: always() so the outcome is reported whether the ESRP task succeeded or failed. In 'test-esrp-auth' mode a failure is the EXPECTED result (ESRP rejects the Maven content-type), so it is framed accordingly. + +.NOTES + Previously uploaded an ADO run-summary box via the task.uploadsummary + logging command, but that command is blocked by OneBranch policy, so the + same information is written to the log instead. #> [CmdletBinding()] param( @@ -13,31 +18,23 @@ param( [Parameter(Mandatory = $true)] [string]$PublishVersion, [Parameter(Mandatory = $true)] [string]$Mode, # Agent.JobStatus: Succeeded / SucceededWithIssues / Failed / Canceled. - [Parameter(Mandatory = $true)] [string]$JobStatus, - [Parameter(Mandatory = $true)] [string]$SummaryPath + [Parameter(Mandatory = $true)] [string]$JobStatus ) $ErrorActionPreference = 'Stop' -$lines = @() -$lines += "## ESRP publish outcome" -$lines += "" -$lines += "| Field | Value |" -$lines += "| --- | --- |" -$lines += "| Package | ``$PackageName@$PublishVersion`` |" -$lines += "| Mode | ``$Mode`` |" -$lines += "| Job status | ``$JobStatus`` |" -$lines += "" +Write-Output "=== ESRP publish outcome ===" +Write-Output " Package : $PackageName@$PublishVersion" +Write-Output " Mode : $Mode" +Write-Output " Job status : $JobStatus" +Write-Output "" if ($Mode -eq 'test-esrp-auth') { - $lines += "> Smoke test: NOTHING was published. A **failure** here is expected (ESRP rejects the Maven content-type at validation). Inspect the ESRP task log to confirm the failure was the content-type rejection and NOT an auth/cert/Key Vault error." + Write-Output "Smoke test: NOTHING was published. A failure here is expected (ESRP rejects the Maven content-type at validation). Inspect the ESRP task log to confirm the failure was the content-type rejection and NOT an auth/cert/Key Vault error." } elseif ($Mode -eq 'publish') { if ($JobStatus -eq 'Succeeded') { - $lines += "> [OK] Published ``$PackageName@$PublishVersion`` to npmjs.org." + Write-Output "[OK] Published $PackageName@$PublishVersion to npmjs.org." } else { - $lines += "> [FAILED] Publish did NOT complete successfully (status: $JobStatus). Check the ESRP task log." + Write-Output "[FAILED] Publish did NOT complete successfully (status: $JobStatus). Check the ESRP task log." } } - -$lines -join "`n" | Out-File -FilePath $SummaryPath -Encoding utf8 -Write-Host "##vso[task.uploadsummary]$SummaryPath" diff --git a/.azure-pipelines/scripts/write-release-summary.ps1 b/.azure-pipelines/scripts/write-release-summary.ps1 index 5a2bb6a82..d3e36bcdf 100644 --- a/.azure-pipelines/scripts/write-release-summary.ps1 +++ b/.azure-pipelines/scripts/write-release-summary.ps1 @@ -1,7 +1,12 @@ <# .SYNOPSIS - Emits the pre-publish run-summary box describing the release intent - (package, version, mode, source build) for approvers. + Prints the release intent (package, version, mode, source build) to the step + log so approvers can review it without opening other steps. + +.NOTES + Previously uploaded an ADO run-summary box via the task.uploadsummary + logging command, but that command is blocked by OneBranch policy, so the + same information is written to the log instead. #> [CmdletBinding()] param( @@ -12,8 +17,7 @@ param( [Parameter(Mandatory = $true)] [string]$SourceRunName, [Parameter(Mandatory = $true)] [string]$SourceRunId, [Parameter(Mandatory = $true)] [string]$SourceBranch, - [Parameter(Mandatory = $true)] [string]$SourceCommit, - [Parameter(Mandatory = $true)] [string]$SummaryPath + [Parameter(Mandatory = $true)] [string]$SourceCommit ) $ErrorActionPreference = 'Stop' @@ -21,24 +25,17 @@ $ErrorActionPreference = 'Stop' $modeNote = switch ($Mode) { 'validate-only' { 'Validation only - ESRP is NOT contacted and nothing is published.' } 'test-esrp-auth' { 'Smoke test - exercises ESRP auth then fails at content validation. Nothing is published.' } - 'publish' { '**REAL PUBLISH** - this run pushes the package to npmjs.org.' } + 'publish' { 'REAL PUBLISH - this run pushes the package to npmjs.org.' } default { "Unknown mode: $Mode" } } -$lines = @() -$lines += "## npm release run" -$lines += "" -$lines += "| Field | Value |" -$lines += "| --- | --- |" -$lines += "| Package | ``$PackageName`` |" -$lines += "| Version | ``$PublishVersion`` |" -$lines += "| Mode | ``$Mode`` |" -$lines += "| .tgz | ``$TgzFileName`` |" -$lines += "| Source build | $SourceRunName (run $SourceRunId) |" -$lines += "| Source branch | $SourceBranch |" -$lines += "| Source commit | $SourceCommit |" -$lines += "" -$lines += "> $modeNote" - -$lines -join "`n" | Out-File -FilePath $SummaryPath -Encoding utf8 -Write-Host "##vso[task.uploadsummary]$SummaryPath" +Write-Output "=== npm release run ===" +Write-Output " Package : $PackageName" +Write-Output " Version : $PublishVersion" +Write-Output " Mode : $Mode" +Write-Output " .tgz : $TgzFileName" +Write-Output " Source build : $SourceRunName (run $SourceRunId)" +Write-Output " Source branch : $SourceBranch" +Write-Output " Source commit : $SourceCommit" +Write-Output "" +Write-Output $modeNote From e6ca460361fb0ec0921566c6d9ba1953a40133bb Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 6 Jul 2026 21:30:01 +0000 Subject: [PATCH 15/25] fix(ci): bundle pipeline scripts into build artifact for release job The release pipeline runs on a 'release' pool that consumes the build artifact and does NOT check out the repo, so the extracted .ps1 scripts it invokes were not present (task failed with 'Invalid file path ... show-source-build-metadata.ps1'). Stage .azure-pipelines/scripts/*.ps1 into the build artifact so they travel to the release job at $(System.DefaultWorkingDirectory)/.azure-pipelines/ scripts (where the release steps already reference them). Also exclude .ps1 from the build's code-sign validation (they are not signed binaries). NOTE: a fresh build pipeline run is required before the next release run so the artifact contains the scripts. --- .azure-pipelines/build-npm-packages.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.azure-pipelines/build-npm-packages.yml b/.azure-pipelines/build-npm-packages.yml index 4a3377601..81b75af18 100644 --- a/.azure-pipelines/build-npm-packages.yml +++ b/.azure-pipelines/build-npm-packages.yml @@ -89,7 +89,10 @@ extends: type: windows variables: ob_outputDirectory: '$(Build.ArtifactStagingDirectory)\build' # this directory is uploaded to pipeline artifacts. More info at https://aka.ms/obpipelines/artifacts - ob_sdl_codeSignValidation_excludes: '-|**\*.json;-|**\*.js;-|**\node_modules\**;' + # Exclude .json/.js (the packed package) and .ps1 (the pipeline + # scripts we bundle into the artifact for the release pipeline) from + # code-sign validation - none of these are signed binaries. + ob_sdl_codeSignValidation_excludes: '-|**\*.json;-|**\*.js;-|**\*.ps1;-|**\node_modules\**;' steps: - task: ComponentGovernanceComponentDetection@0 displayName: 'Component Governance - Component Detection' @@ -147,3 +150,16 @@ extends: packages/vscode-ext-webview/package.json !**/node_modules/** TargetFolder: $(ob_outputDirectory) + + # The release pipeline runs on a 'release' pool that consumes this + # artifact and does NOT check out the repo, so the .ps1 scripts it + # invokes must travel inside the artifact. Stage them here; they land + # at $(System.DefaultWorkingDirectory)/.azure-pipelines/scripts after + # the release pipeline downloads the artifact. + - task: CopyFiles@2 + displayName: 'Stage pipeline scripts for the release pipeline' + condition: succeeded() + inputs: + SourceFolder: '$(Build.SourcesDirectory)/.azure-pipelines/scripts' + Contents: '*.ps1' + TargetFolder: '$(ob_outputDirectory)/.azure-pipelines/scripts' From a198005e78831cd75a22c4521a8d2dc72b2fe454 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 6 Jul 2026 21:38:54 +0000 Subject: [PATCH 16/25] ci: inline release pipeline scripts; drop artifact script bundling The release pipeline runs on a 'release' pool that does not check out the repo, which made the extracted .ps1 scripts unavailable there. Rather than ship the scripts inside the build artifact (an extra moving part + coupling + rebuild-before-release ordering trap), inline the release steps so the release pipeline is self-contained again. - release-npm-packages.yml: all 8 steps back to targetType: inline - delete the 8 now-unused release-only scripts - build-npm-packages.yml: remove the 'Stage pipeline scripts' CopyFiles step and the .ps1 code-sign-validation exclude The build pipeline keeps pack-npm-packages.ps1 extracted: build jobs check out source, so it runs from $(Build.SourcesDirectory) with no moving parts. --- .azure-pipelines/build-npm-packages.yml | 18 +- .azure-pipelines/release-npm-packages.yml | 278 ++++++++++++++---- .azure-pipelines/scripts/find-tgz.ps1 | 53 ---- .azure-pipelines/scripts/inspect-tgz.ps1 | 64 ---- .azure-pipelines/scripts/prepare-package.ps1 | 30 -- .../scripts/set-release-build-number.ps1 | 26 -- .../scripts/show-source-build-metadata.ps1 | 24 -- .../scripts/verify-package-version.ps1 | 54 ---- .../scripts/write-publish-outcome-summary.ps1 | 40 --- .../scripts/write-release-summary.ps1 | 41 --- 10 files changed, 224 insertions(+), 404 deletions(-) delete mode 100644 .azure-pipelines/scripts/find-tgz.ps1 delete mode 100644 .azure-pipelines/scripts/inspect-tgz.ps1 delete mode 100644 .azure-pipelines/scripts/prepare-package.ps1 delete mode 100644 .azure-pipelines/scripts/set-release-build-number.ps1 delete mode 100644 .azure-pipelines/scripts/show-source-build-metadata.ps1 delete mode 100644 .azure-pipelines/scripts/verify-package-version.ps1 delete mode 100644 .azure-pipelines/scripts/write-publish-outcome-summary.ps1 delete mode 100644 .azure-pipelines/scripts/write-release-summary.ps1 diff --git a/.azure-pipelines/build-npm-packages.yml b/.azure-pipelines/build-npm-packages.yml index 81b75af18..4a3377601 100644 --- a/.azure-pipelines/build-npm-packages.yml +++ b/.azure-pipelines/build-npm-packages.yml @@ -89,10 +89,7 @@ extends: type: windows variables: ob_outputDirectory: '$(Build.ArtifactStagingDirectory)\build' # this directory is uploaded to pipeline artifacts. More info at https://aka.ms/obpipelines/artifacts - # Exclude .json/.js (the packed package) and .ps1 (the pipeline - # scripts we bundle into the artifact for the release pipeline) from - # code-sign validation - none of these are signed binaries. - ob_sdl_codeSignValidation_excludes: '-|**\*.json;-|**\*.js;-|**\*.ps1;-|**\node_modules\**;' + ob_sdl_codeSignValidation_excludes: '-|**\*.json;-|**\*.js;-|**\node_modules\**;' steps: - task: ComponentGovernanceComponentDetection@0 displayName: 'Component Governance - Component Detection' @@ -150,16 +147,3 @@ extends: packages/vscode-ext-webview/package.json !**/node_modules/** TargetFolder: $(ob_outputDirectory) - - # The release pipeline runs on a 'release' pool that consumes this - # artifact and does NOT check out the repo, so the .ps1 scripts it - # invokes must travel inside the artifact. Stage them here; they land - # at $(System.DefaultWorkingDirectory)/.azure-pipelines/scripts after - # the release pipeline downloads the artifact. - - task: CopyFiles@2 - displayName: 'Stage pipeline scripts for the release pipeline' - condition: succeeded() - inputs: - SourceFolder: '$(Build.SourcesDirectory)/.azure-pipelines/scripts' - Contents: '*.ps1' - TargetFolder: '$(ob_outputDirectory)/.azure-pipelines/scripts' diff --git a/.azure-pipelines/release-npm-packages.yml b/.azure-pipelines/release-npm-packages.yml index 11c80190b..b8494a0ae 100644 --- a/.azure-pipelines/release-npm-packages.yml +++ b/.azure-pipelines/release-npm-packages.yml @@ -192,49 +192,135 @@ extends: - task: PowerShell@2 displayName: 'Show source build metadata' inputs: - targetType: 'filePath' - filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/show-source-build-metadata.ps1' - arguments: >- - -PipelineName '$(resources.pipeline.npmBuild.pipelineName)' - -RunId '$(resources.pipeline.npmBuild.runID)' - -RunName '$(resources.pipeline.npmBuild.runName)' - -SourceBranch '$(resources.pipeline.npmBuild.sourceBranch)' - -SourceCommit '$(resources.pipeline.npmBuild.sourceCommit)' - -SourceProvider '$(resources.pipeline.npmBuild.sourceProvider)' + targetType: 'inline' + script: | + Write-Output "Releasing from npmBuild pipeline run:" + Write-Output " Pipeline : $(resources.pipeline.npmBuild.pipelineName)" + Write-Output " Run id : $(resources.pipeline.npmBuild.runID)" + Write-Output " Run name : $(resources.pipeline.npmBuild.runName)" + Write-Output " Source branch : $(resources.pipeline.npmBuild.sourceBranch)" + Write-Output " Source commit : $(resources.pipeline.npmBuild.sourceCommit)" + Write-Output " Source provider : $(resources.pipeline.npmBuild.sourceProvider)" # Set a descriptive build number - task: PowerShell@2 displayName: 'Set build number' inputs: - targetType: 'filePath' - filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/set-release-build-number.ps1' - arguments: >- - -PackageName '${{ parameters.packageName }}' - -PublishVersion '${{ parameters.publishVersion }}' - -Mode '${{ parameters.mode }}' - -BuildId '$(Build.BuildId)' + targetType: 'inline' + script: | + $packageName = "${{ parameters.packageName }}" + $publishVersion = "${{ parameters.publishVersion }}" + $mode = "${{ parameters.mode }}" + $buildId = "$(Build.BuildId)" + + $modeSuffix = switch ($mode) { + 'validate-only' { '-validate' } + 'test-esrp-auth' { '-esrptest' } + 'publish' { '' } + default { "-$mode" } + } + + $newBuildNumber = "npm-${packageName}-${publishVersion}${modeSuffix}-${buildId}" + Write-Output "Mode: $mode" + Write-Output "Setting build number to: $newBuildNumber" + Write-Output "##vso[build.updatebuildnumber]$newBuildNumber" # Verify the version in package.json matches the intended publish version - task: PowerShell@2 displayName: 'Verify package version' inputs: - targetType: 'filePath' - filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/verify-package-version.ps1' - arguments: >- - -PackageName '${{ parameters.packageName }}' - -PublishVersion '${{ parameters.publishVersion }}' - -WorkingDirectory '$(System.DefaultWorkingDirectory)' + targetType: 'inline' + script: | + $packageName = "${{ parameters.packageName }}" + $publishVersion = "${{ parameters.publishVersion }}" + + $packageJsonPath = "$(System.DefaultWorkingDirectory)/packages/$packageName/package.json" + if (-not (Test-Path $packageJsonPath)) { + Write-Error "[Error] package.json not found at $packageJsonPath" + Write-Output "Available files:" + Get-ChildItem -Path "$(System.DefaultWorkingDirectory)" -Recurse -Filter "package.json" | ForEach-Object { Write-Output $_.FullName } + exit 1 + } + + $packageJson = Get-Content $packageJsonPath | ConvertFrom-Json + $actualVersion = $packageJson.version + $fullPackageName = $packageJson.name + + Write-Output "Package: $fullPackageName" + Write-Output "Version in package.json: $actualVersion" + Write-Output "Expected publish version: $publishVersion" + + # Validate semantic version format + $semverPattern = '^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$' + if ($actualVersion -notmatch $semverPattern) { + Write-Error "[Error] Version in package.json ($actualVersion) is not a valid semantic version" + exit 1 + } + if ($publishVersion -notmatch $semverPattern) { + Write-Error "[Error] Publish version ($publishVersion) is not a valid semantic version" + exit 1 + } + + if ($actualVersion -eq $publishVersion) { + Write-Output "[Success] Version matches. Proceeding with release of $fullPackageName@$publishVersion" + } else { + Write-Error "[Error] Publish version '$publishVersion' does not match version in package.json '$actualVersion'. Cancelling release." + exit 1 + } + + # ESRP Release cannot publish private packages + if ($packageJson.private -eq $true) { + Write-Error "[Error] Package $fullPackageName is marked as private in package.json. Cannot publish to npm." + exit 1 + } # Find the .tgz file for the selected package - task: PowerShell@2 displayName: 'Find .tgz file' name: findTgzStep inputs: - targetType: 'filePath' - filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/find-tgz.ps1' - arguments: >- - -PackageName '${{ parameters.packageName }}' - -WorkingDirectory '$(System.DefaultWorkingDirectory)' + targetType: 'inline' + script: | + $packageName = "${{ parameters.packageName }}" + $npmPackagesDir = "$(System.DefaultWorkingDirectory)/npm-packages" + + Write-Output "Searching for .tgz files in: $npmPackagesDir" + + if (-not (Test-Path $npmPackagesDir)) { + Write-Error "[Error] npm-packages directory not found at $npmPackagesDir" + Write-Output "Available directories:" + Get-ChildItem -Path "$(System.DefaultWorkingDirectory)" -Directory | ForEach-Object { Write-Output $_.FullName } + exit 1 + } + + # List all available .tgz files + Write-Output "Available .tgz files:" + Get-ChildItem -Path $npmPackagesDir -Filter "*.tgz" | ForEach-Object { + $sizeKB = [math]::Round($_.Length / 1KB, 2) + Write-Output " $($_.Name) (${sizeKB} KB)" + } + + # Find the .tgz file matching the selected package. + # npm pack writes scope-flattened filenames, e.g.: + # documentdb-js-schema-analyzer-0.8.1.tgz + # microsoft-vscode-ext-webview-0.9.0-preview.tgz + # The packageName parameter matches the folder name under packages/, + # which appears in the .tgz filename for all packages here. + $tgzFiles = Get-ChildItem -Path $npmPackagesDir -Filter "*$packageName*.tgz" + + if ($tgzFiles.Count -eq 0) { + Write-Error "[Error] No .tgz file found matching package '$packageName'" + exit 1 + } elseif ($tgzFiles.Count -gt 1) { + Write-Error "[Error] Multiple .tgz files found matching package '$packageName': $($tgzFiles.Name -join ', ')" + exit 1 + } + + $tgzFile = $tgzFiles[0] + $tgzSize = [math]::Round($tgzFile.Length / 1KB, 2) + Write-Output "" + Write-Output "[Success] Found .tgz file: $($tgzFile.Name) (${tgzSize} KB)" + Write-Output "##vso[task.setvariable variable=tgzFileName;isOutput=true]$($tgzFile.Name)" # Print the release intent - package, version, mode, and source # build - to the step log so it is visible to approvers without @@ -244,17 +330,26 @@ extends: displayName: 'Publish run summary' condition: succeeded() inputs: - targetType: 'filePath' - filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/write-release-summary.ps1' - arguments: >- - -PackageName '${{ parameters.packageName }}' - -PublishVersion '${{ parameters.publishVersion }}' - -Mode '${{ parameters.mode }}' - -TgzFileName '$(findTgzStep.tgzFileName)' - -SourceRunName '$(resources.pipeline.npmBuild.runName)' - -SourceRunId '$(resources.pipeline.npmBuild.runID)' - -SourceBranch '$(resources.pipeline.npmBuild.sourceBranch)' - -SourceCommit '$(resources.pipeline.npmBuild.sourceCommit)' + targetType: 'inline' + script: | + $mode = "${{ parameters.mode }}" + $modeNote = switch ($mode) { + 'validate-only' { 'Validation only - ESRP is NOT contacted and nothing is published.' } + 'test-esrp-auth' { 'Smoke test - exercises ESRP auth then fails at content validation. Nothing is published.' } + 'publish' { 'REAL PUBLISH - this run pushes the package to npmjs.org.' } + default { "Unknown mode: $mode" } + } + + Write-Output "=== npm release run ===" + Write-Output " Package : ${{ parameters.packageName }}" + Write-Output " Version : ${{ parameters.publishVersion }}" + Write-Output " Mode : $mode" + Write-Output " .tgz : $(findTgzStep.tgzFileName)" + Write-Output " Source build : $(resources.pipeline.npmBuild.runName) (run $(resources.pipeline.npmBuild.runID))" + Write-Output " Source branch : $(resources.pipeline.npmBuild.sourceBranch)" + Write-Output " Source commit : $(resources.pipeline.npmBuild.sourceCommit)" + Write-Output "" + Write-Output $modeNote # Use compile-time inclusion so the publish job does NOT exist in the # compiled pipeline graph for 'validate-only'. This is stronger than a @@ -310,12 +405,25 @@ extends: - task: PowerShell@2 displayName: 'Prepare package for publishing' inputs: - targetType: 'filePath' - filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/prepare-package.ps1' - arguments: >- - -TgzFileName '$(tgzFileName)' - -WorkingDirectory '$(System.DefaultWorkingDirectory)' - -PublishDir '$(Build.ArtifactStagingDirectory)\npm-publish' + targetType: 'inline' + script: | + $tgzFileName = "$(tgzFileName)" + $npmPackagesDir = "$(System.DefaultWorkingDirectory)\npm-packages" + $publishDir = "$(Build.ArtifactStagingDirectory)\npm-publish" + + New-Item -ItemType Directory -Force -Path $publishDir | Out-Null + + $sourcePath = Join-Path $npmPackagesDir $tgzFileName + if (-not (Test-Path $sourcePath)) { + Write-Error "[Error] .tgz file not found: $sourcePath" + exit 1 + } + + Copy-Item -Path $sourcePath -Destination $publishDir + Write-Output "Prepared for publishing:" + Get-ChildItem -Path $publishDir | ForEach-Object { + Write-Output " $($_.Name) ($([math]::Round($_.Length / 1KB, 2)) KB)" + } # Inspect the .tgz contents and fail on obviously sensitive files. # This is a safety net (CredScan runs earlier, but the .tgz is the @@ -323,10 +431,57 @@ extends: - task: PowerShell@2 displayName: 'Inspect .tgz contents' inputs: - targetType: 'filePath' - filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/inspect-tgz.ps1' - arguments: >- - -PublishDir '$(Build.ArtifactStagingDirectory)\npm-publish' + targetType: 'inline' + script: | + $ErrorActionPreference = "Stop" + $publishDir = "$(Build.ArtifactStagingDirectory)\npm-publish" + $tgzPath = Get-ChildItem -Path $publishDir -Filter "*.tgz" | Select-Object -First 1 -ExpandProperty FullName + if (-not $tgzPath) { + Write-Error "[Error] No .tgz found in $publishDir" + exit 1 + } + + Write-Output "Listing contents of $tgzPath ..." + $entries = & tar -tzf $tgzPath + if ($LASTEXITCODE -ne 0) { + Write-Error "[Error] Failed to list tarball contents" + exit 1 + } + + $entries | ForEach-Object { Write-Output " $_" } + + # Patterns that should never appear in a published package. + $forbiddenPatterns = @( + '\.env(\..*)?$', + '(^|/)\.npmrc$', + '(^|/)\.netrc$', + '(^|/)id_rsa(\.pub)?$', + '(^|/)\.ssh/', + '\.pem$', + '\.pfx$', + '\.p12$', + '\.key$', + '(^|/)secrets?(\.json|\.yaml|\.yml|\.txt)$', + '(^|/)credentials?(\.json|\.yaml|\.yml|\.txt)$' + ) + + $violations = @() + foreach ($entry in $entries) { + foreach ($pattern in $forbiddenPatterns) { + if ($entry -match $pattern) { + $violations += " $entry (matched: $pattern)" + } + } + } + + if ($violations.Count -gt 0) { + Write-Error "[Error] Forbidden file(s) detected in the .tgz:" + $violations | ForEach-Object { Write-Error $_ } + exit 1 + } + + Write-Output "" + Write-Output "[Success] No forbidden files detected ($($entries.Count) entries scanned)" # Publish to npmjs.org via ESRP Release # Docs: https://eng.ms/docs/microsoft-security/identity/trust-and-security-services/tss-release-distribute/tss-release-esrp-parent/release-onboarding @@ -379,10 +534,23 @@ extends: displayName: 'Publish outcome summary' condition: always() inputs: - targetType: 'filePath' - filePath: '$(System.DefaultWorkingDirectory)/.azure-pipelines/scripts/write-publish-outcome-summary.ps1' - arguments: >- - -PackageName '${{ parameters.packageName }}' - -PublishVersion '${{ parameters.publishVersion }}' - -Mode '${{ parameters.mode }}' - -JobStatus '$(Agent.JobStatus)' + targetType: 'inline' + script: | + $mode = "${{ parameters.mode }}" + $jobStatus = "$(Agent.JobStatus)" + + Write-Output "=== ESRP publish outcome ===" + Write-Output " Package : ${{ parameters.packageName }}@${{ parameters.publishVersion }}" + Write-Output " Mode : $mode" + Write-Output " Job status : $jobStatus" + Write-Output "" + + if ($mode -eq 'test-esrp-auth') { + Write-Output "Smoke test: NOTHING was published. A failure here is expected (ESRP rejects the Maven content-type at validation). Inspect the ESRP task log to confirm the failure was the content-type rejection and NOT an auth/cert/Key Vault error." + } elseif ($mode -eq 'publish') { + if ($jobStatus -eq 'Succeeded') { + Write-Output "[OK] Published ${{ parameters.packageName }}@${{ parameters.publishVersion }} to npmjs.org." + } else { + Write-Output "[FAILED] Publish did NOT complete successfully (status: $jobStatus). Check the ESRP task log." + } + } diff --git a/.azure-pipelines/scripts/find-tgz.ps1 b/.azure-pipelines/scripts/find-tgz.ps1 deleted file mode 100644 index 69e4ab3db..000000000 --- a/.azure-pipelines/scripts/find-tgz.ps1 +++ /dev/null @@ -1,53 +0,0 @@ -<# -.SYNOPSIS - Finds the single .tgz produced for the selected package and exposes its - filename as the output variable 'tgzFileName'. -#> -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] [string]$PackageName, - # System.DefaultWorkingDirectory (where the build artifact was downloaded). - [Parameter(Mandatory = $true)] [string]$WorkingDirectory -) - -$ErrorActionPreference = 'Stop' - -$npmPackagesDir = Join-Path $WorkingDirectory 'npm-packages' - -Write-Output "Searching for .tgz files in: $npmPackagesDir" - -if (-not (Test-Path $npmPackagesDir)) { - Write-Error "[Error] npm-packages directory not found at $npmPackagesDir" - Write-Output "Available directories:" - Get-ChildItem -Path $WorkingDirectory -Directory | ForEach-Object { Write-Output $_.FullName } - exit 1 -} - -# List all available .tgz files -Write-Output "Available .tgz files:" -Get-ChildItem -Path $npmPackagesDir -Filter "*.tgz" | ForEach-Object { - $sizeKB = [math]::Round($_.Length / 1KB, 2) - Write-Output " $($_.Name) (${sizeKB} KB)" -} - -# Find the .tgz file matching the selected package. -# npm pack writes scope-flattened filenames, e.g.: -# documentdb-js-schema-analyzer-0.8.1.tgz -# microsoft-vscode-ext-webview-0.9.0-preview.tgz -# The PackageName parameter matches the folder name under packages/, -# which appears in the .tgz filename for all packages here. -$tgzFiles = Get-ChildItem -Path $npmPackagesDir -Filter "*$PackageName*.tgz" - -if ($tgzFiles.Count -eq 0) { - Write-Error "[Error] No .tgz file found matching package '$PackageName'" - exit 1 -} elseif ($tgzFiles.Count -gt 1) { - Write-Error "[Error] Multiple .tgz files found matching package '$PackageName': $($tgzFiles.Name -join ', ')" - exit 1 -} - -$tgzFile = $tgzFiles[0] -$tgzSize = [math]::Round($tgzFile.Length / 1KB, 2) -Write-Output "" -Write-Output "[Success] Found .tgz file: $($tgzFile.Name) (${tgzSize} KB)" -Write-Output "##vso[task.setvariable variable=tgzFileName;isOutput=true]$($tgzFile.Name)" diff --git a/.azure-pipelines/scripts/inspect-tgz.ps1 b/.azure-pipelines/scripts/inspect-tgz.ps1 deleted file mode 100644 index 9629a5f62..000000000 --- a/.azure-pipelines/scripts/inspect-tgz.ps1 +++ /dev/null @@ -1,64 +0,0 @@ -<# -.SYNOPSIS - Inspects the .tgz contents and fails on obviously sensitive files. - -.DESCRIPTION - Safety net that runs right before the artifact is uploaded to ESRP. - CredScan runs earlier in the build, but the .tgz is the actual public - artifact, so it is worth double-checking its exact contents here. -#> -[CmdletBinding()] -param( - # Folder containing the single .tgz prepared for publishing. - [Parameter(Mandatory = $true)] [string]$PublishDir -) - -$ErrorActionPreference = 'Stop' - -$tgzPath = Get-ChildItem -Path $PublishDir -Filter "*.tgz" | Select-Object -First 1 -ExpandProperty FullName -if (-not $tgzPath) { - Write-Error "[Error] No .tgz found in $PublishDir" - exit 1 -} - -Write-Output "Listing contents of $tgzPath ..." -$entries = & tar -tzf $tgzPath -if ($LASTEXITCODE -ne 0) { - Write-Error "[Error] Failed to list tarball contents" - exit 1 -} - -$entries | ForEach-Object { Write-Output " $_" } - -# Patterns that should never appear in a published package. -$forbiddenPatterns = @( - '\.env(\..*)?$', - '(^|/)\.npmrc$', - '(^|/)\.netrc$', - '(^|/)id_rsa(\.pub)?$', - '(^|/)\.ssh/', - '\.pem$', - '\.pfx$', - '\.p12$', - '\.key$', - '(^|/)secrets?(\.json|\.yaml|\.yml|\.txt)$', - '(^|/)credentials?(\.json|\.yaml|\.yml|\.txt)$' -) - -$violations = @() -foreach ($entry in $entries) { - foreach ($pattern in $forbiddenPatterns) { - if ($entry -match $pattern) { - $violations += " $entry (matched: $pattern)" - } - } -} - -if ($violations.Count -gt 0) { - Write-Error "[Error] Forbidden file(s) detected in the .tgz:" - $violations | ForEach-Object { Write-Error $_ } - exit 1 -} - -Write-Output "" -Write-Output "[Success] No forbidden files detected ($($entries.Count) entries scanned)" diff --git a/.azure-pipelines/scripts/prepare-package.ps1 b/.azure-pipelines/scripts/prepare-package.ps1 deleted file mode 100644 index 4531add0e..000000000 --- a/.azure-pipelines/scripts/prepare-package.ps1 +++ /dev/null @@ -1,30 +0,0 @@ -<# -.SYNOPSIS - Copies only the selected .tgz into an isolated folder so ESRP publishes - exactly one artifact and nothing else. -#> -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] [string]$TgzFileName, - # System.DefaultWorkingDirectory (where the build artifact was downloaded). - [Parameter(Mandatory = $true)] [string]$WorkingDirectory, - # Folder to place the single .tgz into (handed to ESRP as folderlocation). - [Parameter(Mandatory = $true)] [string]$PublishDir -) - -$ErrorActionPreference = 'Stop' - -$npmPackagesDir = Join-Path $WorkingDirectory 'npm-packages' -New-Item -ItemType Directory -Force -Path $PublishDir | Out-Null - -$sourcePath = Join-Path $npmPackagesDir $TgzFileName -if (-not (Test-Path $sourcePath)) { - Write-Error "[Error] .tgz file not found: $sourcePath" - exit 1 -} - -Copy-Item -Path $sourcePath -Destination $PublishDir -Write-Output "Prepared for publishing:" -Get-ChildItem -Path $PublishDir | ForEach-Object { - Write-Output " $($_.Name) ($([math]::Round($_.Length / 1KB, 2)) KB)" -} diff --git a/.azure-pipelines/scripts/set-release-build-number.ps1 b/.azure-pipelines/scripts/set-release-build-number.ps1 deleted file mode 100644 index 9b5bc8a18..000000000 --- a/.azure-pipelines/scripts/set-release-build-number.ps1 +++ /dev/null @@ -1,26 +0,0 @@ -<# -.SYNOPSIS - Sets a descriptive ADO run (build) number that encodes the package, version - and run mode, e.g. npm-vscode-ext-webview-0.9.0-preview-esrptest-12345. -#> -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] [string]$PackageName, - [Parameter(Mandatory = $true)] [string]$PublishVersion, - [Parameter(Mandatory = $true)] [string]$Mode, - [Parameter(Mandatory = $true)] [string]$BuildId -) - -$ErrorActionPreference = 'Stop' - -$modeSuffix = switch ($Mode) { - 'validate-only' { '-validate' } - 'test-esrp-auth' { '-esrptest' } - 'publish' { '' } - default { "-$Mode" } -} - -$newBuildNumber = "npm-${PackageName}-${PublishVersion}${modeSuffix}-${BuildId}" -Write-Output "Mode: $Mode" -Write-Output "Setting build number to: $newBuildNumber" -Write-Output "##vso[build.updatebuildnumber]$newBuildNumber" diff --git a/.azure-pipelines/scripts/show-source-build-metadata.ps1 b/.azure-pipelines/scripts/show-source-build-metadata.ps1 deleted file mode 100644 index 42ecdfc04..000000000 --- a/.azure-pipelines/scripts/show-source-build-metadata.ps1 +++ /dev/null @@ -1,24 +0,0 @@ -<# -.SYNOPSIS - Prints the source build (npmBuild pipeline resource) metadata so an operator - can confirm the release is coming from the intended commit/branch. -#> -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] [string]$PipelineName, - [Parameter(Mandatory = $true)] [string]$RunId, - [Parameter(Mandatory = $true)] [string]$RunName, - [Parameter(Mandatory = $true)] [string]$SourceBranch, - [Parameter(Mandatory = $true)] [string]$SourceCommit, - [Parameter(Mandatory = $true)] [string]$SourceProvider -) - -$ErrorActionPreference = 'Stop' - -Write-Output "Releasing from npmBuild pipeline run:" -Write-Output " Pipeline : $PipelineName" -Write-Output " Run id : $RunId" -Write-Output " Run name : $RunName" -Write-Output " Source branch : $SourceBranch" -Write-Output " Source commit : $SourceCommit" -Write-Output " Source provider : $SourceProvider" diff --git a/.azure-pipelines/scripts/verify-package-version.ps1 b/.azure-pipelines/scripts/verify-package-version.ps1 deleted file mode 100644 index 4e144d6e3..000000000 --- a/.azure-pipelines/scripts/verify-package-version.ps1 +++ /dev/null @@ -1,54 +0,0 @@ -<# -.SYNOPSIS - Verifies the packed package.json version matches the intended publish - version, validates semver, and refuses private packages. -#> -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] [string]$PackageName, - [Parameter(Mandatory = $true)] [string]$PublishVersion, - # System.DefaultWorkingDirectory (where the build artifact was downloaded). - [Parameter(Mandatory = $true)] [string]$WorkingDirectory -) - -$ErrorActionPreference = 'Stop' - -$packageJsonPath = Join-Path $WorkingDirectory "packages/$PackageName/package.json" -if (-not (Test-Path $packageJsonPath)) { - Write-Error "[Error] package.json not found at $packageJsonPath" - Write-Output "Available files:" - Get-ChildItem -Path $WorkingDirectory -Recurse -Filter "package.json" | ForEach-Object { Write-Output $_.FullName } - exit 1 -} - -$packageJson = Get-Content $packageJsonPath | ConvertFrom-Json -$actualVersion = $packageJson.version -$fullPackageName = $packageJson.name - -Write-Output "Package: $fullPackageName" -Write-Output "Version in package.json: $actualVersion" -Write-Output "Expected publish version: $PublishVersion" - -# Validate semantic version format -$semverPattern = '^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$' -if ($actualVersion -notmatch $semverPattern) { - Write-Error "[Error] Version in package.json ($actualVersion) is not a valid semantic version" - exit 1 -} -if ($PublishVersion -notmatch $semverPattern) { - Write-Error "[Error] Publish version ($PublishVersion) is not a valid semantic version" - exit 1 -} - -if ($actualVersion -eq $PublishVersion) { - Write-Output "[Success] Version matches. Proceeding with release of $fullPackageName@$PublishVersion" -} else { - Write-Error "[Error] Publish version '$PublishVersion' does not match version in package.json '$actualVersion'. Cancelling release." - exit 1 -} - -# ESRP Release cannot publish private packages -if ($packageJson.private -eq $true) { - Write-Error "[Error] Package $fullPackageName is marked as private in package.json. Cannot publish to npm." - exit 1 -} diff --git a/.azure-pipelines/scripts/write-publish-outcome-summary.ps1 b/.azure-pipelines/scripts/write-publish-outcome-summary.ps1 deleted file mode 100644 index d8246a2dd..000000000 --- a/.azure-pipelines/scripts/write-publish-outcome-summary.ps1 +++ /dev/null @@ -1,40 +0,0 @@ -<# -.SYNOPSIS - Prints the ESRP publish outcome to the step log. - -.DESCRIPTION - Invoked with condition: always() so the outcome is reported whether the ESRP - task succeeded or failed. In 'test-esrp-auth' mode a failure is the EXPECTED - result (ESRP rejects the Maven content-type), so it is framed accordingly. - -.NOTES - Previously uploaded an ADO run-summary box via the task.uploadsummary - logging command, but that command is blocked by OneBranch policy, so the - same information is written to the log instead. -#> -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] [string]$PackageName, - [Parameter(Mandatory = $true)] [string]$PublishVersion, - [Parameter(Mandatory = $true)] [string]$Mode, - # Agent.JobStatus: Succeeded / SucceededWithIssues / Failed / Canceled. - [Parameter(Mandatory = $true)] [string]$JobStatus -) - -$ErrorActionPreference = 'Stop' - -Write-Output "=== ESRP publish outcome ===" -Write-Output " Package : $PackageName@$PublishVersion" -Write-Output " Mode : $Mode" -Write-Output " Job status : $JobStatus" -Write-Output "" - -if ($Mode -eq 'test-esrp-auth') { - Write-Output "Smoke test: NOTHING was published. A failure here is expected (ESRP rejects the Maven content-type at validation). Inspect the ESRP task log to confirm the failure was the content-type rejection and NOT an auth/cert/Key Vault error." -} elseif ($Mode -eq 'publish') { - if ($JobStatus -eq 'Succeeded') { - Write-Output "[OK] Published $PackageName@$PublishVersion to npmjs.org." - } else { - Write-Output "[FAILED] Publish did NOT complete successfully (status: $JobStatus). Check the ESRP task log." - } -} diff --git a/.azure-pipelines/scripts/write-release-summary.ps1 b/.azure-pipelines/scripts/write-release-summary.ps1 deleted file mode 100644 index d3e36bcdf..000000000 --- a/.azure-pipelines/scripts/write-release-summary.ps1 +++ /dev/null @@ -1,41 +0,0 @@ -<# -.SYNOPSIS - Prints the release intent (package, version, mode, source build) to the step - log so approvers can review it without opening other steps. - -.NOTES - Previously uploaded an ADO run-summary box via the task.uploadsummary - logging command, but that command is blocked by OneBranch policy, so the - same information is written to the log instead. -#> -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] [string]$PackageName, - [Parameter(Mandatory = $true)] [string]$PublishVersion, - [Parameter(Mandatory = $true)] [string]$Mode, - [Parameter(Mandatory = $true)] [string]$TgzFileName, - [Parameter(Mandatory = $true)] [string]$SourceRunName, - [Parameter(Mandatory = $true)] [string]$SourceRunId, - [Parameter(Mandatory = $true)] [string]$SourceBranch, - [Parameter(Mandatory = $true)] [string]$SourceCommit -) - -$ErrorActionPreference = 'Stop' - -$modeNote = switch ($Mode) { - 'validate-only' { 'Validation only - ESRP is NOT contacted and nothing is published.' } - 'test-esrp-auth' { 'Smoke test - exercises ESRP auth then fails at content validation. Nothing is published.' } - 'publish' { 'REAL PUBLISH - this run pushes the package to npmjs.org.' } - default { "Unknown mode: $Mode" } -} - -Write-Output "=== npm release run ===" -Write-Output " Package : $PackageName" -Write-Output " Version : $PublishVersion" -Write-Output " Mode : $Mode" -Write-Output " .tgz : $TgzFileName" -Write-Output " Source build : $SourceRunName (run $SourceRunId)" -Write-Output " Source branch : $SourceBranch" -Write-Output " Source commit : $SourceCommit" -Write-Output "" -Write-Output $modeNote From a6c4d68d400955c771d9794040505ed9f4dfd561 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 6 Jul 2026 22:05:53 +0000 Subject: [PATCH 17/25] ci: make release 'mode' picklist self-documenting via prefix matching The test-esrp-auth picklist value now carries an inline explanation: 'test-esrp-auth (submits an invalid Maven job that ESRP rejects; nothing is published)'. To keep control flow decoupled from that descriptive text, match on the leading key instead of exact equality: - template guards use startsWith(parameters.mode, 'test-esrp-auth'|'publish') - PowerShell uses 'switch -Wildcard' with 'key*' patterns and -like 'key*' This way the human-readable suffix can change freely without risk of a missed comparison flipping a smoke-test run into a real publish. --- .azure-pipelines/release-npm-packages.yml | 38 ++++++++++++----------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/.azure-pipelines/release-npm-packages.yml b/.azure-pipelines/release-npm-packages.yml index b8494a0ae..be541844e 100644 --- a/.azure-pipelines/release-npm-packages.yml +++ b/.azure-pipelines/release-npm-packages.yml @@ -83,7 +83,7 @@ parameters: default: 'validate-only' values: - validate-only - - test-esrp-auth + - test-esrp-auth (submits an invalid Maven job that ESRP rejects; nothing is published) - publish - name: 'debug' @@ -169,7 +169,7 @@ extends: - name: ob_release_environment # Only use the Production environment (and its stricter approval gate) # for an actual publish. Validation and smoke-test runs use Test. - ${{ if eq(parameters.mode, 'publish') }}: + ${{ if startsWith(parameters.mode, 'publish') }}: value: Production ${{ else }}: value: Test @@ -213,11 +213,13 @@ extends: $mode = "${{ parameters.mode }}" $buildId = "$(Build.BuildId)" - $modeSuffix = switch ($mode) { - 'validate-only' { '-validate' } - 'test-esrp-auth' { '-esrptest' } - 'publish' { '' } - default { "-$mode" } + # Match on the leading key so the descriptive text in the + # 'mode' picklist value can change without touching this logic. + $modeSuffix = switch -Wildcard ($mode) { + 'validate-only*' { '-validate' } + 'test-esrp-auth*' { '-esrptest' } + 'publish*' { '' } + default { "-$mode" } } $newBuildNumber = "npm-${packageName}-${publishVersion}${modeSuffix}-${buildId}" @@ -333,11 +335,11 @@ extends: targetType: 'inline' script: | $mode = "${{ parameters.mode }}" - $modeNote = switch ($mode) { - 'validate-only' { 'Validation only - ESRP is NOT contacted and nothing is published.' } - 'test-esrp-auth' { 'Smoke test - exercises ESRP auth then fails at content validation. Nothing is published.' } - 'publish' { 'REAL PUBLISH - this run pushes the package to npmjs.org.' } - default { "Unknown mode: $mode" } + $modeNote = switch -Wildcard ($mode) { + 'validate-only*' { 'Validation only - ESRP is NOT contacted and nothing is published.' } + 'test-esrp-auth*' { 'Smoke test - exercises ESRP auth then fails at content validation. Nothing is published.' } + 'publish*' { 'REAL PUBLISH - this run pushes the package to npmjs.org.' } + default { "Unknown mode: $mode" } } Write-Output "=== npm release run ===" @@ -355,7 +357,7 @@ extends: # compiled pipeline graph for 'validate-only'. This is stronger than a # runtime condition: even a future regression that drops the condition # cannot accidentally publish in validate-only mode. - - ${{ if or(eq(parameters.mode, 'publish'), eq(parameters.mode, 'test-esrp-auth')) }}: + - ${{ if or(startsWith(parameters.mode, 'publish'), startsWith(parameters.mode, 'test-esrp-auth')) }}: - job: PublishPackage displayName: ${{ format('Publish ({0})', parameters.mode) }} dependsOn: ReleaseValidation @@ -376,7 +378,7 @@ extends: artifactName: drop_BuildStage_Main steps: # Loud banner for the smoke-test mode so logs are unambiguous - - ${{ if eq(parameters.mode, 'test-esrp-auth') }}: + - ${{ if startsWith(parameters.mode, 'test-esrp-auth') }}: - task: PowerShell@2 displayName: 'SMOKE TEST MODE - not publishing' inputs: @@ -391,7 +393,7 @@ extends: # Loud banner for the REAL publish mode so the log is unambiguous # about the fact that this run WILL push to npmjs.org. - - ${{ if eq(parameters.mode, 'publish') }}: + - ${{ if startsWith(parameters.mode, 'publish') }}: - task: PowerShell@2 displayName: 'REAL PUBLISH MODE - will push to npmjs.org' inputs: @@ -507,7 +509,7 @@ extends: signcertname: '$(EsrpSignCertName)' clientid: '$(EsrpClientId)' intent: 'PackageDistribution' - ${{ if eq(parameters.mode, 'test-esrp-auth') }}: + ${{ if startsWith(parameters.mode, 'test-esrp-auth') }}: contenttype: 'Maven' ${{ else }}: contenttype: 'npm' @@ -545,9 +547,9 @@ extends: Write-Output " Job status : $jobStatus" Write-Output "" - if ($mode -eq 'test-esrp-auth') { + if ($mode -like 'test-esrp-auth*') { Write-Output "Smoke test: NOTHING was published. A failure here is expected (ESRP rejects the Maven content-type at validation). Inspect the ESRP task log to confirm the failure was the content-type rejection and NOT an auth/cert/Key Vault error." - } elseif ($mode -eq 'publish') { + } elseif ($mode -like 'publish*') { if ($jobStatus -eq 'Succeeded') { Write-Output "[OK] Published ${{ parameters.packageName }}@${{ parameters.publishVersion }} to npmjs.org." } else { From 888b125ae3ba7435f9bcc9d2f023cd4f6b59c10a Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 6 Jul 2026 22:07:05 +0000 Subject: [PATCH 18/25] fix: clarify description of test-esrp-auth parameter in release pipeline --- .azure-pipelines/release-npm-packages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.azure-pipelines/release-npm-packages.yml b/.azure-pipelines/release-npm-packages.yml index be541844e..217ea7f5d 100644 --- a/.azure-pipelines/release-npm-packages.yml +++ b/.azure-pipelines/release-npm-packages.yml @@ -83,7 +83,7 @@ parameters: default: 'validate-only' values: - validate-only - - test-esrp-auth (submits an invalid Maven job that ESRP rejects; nothing is published) + - test-esrp-auth (submits an invalid Maven job that ESRP rejects; the job fails; nothing is published) - publish - name: 'debug' From a6a0a6beccbec21a38d9433edbb11eaedc64e856 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 6 Jul 2026 22:11:34 +0000 Subject: [PATCH 19/25] fix: enhance descriptions for run mode parameters in release pipeline --- .azure-pipelines/release-npm-packages.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.azure-pipelines/release-npm-packages.yml b/.azure-pipelines/release-npm-packages.yml index 217ea7f5d..a5c8efb1c 100644 --- a/.azure-pipelines/release-npm-packages.yml +++ b/.azure-pipelines/release-npm-packages.yml @@ -80,11 +80,11 @@ parameters: - name: mode displayName: 'Run mode' type: string - default: 'validate-only' + default: 'validate-only (Validate the artifact only. Skips ESRP entirely. No risk of publishing.)' values: - - validate-only - - test-esrp-auth (submits an invalid Maven job that ESRP rejects; the job fails; nothing is published) - - publish + - validate-only (Validate the artifact only. Skips ESRP entirely. No risk of publishing.) + - test-esrp-auth (Submits an invalid job, Maven instead of npm, that ESRP rejects; the job fails; nothing is published.) + - publish (The REAL release. Publishes the package to npmjs.org via ESRP.) - name: 'debug' displayName: 'Enable debug output' From 8068d4ff26b6487eaa035ef51df6ce1e3c0a99ad Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 6 Jul 2026 22:27:30 +0000 Subject: [PATCH 20/25] fix: add additional release owners and approvers for ESRP notifications --- .azure-pipelines/release-npm-packages.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.azure-pipelines/release-npm-packages.yml b/.azure-pipelines/release-npm-packages.yml index a5c8efb1c..691fcc45f 100644 --- a/.azure-pipelines/release-npm-packages.yml +++ b/.azure-pipelines/release-npm-packages.yml @@ -128,8 +128,8 @@ variables: # ESRP release notification / approval contacts. Defaults to the current # release owner, but prefer a TEAM security group or distribution list so # releases are not tied to a single person. Override in ADO -> Variables. - EsrpReleaseOwners: 'guanzhousong@microsoft.com' - EsrpReleaseApprovers: 'guanzhousong@microsoft.com' + EsrpReleaseOwners: 'guanzhousong@microsoft.com;tnaumowicz@microsoft.com' + EsrpReleaseApprovers: 'guanzhousong@microsoft.com;tnaumowicz@microsoft.com' extends: template: v2/OneBranch.Official.CrossPlat.yml@templates From 7596a083aea65a2acbcf02b4b19dde569a95e2d5 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 6 Jul 2026 22:38:35 +0000 Subject: [PATCH 21/25] fix(ci): use comma to separate ESRP owners/approvers emails EsrpRelease@11 splits owners/approvers on ',' - a ';' was treated as part of a single address and rejected ('Invalid email: a@x;b@x'). Switch the delimiter to a comma and add tnaumowicz@microsoft.com. --- .azure-pipelines/release-npm-packages.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.azure-pipelines/release-npm-packages.yml b/.azure-pipelines/release-npm-packages.yml index 691fcc45f..2a2f8a917 100644 --- a/.azure-pipelines/release-npm-packages.yml +++ b/.azure-pipelines/release-npm-packages.yml @@ -125,11 +125,13 @@ variables: # ESRP-approved publisher app: DocumentDB-VSC-Extension-ESRP-Release (AME). EsrpClientId: '8ba27e59-eace-4759-a6dd-36962b0e55d0' - # ESRP release notification / approval contacts. Defaults to the current - # release owner, but prefer a TEAM security group or distribution list so - # releases are not tied to a single person. Override in ADO -> Variables. - EsrpReleaseOwners: 'guanzhousong@microsoft.com;tnaumowicz@microsoft.com' - EsrpReleaseApprovers: 'guanzhousong@microsoft.com;tnaumowicz@microsoft.com' + # ESRP release notification / approval contacts. Comma-separated list of + # email addresses (the EsrpRelease task splits on ',' - a ';' is treated as + # part of a single address and rejected as an invalid email). Prefer a TEAM + # security group or distribution list so releases are not tied to a single + # person. Override in ADO -> Variables. + EsrpReleaseOwners: 'guanzhousong@microsoft.com,tnaumowicz@microsoft.com' + EsrpReleaseApprovers: 'guanzhousong@microsoft.com,tnaumowicz@microsoft.com' extends: template: v2/OneBranch.Official.CrossPlat.yml@templates From 6f6bb5e83f45717f9e0a2588cc911153a2bf94c3 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Tue, 7 Jul 2026 10:30:53 +0000 Subject: [PATCH 22/25] fix(ci): read package.json with -Raw before ConvertFrom-Json in pack script Get-Content without -Raw returns a string array under Windows PowerShell 5.1 (PowerShell@2 tasks run without pwsh:true), which breaks ConvertFrom-Json on multi-line package.json. Addresses Copilot review feedback. --- .azure-pipelines/scripts/pack-npm-packages.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.azure-pipelines/scripts/pack-npm-packages.ps1 b/.azure-pipelines/scripts/pack-npm-packages.ps1 index d92854ca7..ca944b3be 100644 --- a/.azure-pipelines/scripts/pack-npm-packages.ps1 +++ b/.azure-pipelines/scripts/pack-npm-packages.ps1 @@ -43,7 +43,7 @@ foreach ($pkgDirName in $publishablePackages) { exit 1 } - $packageJson = Get-Content $packageJsonPath | ConvertFrom-Json + $packageJson = Get-Content $packageJsonPath -Raw | ConvertFrom-Json $pkgName = $packageJson.name $pkgVersion = $packageJson.version From 12b3eac6c4b2345c31357b46c3a62ffdaf3637a6 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Tue, 7 Jul 2026 10:31:03 +0000 Subject: [PATCH 23/25] fix(ci): force array before .Count when counting .tgz files in pack script Defensive @() wrapping so the count check is robust regardless of how many files Get-ChildItem returns. Addresses Copilot review feedback. --- .azure-pipelines/scripts/pack-npm-packages.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.azure-pipelines/scripts/pack-npm-packages.ps1 b/.azure-pipelines/scripts/pack-npm-packages.ps1 index ca944b3be..f3a36a9c4 100644 --- a/.azure-pipelines/scripts/pack-npm-packages.ps1 +++ b/.azure-pipelines/scripts/pack-npm-packages.ps1 @@ -70,7 +70,7 @@ Get-ChildItem -Path $npmPackagesDir -Filter "*.tgz" | ForEach-Object { Write-Output " $($_.Name) (${sizeKB} KB)" } -$tgzCount = (Get-ChildItem -Path $npmPackagesDir -Filter "*.tgz").Count +$tgzCount = @(Get-ChildItem -Path $npmPackagesDir -Filter "*.tgz").Count Write-Output "" Write-Output "Total packages: $tgzCount" From 371622f595995d948cd0f161428e5641b27bda3f Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Tue, 7 Jul 2026 10:31:15 +0000 Subject: [PATCH 24/25] fix(ci): read package.json with -Raw before ConvertFrom-Json in release pipeline Get-Content without -Raw returns a string array under Windows PowerShell 5.1, which breaks ConvertFrom-Json on multi-line package.json during version validation. Addresses Copilot review feedback. --- .azure-pipelines/release-npm-packages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.azure-pipelines/release-npm-packages.yml b/.azure-pipelines/release-npm-packages.yml index 2a2f8a917..d77c0a3ef 100644 --- a/.azure-pipelines/release-npm-packages.yml +++ b/.azure-pipelines/release-npm-packages.yml @@ -246,7 +246,7 @@ extends: exit 1 } - $packageJson = Get-Content $packageJsonPath | ConvertFrom-Json + $packageJson = Get-Content $packageJsonPath -Raw | ConvertFrom-Json $actualVersion = $packageJson.version $fullPackageName = $packageJson.name From 19e5005796c29602bae19b3c3fd6448bd3555e38 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Tue, 7 Jul 2026 10:31:41 +0000 Subject: [PATCH 25/25] fix(ci): force array when matching .tgz files in release pipeline Defensive @() wrapping so .Count and [0] indexing behave consistently whether Get-ChildItem returns zero, one, or many matches. Addresses Copilot review feedback. --- .azure-pipelines/release-npm-packages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.azure-pipelines/release-npm-packages.yml b/.azure-pipelines/release-npm-packages.yml index d77c0a3ef..f229bf69d 100644 --- a/.azure-pipelines/release-npm-packages.yml +++ b/.azure-pipelines/release-npm-packages.yml @@ -310,7 +310,7 @@ extends: # microsoft-vscode-ext-webview-0.9.0-preview.tgz # The packageName parameter matches the folder name under packages/, # which appears in the .tgz filename for all packages here. - $tgzFiles = Get-ChildItem -Path $npmPackagesDir -Filter "*$packageName*.tgz" + $tgzFiles = @(Get-ChildItem -Path $npmPackagesDir -Filter "*$packageName*.tgz") if ($tgzFiles.Count -eq 0) { Write-Error "[Error] No .tgz file found matching package '$packageName'"