diff --git a/build/azure-devdiv-pipeline.pre-release.yml b/build/azure-devdiv-pipeline.pre-release.yml index 3ac8ddf4..a3d96234 100644 --- a/build/azure-devdiv-pipeline.pre-release.yml +++ b/build/azure-devdiv-pipeline.pre-release.yml @@ -108,6 +108,7 @@ extends: buildSteps: ${{ parameters.buildSteps }} isPreRelease: true standardizedVersioning: true + customNPMRegistry: $(AZURE_ARTIFACTS_FEED) - stage: Publish displayName: Publish Extension @@ -122,3 +123,4 @@ extends: ghCreateTag: true ghCreateRelease: true ghReleaseAddChangeLog: true + customNPMRegistry: $(AZURE_ARTIFACTS_FEED) diff --git a/build/azure-devdiv-pipeline.stable.yml b/build/azure-devdiv-pipeline.stable.yml index d8219b3e..d3d000ed 100644 --- a/build/azure-devdiv-pipeline.stable.yml +++ b/build/azure-devdiv-pipeline.stable.yml @@ -105,6 +105,7 @@ extends: buildPlatforms: ${{ parameters.buildPlatforms }} buildSteps: ${{ parameters.buildSteps }} isPreRelease: false + customNPMRegistry: $(AZURE_ARTIFACTS_FEED) - stage: Publish displayName: Publish Extension @@ -116,3 +117,4 @@ extends: publishExtension: ${{ parameters.publishExtension }} preRelease: false teamName: $(TeamName) + customNPMRegistry: $(AZURE_ARTIFACTS_FEED) diff --git a/build/scripts/ensure-npm-userconfig.ps1 b/build/scripts/ensure-npm-userconfig.ps1 new file mode 100644 index 00000000..07d04f34 --- /dev/null +++ b/build/scripts/ensure-npm-userconfig.ps1 @@ -0,0 +1,49 @@ +[CmdletBinding()] +<# +.SYNOPSIS + Creates a temporary npm user config (.npmrc) file for Azure Pipelines. + +.DESCRIPTION + Ensures the path exists and points to a file (not a directory), then sets pipeline + variables so subsequent steps can use a job-scoped npm config instead of relying + on a checked-in repository .npmrc. + + Variables set: + - NPM_CONFIG_USERCONFIG: points npm/npx to the temp .npmrc + - NPM_CONFIG_REGISTRY: (optional) registry URL to use for installs + +.PARAMETER Path + Full path to the .npmrc file to create/use (e.g. $(Agent.TempDirectory)/.npmrc). + +.PARAMETER Registry + Optional custom npm registry URL. If provided, sets NPM_CONFIG_REGISTRY. + +.EXAMPLE + ./ensure-npm-userconfig.ps1 -Path "$(Agent.TempDirectory)/.npmrc" -Registry "$(AZURE_ARTIFACTS_FEED)" +#> +param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $false)] + [string]$Registry = '' +) + +if (Test-Path -LiteralPath $Path -PathType Container) { + throw "npmrcPath points to a directory (expected a file): $Path" +} + +$parent = Split-Path -Parent $Path +if ($parent -and -not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null +} + +if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + New-Item -ItemType File -Path $Path -Force | Out-Null +} + +Write-Host "##vso[task.setvariable variable=NPM_CONFIG_USERCONFIG]$Path" + +if (-not [string]::IsNullOrWhiteSpace($Registry)) { + Write-Host "##vso[task.setvariable variable=NPM_CONFIG_REGISTRY]$Registry" +} diff --git a/build/scripts/finalize-npm-config.ps1 b/build/scripts/finalize-npm-config.ps1 new file mode 100644 index 00000000..f8fba2aa --- /dev/null +++ b/build/scripts/finalize-npm-config.ps1 @@ -0,0 +1,32 @@ +[CmdletBinding()] +<# +.SYNOPSIS + Ensures the npm user config contains "always-auth=true". + +.DESCRIPTION + npmAuthenticate@0 may overwrite the working .npmrc. This script is intended to run + after npmAuthenticate@0 to append "always-auth=true" if it is not already present. + +.PARAMETER Path + Path to the npm user config file to update. + +.EXAMPLE + ./finalize-npm-config.ps1 -Path "$(Agent.TempDirectory)/.npmrc" +#> +param( + [Parameter(Mandatory = $true)] + [string]$Path +) + +$existing = if (Test-Path -LiteralPath $Path) { + Get-Content -LiteralPath $Path -ErrorAction Stop +} else { + @() +} + +if ($existing -notcontains 'always-auth=true') { + 'always-auth=true' | Out-File -FilePath $Path -Append -Encoding utf8 + Write-Host "Appended always-auth=true -> $Path" +} else { + Write-Host "always-auth=true already present in $Path" +} diff --git a/build/scripts/setup-npm-and-yarn.ps1 b/build/scripts/setup-npm-and-yarn.ps1 new file mode 100644 index 00000000..c97f66f8 --- /dev/null +++ b/build/scripts/setup-npm-and-yarn.ps1 @@ -0,0 +1,50 @@ +[CmdletBinding()] +<# +.SYNOPSIS + Configures npm (and yarn, if present) to use a custom registry for the current job. + +.DESCRIPTION + Intended for Azure Pipelines jobs that authenticate using npmAuthenticate@0 against a + temp user config (.npmrc). This script sets per-process environment variables so npm + reads from the provided user config and targets the provided registry. + + Notes: + - Normalizes the registry to ensure it ends with '/'. + - Writes npm's registry setting into the user config file via `npm config set`. + - If yarn is installed on the agent, updates yarn's registry as well. + +.PARAMETER NpmrcPath + Path to the npm user config file (the file used by npmAuthenticate@0). + +.PARAMETER Registry + Custom registry URL. + +.EXAMPLE + ./setup-npm-and-yarn.ps1 -NpmrcPath "$(Agent.TempDirectory)/.npmrc" -Registry "$(AZURE_ARTIFACTS_FEED)" +#> +param( + [Parameter(Mandatory = $true)] + [string]$NpmrcPath, + + [Parameter(Mandatory = $true)] + [string]$Registry +) + +$Registry = $Registry.Trim() +if (-not $Registry.EndsWith('/')) { + $Registry = "$Registry/" +} + +$env:NPM_CONFIG_USERCONFIG = $NpmrcPath +$env:NPM_CONFIG_REGISTRY = $Registry + +# Configure npm to use the custom registry (writes to the user config file). +npm config set registry "$Registry" + +# Configure yarn if available. +$yarn = Get-Command yarn -ErrorAction SilentlyContinue +if ($null -ne $yarn) { + yarn config set registry "$Registry" +} else { + Write-Host "yarn not found; skipping yarn registry configuration" +} diff --git a/build/scripts/setup-npm-registry.js b/build/scripts/setup-npm-registry.js new file mode 100644 index 00000000..b7c43c5a --- /dev/null +++ b/build/scripts/setup-npm-registry.js @@ -0,0 +1,70 @@ +/** + * Rewrites lockfiles to use a custom npm registry. + * + * Purpose + * - Some lockfiles contain hardcoded references to public registries. + * - In Azure Pipelines, we want installs and npx to consistently resolve from a + * configured private/custom registry feed. + * + * Inputs + * - Environment variable: NPM_CONFIG_REGISTRY (required) + * + * Behavior + * - Recursively scans the repo (excluding node_modules and .git) for: + * - package-lock.json + * - yarn.lock + * - Replaces URLs matching: https://registry..(com|org)/ + * with the provided registry URL. + */ +const fs = require('fs').promises; +const path = require('path'); + +async function* getLockFiles(dir) { + const files = await fs.readdir(dir); + + for (const file of files) { + const fullPath = path.join(dir, file); + const stat = await fs.stat(fullPath); + + if (stat.isDirectory()) { + if (file === 'node_modules' || file === '.git') { + continue; + } + yield* getLockFiles(fullPath); + continue; + } + + if (file === 'yarn.lock' || file === 'package-lock.json') { + yield fullPath; + } + } +} + +async function rewrite(file, registry) { + let contents = await fs.readFile(file, 'utf8'); + const re = /https:\/\/registry\.[^.]+\.(com|org)\//g; + contents = contents.replace(re, registry); + await fs.writeFile(file, contents); +} + +async function main() { + let registry = process.env.NPM_CONFIG_REGISTRY; + if (!registry) { + throw new Error('NPM_CONFIG_REGISTRY is not set'); + } + + if (!registry.endsWith('/')) { + registry += '/'; + } + + const root = process.cwd(); + for await (const file of getLockFiles(root)) { + await rewrite(file, registry); + console.log('Updated node registry:', file); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/build/scripts/setup-npm-registry.ps1 b/build/scripts/setup-npm-registry.ps1 new file mode 100644 index 00000000..8d93cf60 --- /dev/null +++ b/build/scripts/setup-npm-registry.ps1 @@ -0,0 +1,34 @@ +[CmdletBinding()] +<# +.SYNOPSIS + Rewrites lockfiles to use a custom npm registry. + +.DESCRIPTION + Some lockfiles can contain hardcoded references to public npm registries. + This wrapper sets NPM_CONFIG_REGISTRY and runs the Node helper script + (setup-npm-registry.js) that performs in-repo lockfile rewrites. + +.PARAMETER Registry + Custom registry URL. + +.EXAMPLE + ./setup-npm-registry.ps1 -Registry "$(AZURE_ARTIFACTS_FEED)" +#> +param( + [Parameter(Mandatory = $true)] + [string]$Registry +) + +$Registry = $Registry.Trim() +if (-not $Registry.EndsWith('/')) { + $Registry = "$Registry/" +} + +$env:NPM_CONFIG_REGISTRY = $Registry + +$scriptPath = Join-Path $PSScriptRoot 'setup-npm-registry.js' +if (-not (Test-Path -LiteralPath $scriptPath -PathType Leaf)) { + throw "Expected JS helper script at: $scriptPath" +} + +node $scriptPath diff --git a/build/templates/package.yml b/build/templates/package.yml index 1ed988eb..b4d1e378 100644 --- a/build/templates/package.yml +++ b/build/templates/package.yml @@ -10,6 +10,16 @@ parameters: type: object displayName: 'List of platforms to build' + - name: customNPMRegistry + type: string + default: '' + displayName: 'Custom NPM registry (optional)' + + - name: nodeVersion + type: string + default: '22.17.0' + displayName: 'Node version to install' + - name: buildSteps type: stepList default: [] @@ -73,6 +83,9 @@ jobs: steps: - template: setup.yml@self + parameters: + customNPMRegistry: ${{ parameters.customNPMRegistry }} + nodeVersion: ${{ parameters.nodeVersion }} - ${{ if and(eq(parameters.isPreRelease, true), eq(parameters.standardizedVersioning, true)) }}: - template: modify-extension-version.yml@self diff --git a/build/templates/publish-extension.yml b/build/templates/publish-extension.yml index 163cb04d..b1cc7c92 100644 --- a/build/templates/publish-extension.yml +++ b/build/templates/publish-extension.yml @@ -26,6 +26,16 @@ parameters: type: object displayName: 'List of platforms to sign and publish' + - name: customNPMRegistry + type: string + default: '' + displayName: 'Custom NPM registry (optional)' + + - name: nodeVersion + type: string + default: '22.17.0' + displayName: 'Node version to install' + # Signing parameters - name: signType type: string @@ -151,6 +161,8 @@ jobs: signType: ${{ parameters.signType }} verifySignature: ${{ parameters.verifySignature }} teamName: ${{ parameters.teamName }} + customNPMRegistry: ${{ parameters.customNPMRegistry }} + nodeVersion: ${{ parameters.nodeVersion }} # Job 2: Publish to marketplace - ${{ if eq(parameters.publishExtension, true) }}: @@ -165,17 +177,19 @@ jobs: type: releaseJob # This makes a job a release job isProduction: true # Indicates a production release steps: - - template: setup.yml - parameters: - installNode: true - installPython: false - - task: 1ES.DownloadPipelineArtifact@1 inputs: artifactName: extension targetPath: $(Build.ArtifactStagingDirectory)/${{ parameters.publishFolder }} displayName: 🚛 Download signed extension + - template: setup.yml + parameters: + installNode: true + installPython: false + customNPMRegistry: ${{ parameters.customNPMRegistry }} + nodeVersion: ${{ parameters.nodeVersion }} + # Extract VSIX to read publisher/version for GitHub release tagging. # Use Agent.TempDirectory to avoid reusing Build.ArtifactStagingDirectory which # is reserved for final artifact staging. diff --git a/build/templates/publish.yml b/build/templates/publish.yml index 66ac7f9f..def1f746 100644 --- a/build/templates/publish.yml +++ b/build/templates/publish.yml @@ -102,12 +102,12 @@ steps: if ('${{ parameters.preRelease }}' -eq 'True') { Write-Host 'Publishing as pre-release' - Write-Host "Executing: npx vsce publish --pat *** --packagePath $vsixPath --manifestPath $manifestPath --signaturePath $signaturePath --pre-release" - npx vsce publish --pat $aadToken --packagePath $vsixPath --manifestPath $manifestPath --signaturePath $signaturePath --pre-release + Write-Host "Executing: npx @vscode/vsce@latest publish --pat *** --packagePath $vsixPath --manifestPath $manifestPath --signaturePath $signaturePath --pre-release" + npx @vscode/vsce@latest publish --pat $aadToken --packagePath $vsixPath --manifestPath $manifestPath --signaturePath $signaturePath --pre-release } else { Write-Host 'Publishing as stable release' - Write-Host "Executing: npx vsce publish --pat *** --packagePath $vsixPath --manifestPath $manifestPath --signaturePath $signaturePath" - npx vsce publish --pat $aadToken --packagePath $vsixPath --manifestPath $manifestPath --signaturePath $signaturePath + Write-Host "Executing: npx @vscode/vsce@latest publish --pat *** --packagePath $vsixPath --manifestPath $manifestPath --signaturePath $signaturePath" + npx @vscode/vsce@latest publish --pat $aadToken --packagePath $vsixPath --manifestPath $manifestPath --signaturePath $signaturePath } if ($LASTEXITCODE -ne 0) { diff --git a/build/templates/setup.yml b/build/templates/setup.yml index 4cd0aa80..f6b37227 100644 --- a/build/templates/setup.yml +++ b/build/templates/setup.yml @@ -6,36 +6,65 @@ parameters: - name: installPython type: boolean default: true + - name: customNPMRegistry + type: string + default: '' + - name: nodeVersion + type: string + default: '22.17.0' + - name: pythonVersion + type: string + default: '3.9' + - name: npmrcPath + type: string + default: '$(Agent.TempDirectory)/.npmrc' steps: - ${{ if eq(parameters.installNode, true) }}: - - pwsh: | - if (-not (Test-Path '.npmrc')) { - Write-Host 'No .npmrc found, creating one with public npm registry' - @" - # Force public npm registry to avoid CI auth (E401) when no token is provided - registry=https://registry.npmjs.org/ - # Do not require auth for public installs - always-auth=false - "@ | Out-File -FilePath '.npmrc' -Encoding utf8 - } else { - Write-Host '.npmrc already exists' - } - displayName: Ensure .npmrc exists - - - task: npmAuthenticate@0 + - task: NodeTool@0 inputs: - workingFile: .npmrc + versionSpec: ${{ parameters.nodeVersion }} + checkLatest: true + displayName: 🛠 Install Node ${{ parameters.nodeVersion }} + + - ${{ if ne(parameters.customNPMRegistry, '') }}: + # When using a private/custom registry, configure npm to read auth/config from a temp user config + # instead of relying on a checked-in project .npmrc. + - pwsh: > + $(Build.SourcesDirectory)/build/scripts/ensure-npm-userconfig.ps1 + -Path "${{ parameters.npmrcPath }}" + -Registry "${{ parameters.customNPMRegistry }}" + displayName: 📦 Setup NPM User Config + + # Configure npm/yarn to use the custom registry and ensure auth headers are sent. + - pwsh: > + $(Build.SourcesDirectory)/build/scripts/setup-npm-and-yarn.ps1 + -NpmrcPath "${{ parameters.npmrcPath }}" + -Registry "${{ parameters.customNPMRegistry }}" + displayName: 📦 Setup NPM & Yarn + + # Populate the temp .npmrc with auth for the configured registry. + - task: npmAuthenticate@0 + inputs: + workingFile: ${{ parameters.npmrcPath }} + displayName: 📦 Setup NPM Authentication + + # Ensure the registry always sends auth headers (npmAuthenticate may overwrite the file). + - pwsh: > + $(Build.SourcesDirectory)/build/scripts/finalize-npm-config.ps1 + -Path "${{ parameters.npmrcPath }}" + displayName: 📦 Finalize NPM config + + # Some lockfiles contain hardcoded references to public registries. Rewrite them so installs + # and `npx` resolve from the custom registry consistently. + - pwsh: > + $(Build.SourcesDirectory)/build/scripts/setup-npm-registry.ps1 + -Registry "${{ parameters.customNPMRegistry }}" + displayName: 📦 Setup NPM Registry - script: npm config get registry displayName: Verify NPM Registry - - task: NodeTool@0 - inputs: - versionSpec: '22.17.0' - checkLatest: true - displayName: Select Node 22 LTS - - ${{ if eq(parameters.installPython, true) }}: - task: PipAuthenticate@1 displayName: 'Pip Authenticate' @@ -44,7 +73,7 @@ steps: - task: UsePythonVersion@0 inputs: - versionSpec: '3.9' # note Install Python dependencies step below relies on Python 3.9 + versionSpec: ${{ parameters.pythonVersion }} addToPath: true architecture: 'x64' - displayName: Select Python version + displayName: Select Python ${{ parameters.pythonVersion }} diff --git a/build/templates/sign.yml b/build/templates/sign.yml index 83948576..f5737eca 100644 --- a/build/templates/sign.yml +++ b/build/templates/sign.yml @@ -13,9 +13,15 @@ parameters: - name: buildPlatforms type: object displayName: 'List of platforms to sign' + - name: customNPMRegistry + type: string + default: '' - name: workingDirectory type: string default: '$(Build.StagingDirectory)' + - name: nodeVersion + type: string + default: '22.17.0' - name: signType type: string default: real @@ -73,8 +79,10 @@ steps: restoreDirectory: '$(Build.SourcesDirectory)/packages' nugetConfigPath: '$(Build.SourcesDirectory)/build/NuGet.config' - # Setup Node.js and npm authentication - template: setup.yml@self + parameters: + customNPMRegistry: ${{ parameters.customNPMRegistry }} + nodeVersion: ${{ parameters.nodeVersion }} - task: Npm@1 displayName: 'npm ci (install vsce)'