Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1419da8
Initial plan
Copilot Jul 9, 2026
509d838
Handle rerun-safe marketplace publish
Copilot Jul 9, 2026
96cb253
Refine duplicate publish regex
Copilot Jul 9, 2026
8d66ff9
Improve publish retry logging
Copilot Jul 9, 2026
39d69ff
Clarify duplicate publish detection
Copilot Jul 9, 2026
337e675
Document publish error matching
Copilot Jul 9, 2026
0c92e35
Clarify publish retry comments
Copilot Jul 9, 2026
1c83b0c
Tighten duplicate publish matching
Copilot Jul 9, 2026
b90eed2
Capture publish output as lines
Copilot Jul 9, 2026
f60d8b8
Clarify publish retry configuration
Copilot Jul 9, 2026
966aa2f
Simplify publish retry helper
Copilot Jul 9, 2026
1044f13
Refine publish retry log details
Copilot Jul 9, 2026
189f066
Clarify publish regex intent
Copilot Jul 9, 2026
a1cc030
Trim publish regex comments
Copilot Jul 9, 2026
e5d7d5b
Ad publishing script
JoeRobich Jul 9, 2026
b8d4b22
Update VSIX publish pipeline integration
Copilot Jul 9, 2026
46aa76e
Document dry-run publish behavior
Copilot Jul 9, 2026
a6f470e
Refine VSIX publish retry handling
Copilot Jul 9, 2026
c4167de
Polish VSIX publish script naming
Copilot Jul 9, 2026
0a78b1e
Tidy publish script constants
Copilot Jul 9, 2026
d6b4bb1
Address publish pipeline review feedback
Copilot Jul 9, 2026
0063ab0
Improve publish script configurability
Copilot Jul 9, 2026
476219e
Finalize publish script polish
Copilot Jul 9, 2026
7e2b980
Clarify publish attempt handling
Copilot Jul 9, 2026
329a754
Copy Publish-Vsix.ps1 into Packages artifact in build-vsix.yml
Copilot Jul 9, 2026
777301b
Add publishing script
JoeRobich Jul 9, 2026
8aff852
Use Publish-Vsix.ps1 from Packages artifact in release.yml, drop chec…
Copilot Jul 9, 2026
61af8cb
Apply suggestions from code review
JoeRobich Jul 9, 2026
4466fd1
Remove version pin from @vscode/vsce in Publish-Vsix.ps1
Copilot Jul 9, 2026
bbb86c9
Potential fix for pull request finding
JoeRobich Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
296 changes: 296 additions & 0 deletions azure-pipelines/Publish-Vsix.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
<#
.SYNOPSIS
Publishes signed VSIX packages in this folder to the Visual Studio Marketplace using vsce.

.DESCRIPTION
For every *.vsix in the target directory, this script locates the matching
.manifest and .signature.p7s files and publishes the package with vsce (run via
npx @vscode/vsce), passing --packagePath, --manifestPath and --signaturePath.

Publishing is tolerant of failures: duplicate-package responses are treated as
successful, transient failures are retried, and the script continues with the
remaining packages. A summary is printed at the end and the script exits with a
non-zero code only if at least one package failed.

.PARAMETER Path
Directory containing the .vsix/.manifest/.signature.p7s files. Defaults to the
script's own directory.

.PARAMETER PreRelease
Publish the packages as pre-release versions.

.PARAMETER DryRun
Enables DryRun behavior and verifies Marketplace credentials without publishing.

.PARAMETER CI
Skips az credentials check when running in CI.

.NOTES
Authentication uses Azure credentials (vsce --azure-credential). You must have the
Azure CLI (az) installed and be logged in (run 'az login') before publishing.

.EXAMPLE
az login
./Publish-Vsix.ps1 -PreRelease
#>
[CmdletBinding(SupportsShouldProcess = $false)]
param(
[string]$Path = $PSScriptRoot,
[switch]$PreRelease,
[switch]$DryRun,
[switch]$CI
)

$ErrorActionPreference = 'Stop'
$publisher = 'ms-dotnettools'
$maxOutputExcerptLength = 200
$retryDelaysInMinutes = @(1, 3, 5)
$secondsPerMinute = 60
$vscePackage = '@vscode/vsce'

if (-not $CI) {
# Publishing authenticates with Azure credentials, so the Azure CLI must be installed
# and a user must be signed in (az login).
$azCmd = Get-Command az -ErrorAction SilentlyContinue
if (-not $azCmd) {
throw "The Azure CLI (az) was not found on PATH. Install it from https://aka.ms/azcli and run 'az login', then try again."
}

az account show 1>$null 2>$null
if ($LASTEXITCODE -ne 0) {
throw "You are not logged in to the Azure CLI. Run 'az login' and try again."
}
}

# Run vsce via npx so no global install is required. npx will fetch @vscode/vsce
# on demand (and reuse it from the cache on subsequent runs).
$npxCmd = Get-Command npx -ErrorAction SilentlyContinue
if (-not $npxCmd) {
throw "npx was not found on PATH. Install Node.js (which includes npx) and try again."
}

$npx = $npxCmd.Source

$vsceVersionPattern = 'v[0-9.]+(?:-[0-9a-z.]+)*'
$vscePackageNamePattern = '[A-Za-z0-9]+(?:[.-][A-Za-z0-9]+)*(?:\s+\([^)]+\))?'

# Keep this regex aligned with the duplicate-package error format emitted by vsce in release pipeline logs.
$alreadyPublishedRegex = [regex]"^\s*ERROR\s+$vscePackageNamePattern\s+$vsceVersionPattern\s+already exists\.\s*$"

function Test-AlreadyPublishedFailure {
param(
[string[]]$CommandOutputLines
)

if ($null -eq $CommandOutputLines -or $CommandOutputLines.Length -eq 0) {
return $false
}

foreach ($commandOutputLine in $CommandOutputLines) {
if ($alreadyPublishedRegex.IsMatch($commandOutputLine)) {
return $true
}
}

return $false
}

function Get-LastOutputExcerpt {
param(
[string[]]$CommandOutputLines
)

$lastNonEmptyOutputLine = $CommandOutputLines | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Last 1
if ([string]::IsNullOrWhiteSpace($lastNonEmptyOutputLine)) {
return 'No output captured from vsce.'
}

if ($lastNonEmptyOutputLine.Length -gt $maxOutputExcerptLength) {
return $lastNonEmptyOutputLine.Substring(0, $maxOutputExcerptLength) + '...'
}

return $lastNonEmptyOutputLine
}

function Invoke-Vsce {
param(
[string[]]$Arguments,
[switch]$DryRun
)

$commandArgs = @(@('--yes', '--package', $vscePackage, 'vsce') + $Arguments)

if ($DryRun) {
Write-Host "DryRun: $npx $($commandArgs -join ' ')"
return @{
OutputLines = @()
ExitCode = 0
}
}

Write-Host "##[command]$npx $($commandArgs -join ' ')"
$commandOutputLines = & $npx @commandArgs 2>&1 | Out-String -Stream
$exitCode = $LASTEXITCODE

if ($null -ne $commandOutputLines -and $commandOutputLines.Length -gt 0) {
Write-Host ($commandOutputLines -join [Environment]::NewLine)
}

return @{
OutputLines = $commandOutputLines
ExitCode = $exitCode
}
}

function Invoke-VscePublish {
param(
[string]$PackagePath,
[string]$ManifestPath,
[string]$SignaturePath
)

$vsceArgs = @(
'publish',
'--packagePath', $PackagePath
'--manifestPath', $ManifestPath
'--signaturePath', $SignaturePath
)
if ($PreRelease) {
# Use the Marketplace CLI pre-release channel flag when publishing pre-release VSIX packages.
$vsceArgs += '--pre-release'
}
$vsceArgs += '--azure-credential'

Comment on lines +162 to +163

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have run this locally and did not run into any issue here.

if ($DryRun) {
Invoke-Vsce -Arguments $vsceArgs -DryRun | Out-Null
return @{
Status = 'DryRun'
Detail = ''
}
}

$totalAttempts = $retryDelaysInMinutes.Length + 1

for ($attemptIndex = 0; $attemptIndex -lt $totalAttempts; $attemptIndex++) {
$attempt = $attemptIndex + 1
Write-Host "Publish attempt $attempt of $totalAttempts."

$publishResult = Invoke-Vsce -Arguments $vsceArgs
$publishOutputLines = $publishResult.OutputLines
$exitCode = $publishResult.ExitCode

if ($exitCode -eq 0) {
Write-Host 'vsce publish succeeded.'
return @{
Status = 'Published'
Detail = ''
}
}

if (Test-AlreadyPublishedFailure -CommandOutputLines $publishOutputLines) {
Write-Warning 'vsce publish reported that the package version already exists. Treating this duplicate-package result as successful.'
return @{
Status = 'AlreadyPublished'
Detail = ''
}
}

$lastOutputExcerpt = Get-LastOutputExcerpt -CommandOutputLines $publishOutputLines
if ($attempt -lt $totalAttempts) {
$delayMinutes = $retryDelaysInMinutes[$attemptIndex]
Write-Warning "vsce publish failed with exit code $exitCode on attempt $attempt of $totalAttempts. Last output: $lastOutputExcerpt. Retrying in $delayMinutes minute(s)."
Start-Sleep -Seconds ($delayMinutes * $secondsPerMinute)
continue
}

return @{
Status = 'Failed'
Detail = "vsce publish failed after $totalAttempts attempts. Last output: $lastOutputExcerpt"
}
}
}

if (-not (Test-Path -LiteralPath $Path)) {
throw "Path '$Path' does not exist."
}

$vsixFiles = Get-ChildItem -LiteralPath $Path -Filter '*.vsix' -File | Sort-Object Name
if (-not $vsixFiles) {
Write-Warning "No .vsix files found in '$Path'. Nothing to publish."
return
}

Write-Host "Verifying companion files for all packages..."
$packages = [System.Collections.Generic.List[object]]::new()
$missingCompanionFiles = @()
foreach ($vsix in $vsixFiles) {
$vsixBaseName = [System.IO.Path]::GetFileNameWithoutExtension($vsix.Name)
$manifestPath = Join-Path $vsix.DirectoryName "$vsixBaseName.manifest"
$signaturePath = Join-Path $vsix.DirectoryName "$vsixBaseName.signature.p7s"

if (-not (Test-Path -LiteralPath $manifestPath)) { $missingCompanionFiles += "manifest ($manifestPath)" }
if (-not (Test-Path -LiteralPath $signaturePath)) { $missingCompanionFiles += "signature ($signaturePath)" }

$packages.Add([pscustomobject]@{
PackagePath = $vsix.FullName
PackageName = $vsix.Name
ManifestPath = $manifestPath
SignaturePath = $signaturePath
})
}
if ($missingCompanionFiles.Count -gt 0) {
throw "Verification check failed. Missing companion file(s):`n $($missingCompanionFiles -join "`n ")`nNo packages were published."
}
Write-Host "All companion files present." -ForegroundColor Green

if ($DryRun) {
Write-Host "Dry run mode enabled. Verifying Marketplace credentials."
$verifyArgs = @('verify-pat', '--azure-credential', $publisher)
$verifyResult = Invoke-Vsce -Arguments $verifyArgs
if ($verifyResult.ExitCode -ne 0) {
throw "Marketplace credential verification failed with exit code $($verifyResult.ExitCode)."
}
}

$results = New-Object System.Collections.Generic.List[object]

foreach ($package in $packages) {
Write-Host ""
Write-Host "=== Publishing $($package.PackageName) ===" -ForegroundColor Cyan

try {
$publishResult = Invoke-VscePublish -PackagePath $package.PackagePath -ManifestPath $package.ManifestPath -SignaturePath $package.SignaturePath
if ($publishResult.Status -eq 'Published') {
Write-Host "Published $($package.PackageName)" -ForegroundColor Green
$results.Add([pscustomobject]@{ Package = $package.PackageName; Status = 'Published'; Detail = '' })
}
elseif ($publishResult.Status -eq 'AlreadyPublished') {
Write-Host "$($package.PackageName) was already published" -ForegroundColor Yellow
$results.Add([pscustomobject]@{ Package = $package.PackageName; Status = 'AlreadyPublished'; Detail = '' })
}
elseif ($publishResult.Status -eq 'DryRun') {
$results.Add([pscustomobject]@{ Package = $package.PackageName; Status = 'DryRun'; Detail = '' })
}
else {
$reason = $publishResult.Detail
Write-Warning $reason
$results.Add([pscustomobject]@{ Package = $package.PackageName; Status = 'Failed'; Detail = $reason })
}
}
catch {
Write-Warning "Error publishing $($package.PackageName): $($_.Exception.Message)"
$results.Add([pscustomobject]@{ Package = $package.PackageName; Status = 'Failed'; Detail = $_.Exception.Message })
}
}

Write-Host ""
Write-Host "=== Summary ===" -ForegroundColor Cyan
$results | Format-Table -AutoSize

$failed = @($results | Where-Object { $_.Status -eq 'Failed' })
if ($failed.Count -gt 0) {
Write-Warning "$($failed.Count) package(s) failed to publish. See summary above."
exit 1
}

exit 0
7 changes: 7 additions & 0 deletions azure-pipelines/build-vsix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,13 @@ stages:
SourceFolder: '$(Build.SourcesDirectory)/vsix'
TargetFolder: '$(Build.SourcesDirectory)/Packages/VSIX_$(channel)'

- task: CopyFiles@2
displayName: 'Copy Publish-Vsix script to Packages'
inputs:
SourceFolder: '$(Build.SourcesDirectory)/azure-pipelines'
Contents: 'Publish-Vsix.ps1'
TargetFolder: '$(Build.SourcesDirectory)/Packages'

- ${{ if eq(parameters.isOfficial, true) }}:
- script: npm run verifyVsix
condition: and( succeeded(), eq('$(SignType)', 'Real'))
Expand Down
47 changes: 17 additions & 30 deletions azure-pipelines/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,6 @@ extends:
Write-Host "##vso[build.updatebuildnumber]$buildNumberName"
displayName: Set BuildNumber
- template: /azure-pipelines/install-node.yml@self
- pwsh: |
npm install --global @vscode/vsce
displayName: 'Install vsce'
- task: AzureCLI@2
displayName: '🚀 Publish to Marketplace'
name: PublishToMarketplaceStep
Expand All @@ -99,45 +96,35 @@ extends:
}

Write-Host "Uploading artifacts from $artifactFolder with prerelease=$uploadPrerelease"
Write-Host "##vso[task.setvariable variable=isPrerelease;isOutput=true]$($uploadPrerelease.ToString().ToLowerInvariant())"

# Our build pipeline would generated build based on attempt number. Publishing the latest attempt.
$allArtifacts = Get-ChildItem -Path "$artifactFolder*" | Sort-Object -Descending
# Our build pipeline would generate builds based on attempt number. Publish the latest attempt.
$allArtifacts = @(Get-ChildItem -Path "$artifactFolder*" | Sort-Object -Property Name -Descending)
if ($allArtifacts.Length -eq 0) {
throw "No artifacts in $artifactFolder"
throw "No artifacts in $artifactFolder"
}

$publishArtifacts = $allArtifacts[0]
Write-Host "All artifacts: $($allArtifacts). Publishing $($publishArtifacts)."

$basePublishArgs = , "publish"
If ( $uploadPrerelease ) {
$basePublishArgs += "--pre-release"
$publishScript = Join-Path '$(Pipeline.Workspace)' 'Publish-Vsix.ps1'
$publishArgs = @('-Path', $publishArtifacts.FullName)
if ($uploadPrerelease) {
Write-Host "Publish to pre-release channel."
Write-Host "##vso[task.setvariable variable=isPrerelease;isOutput=true]true"
} Else {
$publishArgs += '-PreRelease'
} else {
Write-Host "Publish to release channel."
Write-Host "##vso[task.setvariable variable=isPrerelease;isOutput=true]false"
}
$basePublishArgs += '--azure-credential'
$basePublishArgs += '--packagePath'
$publishArgs = $basePublishArgs + (Get-ChildItem $publishArtifacts\*.vsix | Sort-Object Name -Descending |% { $_ })
$publishArgs += '--manifestPath'
$publishArgs += (Get-ChildItem $publishArtifacts\*.manifest | Sort-Object Name -Descending |% { $_ })
$publishArgs += '--signaturePath'
$publishArgs += (Get-ChildItem $publishArtifacts\*.signature.p7s | Sort-Object Name -Descending |% { $_ })

If ("${{ parameters.test }}" -eq "true") {
Write-Host "In test mode, command is printed instead of run."
Write-Host "##[command]vsce $publishArgs"

Write-Host "🔒 Verify PAT."
vsce verify-pat --azure-credential ms-dotnettools
}
Else {
Write-Host "##[command]vsce $publishArgs"
vsce @publishArgs
if ("${{ parameters.test }}" -eq "true") {
Write-Host "In test mode, publish commands are printed instead of executed."
$publishArgs += '-DryRun'
}

$publishArgs += '-CI'

Write-Host "##[command]& $publishScript $($publishArgs -join ' ')"
& $publishScript @publishArgs

- stage: 'TagRelease'
displayName: 'Tag release of vscode-csharp'
dependsOn: 'PublishStage'
Expand Down