diff --git a/.azure-pipelines/build-npm-packages.yml b/.azure-pipelines/build-npm-packages.yml index 2581eccf9..4a3377601 100644 --- a/.azure-pipelines/build-npm-packages.yml +++ b/.azure-pipelines/build-npm-packages.yml @@ -1,62 +1,149 @@ -# 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. +# +# Scope: only packages in $publishablePackages (below) are packed and shipped +# 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- +# 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: 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 +# 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 + +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: 'Use Node.js' + inputs: + versionSource: fromFile + versionFilePath: .nvmrc + + - task: npmAuthenticate@0 + displayName: 'Authenticate to npm registry' + inputs: + workingFile: '$(Build.SourcesDirectory)/.azure-pipelines/.npmrc' + + - task: Npm@1 + displayName: 'Install Dependencies' + condition: succeeded() + inputs: + command: custom + customCommand: ci --userconfig $(Build.SourcesDirectory)/.azure-pipelines/.npmrc + workingDir: $(Build.SourcesDirectory) + + - task: Npm@1 + displayName: 'Build Workspace Packages' + condition: succeeded() + inputs: + command: custom + customCommand: run build --workspaces --if-present + workingDir: $(Build.SourcesDirectory) + + - task: Npm@1 + displayName: 'Test Workspace Packages' + condition: succeeded() + inputs: + command: custom + customCommand: run test --workspaces --if-present + workingDir: $(Build.SourcesDirectory) + + - task: PowerShell@2 + displayName: 'Pack npm packages' + condition: succeeded() + inputs: + targetType: 'filePath' + filePath: '$(Build.SourcesDirectory)/.azure-pipelines/scripts/pack-npm-packages.ps1' + arguments: >- + -SourcesDirectory '$(Build.SourcesDirectory)' + -OutputDirectory '$(ob_outputDirectory)' + + - task: CopyFiles@2 + displayName: 'Copy package metadata to staging' + condition: succeeded() + inputs: + Contents: | + 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 46d1e34cf..f229bf69d 100644 --- a/.azure-pipelines/release-npm-packages.yml +++ b/.azure-pipelines/release-npm-packages.yml @@ -1,70 +1,560 @@ -# 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 +# (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@). + +# 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: vscode-documentdb-npm-release-$(Date:yyyy-MM-dd).$(Rev:r) +appendCommitMessageToRunName: false + +trigger: none +pr: none + +parameters: + # 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-webview + values: + - vscode-ext-webview + + # The expected version - must match the version in the package's package.json + - name: publishVersion + displayName: 'Expected package version' + type: string + + # 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 (Validate the artifact only. Skips ESRP entirely. No risk of publishing.)' + values: + - 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' + 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' + + # 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' + + # 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 + + 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: ${{ format('Release npm package ({0})', parameters.mode) }} + variables: + - 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 startsWith(parameters.mode, 'publish') }}: + value: Production + ${{ else }}: + value: Test + jobs: + - job: ReleaseValidation + displayName: 'Validate Artifacts' + templateContext: + inputs: + - input: pipelineArtifact + pipeline: npmBuild + targetPath: $(System.DefaultWorkingDirectory) + artifactName: drop_BuildStage_Main + pool: + type: release + 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 provider : $(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)" + + # 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}" + 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: '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 -Raw | 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: '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 + # 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() + inputs: + targetType: 'inline' + script: | + $mode = "${{ parameters.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 ===" + 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 + # runtime condition: even a future regression that drops the condition + # cannot accidentally publish in validate-only mode. + - ${{ if or(startsWith(parameters.mode, 'publish'), startsWith(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 startsWith(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 startsWith(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: '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, 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 startsWith(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' + + # 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() + inputs: + 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 -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 -like '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/pack-npm-packages.ps1 b/.azure-pipelines/scripts/pack-npm-packages.ps1 new file mode 100644 index 000000000..f3a36a9c4 --- /dev/null +++ b/.azure-pipelines/scripts/pack-npm-packages.ps1 @@ -0,0 +1,80 @@ +<# +.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 +) + +$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 -Raw | 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 +}