Files
097c47d622 Fix invalid PyPI package versions after shared-script sync (#3985)
* Fix PyPI version conversion output

Discard the regex returned by SetupPythonConventions so package versions remain scalar strings. Add regression coverage for stable, prerelease, build-number, and invalid versions, including generated project metadata.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 8dc224e5-c470-4102-83cc-c5b85948aaf5

* Remove changelog entry for internal packaging fix

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 8dc224e5-c470-4102-83cc-c5b85948aaf5

* Remove generated Pester test report

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 8dc224e5-c470-4102-83cc-c5b85948aaf5

* Explain why Python version conversion discards regex output

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 8dc224e5-c470-4102-83cc-c5b85948aaf5

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: msalaman <28122166+msalaman@users.noreply.github.com>
Copilot-Session: 8dc224e5-c470-4102-83cc-c5b85948aaf5
2026-10-19 19:18:21 +00:00

407 lines
14 KiB
PowerShell

#!/bin/env pwsh
#Requires -Version 7
<#
.SYNOPSIS
Packs MCP server binaries into PyPI wheels for distribution.
.DESCRIPTION
This script creates platform-specific PyPI wheels for configured MCP servers.
Each wheel contains the binary for a specific platform (OS + architecture).
The wheels are named according to PyPI conventions:
- msmcp_azure-1.0.0-py3-none-win_amd64.whl
- msmcp_azure-1.0.0-py3-none-macosx_11_0_arm64.whl
- msmcp_azure-1.0.0-py3-none-manylinux_2_17_x86_64.whl
etc.
Users can install with:
- pip install msmcp-azure
- uvx msmcp-azure
- pipx install msmcp-azure
.PARAMETER ArtifactsPath
Path to the build artifacts containing the server binaries.
.PARAMETER BuildInfoPath
Path to the build_info.json file containing server and platform details.
.PARAMETER OutputPath
Path where the PyPI packages will be created.
.EXAMPLE
./Pack-Pypi.ps1
Creates PyPI packages using default local paths.
.EXAMPLE
./Pack-Pypi.ps1 -ArtifactsPath ".work/build" -BuildInfoPath ".work/build_info.json"
Creates PyPI packages using specified artifact and build info paths.
#>
[CmdletBinding()]
param(
[string] $ArtifactsPath,
[string] $BuildInfoPath,
[string] $OutputPath
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/../common/scripts/common.ps1"
$RepoRoot = $RepoRoot.Path.Replace('\', '/')
$pypiSourcePath = "$RepoRoot/eng/pypi"
# When running locally, ignore missing artifacts instead of failing
$ignoreMissingArtifacts = $env:TF_BUILD -ne 'true'
$exitCode = 0
if (!$ArtifactsPath) {
$ArtifactsPath = "$RepoRoot/.work/build"
}
if (!$BuildInfoPath) {
$BuildInfoPath = "$RepoRoot/.work/build_info.json"
}
if (!$OutputPath) {
$OutputPath = "$RepoRoot/.work/packages_pypi"
Remove-Item -Path $OutputPath -Recurse -Force -ErrorAction SilentlyContinue -ProgressAction SilentlyContinue
}
if (!(Test-Path $ArtifactsPath)) {
LogError "Artifacts path $ArtifactsPath does not exist."
$exitCode = 1
}
if (!(Test-Path $BuildInfoPath)) {
LogError "Build info file $BuildInfoPath does not exist. Run eng/scripts/New-BuildInfo.ps1 to create it."
$exitCode = 1
}
if ($exitCode -ne 0) {
exit $exitCode
}
$buildInfo = Get-Content $BuildInfoPath -Raw | ConvertFrom-Json -AsHashtable
$tempFolder = "$RepoRoot/.work/temp_pypi"
# Map node OS names to PyPI wheel platform tags
# See: https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/
$wheelPlatformMap = @{
'win32-x64' = 'win_amd64'
'win32-arm64' = 'win_arm64'
'darwin-x64' = 'macosx_11_0_x86_64'
'darwin-arm64' = 'macosx_11_0_arm64'
'linux-x64' = 'manylinux_2_17_x86_64.manylinux2014_x86_64'
'linux-arm64' = 'manylinux_2_17_aarch64.manylinux2014_aarch64'
}
# Map OS names to Python classifier OS names
$osClassifierMap = @{
'win32' = 'Microsoft :: Windows'
'darwin' = 'MacOS'
'linux' = 'POSIX :: Linux'
}
function Get-ModuleName($packageName) {
return $packageName.Replace('-', '_')
}
function Get-KeywordsString($keywords) {
return ($keywords | ForEach-Object { "`"$_`"" }) -join ', '
}
function ConvertTo-PythonPackageVersion {
param ([string] $Version)
$parsedVersion = [AzureEngSemanticVersion]::ParseVersionString($Version)
if (!$parsedVersion) {
return $null
}
# Discard the returned regex: PowerShell otherwise emits it alongside the version,
# making this function return an array whose ToString() produces "System.Object[]".
$null = $parsedVersion.SetupPythonConventions()
if ($parsedVersion.PrereleaseLabel -eq 'beta') {
$parsedVersion.PrereleaseLabel = 'b'
}
elseif ($parsedVersion.PrereleaseLabel -eq 'alpha') {
$parsedVersion.PrereleaseLabel = 'a'
}
return $parsedVersion.ToString()
}
function Get-DevStatus {
param ([string] $version)
$parsedVersion = [AzureEngSemanticVersion]::ParseVersionString($version)
if (!$parsedVersion) {
return $null
}
if (!$parsedVersion.IsPrerelease) {
return '5 - Production/Stable'
}
# The parser treats unlabeled major-zero versions as prerelease beta versions.
if ($parsedVersion.Major -eq 0 -and [string]::IsNullOrEmpty($parsedVersion.PrereleaseLabel)) {
return '4 - Beta'
}
switch ($parsedVersion.PrereleaseLabel) {
'alpha' { return '3 - Alpha' }
'beta' { return '4 - Beta' }
'rc' { return '4 - Beta' }
default { throw "Unknown prerelease label '$($parsedVersion.PrereleaseLabel)' for version '$version'." }
}
}
function Get-PythonCommand {
# Try python3 first (common on Linux/macOS), but verify it works
# On Windows, "python3" may be a Store alias that doesn't work
try {
$null = & python3 --version 2>&1
if ($LASTEXITCODE -eq 0) {
return "python3"
}
} catch {}
# Fall back to python
try {
$null = & python --version 2>&1
if ($LASTEXITCODE -eq 0) {
return "python"
}
} catch {}
throw "Python is not installed or not in PATH. Please install Python 3.10+."
}
function BuildServerPackages([hashtable] $server, [bool] $native) {
$serverDirectory = "$ArtifactsPath/$($server.artifactPath)"
if (!(Test-Path $serverDirectory)) {
$message = "Server directory $serverDirectory does not exist."
if ($ignoreMissingArtifacts) {
Write-Warning $message
}
else {
Write-Error $message
}
return
}
$filteredPlatforms = $server.platforms | Where-Object { $_.native -eq $native -and -not $_.specialPurpose }
if ($filteredPlatforms.Count -eq 0) {
Write-Host "No platforms to build for server $($server.name) with native=$native"
return
}
$serverOutputPath = "$OutputPath/$($server.artifactPath)"
New-Item -ItemType Directory -Force -Path $serverOutputPath | Out-Null
# Use PyPI package name from csproj, skip servers without PyPI configuration
$basePackageName = if ([string]::IsNullOrWhiteSpace($server.pypiPackageName)) { $null } else { $server.pypiPackageName }
if (!$basePackageName) {
Write-Host "Skipping $($server.name) - no PyPI package name configured" -ForegroundColor Yellow
return
}
$pythonVersion = ConvertTo-PythonPackageVersion $server.version
if (!$pythonVersion) {
Write-Error "Server version '$($server.version)' is not a valid semantic version for PyPI packaging."
return
}
$pythonVersion = $pythonVersion.ToString()
$devStatus = Get-DevStatus $server.version
$description = if ([string]::IsNullOrWhiteSpace($server.pypiDescription)) { $server.description } else { $server.pypiDescription }
$cliName = $server.cliName
$keywords = @(if ($server.pypiPackageKeywords) { $server.pypiPackageKeywords } else { $server.npmPackageKeywords })
$moduleName = Get-ModuleName $basePackageName
if ($native) {
$basePackageName += "-native"
$description += " with native dependencies"
$keywords += "native"
$moduleName = Get-ModuleName $basePackageName
}
$builtPlatforms = @()
# Build a wheel for each platform
foreach ($platform in $filteredPlatforms) {
$platformDirectory = "$ArtifactsPath/$($platform.artifactPath)"
if (!(Test-Path $platformDirectory)) {
$errorMessage = "Platform directory $platformDirectory does not exist."
if ($ignoreMissingArtifacts) {
Write-Warning $errorMessage
continue
}
Write-Error $errorMessage
return
}
$pypiOs = $platform.nodeOs
$arch = $platform.architecture
$platformKey = "$pypiOs-$arch"
$wheelPlatformTag = $wheelPlatformMap[$platformKey]
if (!$wheelPlatformTag) {
Write-Warning "Unknown platform: $platformKey, skipping"
continue
}
$osClassifier = $osClassifierMap[$pypiOs]
$extension = $platform.extension
Write-Host "`nBuilding wheel for $basePackageName ($platformKey)" -ForegroundColor Cyan
# Clean temp folder
Remove-Item -Path $tempFolder -Recurse -Force -ErrorAction SilentlyContinue -ProgressAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $tempFolder | Out-Null
New-Item -ItemType Directory -Force -Path "$tempFolder/src/$moduleName/bin" | Out-Null
# Copy binary files
Write-Host " Copying binaries from $platformDirectory"
Copy-Item -Path "$platformDirectory/*" -Destination "$tempFolder/src/$moduleName/bin" -Recurse -Force
# Copy package __init__.py
Copy-Item -Path "$pypiSourcePath/__init__.py" -Destination "$tempFolder/src/$moduleName/__init__.py" -Force
# Remove symbols files before packing
Write-Host " Removing symbol files"
Get-ChildItem -Path $tempFolder -Recurse -Include "*.pdb", "*.dSYM", "*.dbg" | Remove-Item -Force -Recurse -ErrorAction SilentlyContinue -ProgressAction SilentlyContinue
# Read template and replace placeholders
$pyprojectTemplate = Get-Content "$pypiSourcePath/pyproject.toml.template" -Raw
$pyprojectContent = $pyprojectTemplate `
-replace '{{PACKAGE_NAME}}', $basePackageName `
-replace '{{VERSION}}', $pythonVersion `
-replace '{{DESCRIPTION}}', $description `
-replace '{{KEYWORDS}}', (Get-KeywordsString $keywords) `
-replace '{{OS_CLASSIFIER}}', $osClassifier `
-replace '{{CLI_NAME}}', $cliName `
-replace '{{MODULE_NAME}}', $moduleName `
-replace '{{HOMEPAGE}}', $server.readmeUrl `
-replace '{{DEV_STATUS}}', $devStatus
$pyprojectPath = "$tempFolder/pyproject.toml"
Write-Host " Writing pyproject.toml"
Write-Host $pyprojectContent
$pyprojectContent | Out-File -FilePath $pyprojectPath -Encoding utf8 -Force
# Configure version and CLI name in __init__.py
$initPyPath = "$tempFolder/src/$moduleName/__init__.py"
$initPyContent = Get-Content $initPyPath -Raw
$initPyContent = $initPyContent `
-replace '{{VERSION}}', $pythonVersion `
-replace '{{CLI_NAME}}', $cliName
$initPyContent | Out-File -FilePath $initPyPath -Encoding utf8 -Force
# Set executable permissions on non-Windows
$binPath = "bin/$cliName$extension"
if (!$IsWindows) {
Write-Host " Setting executable permissions" -ForegroundColor Yellow
$binFullPath = "$tempFolder/src/$moduleName/$binPath"
if (Test-Path $binFullPath) {
Invoke-LoggedCommand "chmod +x `"$binFullPath`""
}
}
else {
Write-Warning " Executable permissions are not set when packing on a Windows agent."
}
# Process and copy README
$insertPayload = @{
ToolTitle = 'PyPI Package'
# The mcp-name HTML comment is required by the MCP registry for package ownership validation.
# It must appear as 'mcp-name: <server-name>' in the package README or publishing will fail with a 400 error.
MCPRepositoryMetadata = "<!-- mcp-name: $($server.mcpRepositoryName) -->"
}
& "$RepoRoot/eng/scripts/Process-PackageReadMe.ps1" `
-Command "extract" `
-InputReadMePath "$RepoRoot/$($server.readmePath)" `
-PackageType "pypi" `
-InsertPayload $insertPayload `
-OutputDirectory $tempFolder
Write-Host " Copying LICENSE and NOTICE.txt"
Copy-Item -Path "$RepoRoot/LICENSE" -Destination $tempFolder -Force
Copy-Item -Path "$RepoRoot/NOTICE.txt" -Destination $tempFolder -Force
# Build the wheel with platform-specific tag
Write-Host " Building wheel" -ForegroundColor Green
Push-Location $tempFolder
try {
$pythonCmd = Get-PythonCommand
Invoke-LoggedCommand "$pythonCmd -m pip install --quiet build==1.2.2 wheel==0.45.1"
# Build wheel only (no sdist for platform packages)
# We use --wheel and then rename to set the correct platform tag
Invoke-LoggedCommand "$pythonCmd -m build --wheel"
# Rename the wheel to include the correct platform tag
$distPath = "$tempFolder/dist"
if (Test-Path $distPath) {
$wheels = Get-ChildItem -Path $distPath -Filter "*.whl"
foreach ($wheel in $wheels) {
# The default wheel name is like: msmcp_azure-1.0.0-py3-none-any.whl
# We need to change it to: msmcp_azure-1.0.0-py3-none-<platform>.whl
$newName = $wheel.Name -replace '-py3-none-any\.whl$', "-py3-none-$wheelPlatformTag.whl"
$newPath = Join-Path $distPath $newName
Write-Host " Renaming wheel to $newName"
Move-Item -Path $wheel.FullName -Destination $newPath -Force
# Copy to output
Copy-Item -Path $newPath -Destination $serverOutputPath -Force
Write-Host " ✅ Created: $newName" -ForegroundColor Green
}
}
}
finally {
Pop-Location
}
$builtPlatforms += $platformKey
}
Write-Host "`n✅ PyPI packages built successfully for $($server.name)" -ForegroundColor Green
Write-Host " Package: $basePackageName"
Write-Host " Platforms: $($builtPlatforms -join ', ')"
}
# Main execution
foreach ($server in $buildInfo.servers) {
Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host "Building PyPI packages for $($server.name)" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
# Build non-native packages
BuildServerPackages $server $false
# Build native packages if available
$hasNative = ($server.platforms | Where-Object { $_.native -eq $true }).Count -gt 0
if ($hasNative) {
BuildServerPackages $server $true
}
}
# Cleanup temp folder
Remove-Item -Path $tempFolder -Recurse -Force -ErrorAction SilentlyContinue -ProgressAction SilentlyContinue
Write-Host "`n========================================" -ForegroundColor Green
Write-Host "PyPI packaging complete!" -ForegroundColor Green
Write-Host "Output: $OutputPath" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
exit $exitCode