From 74493de106ae1812d7b678cf5f8892925e4676df Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 12 Jun 2026 12:41:56 -0400 Subject: [PATCH 1/3] Port npm release pipeline changes to main Backports the release/13.4 npm publish validation and CLI package metadata changes from #18093 onto main. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + docs/specs/npm-cli-package.md | 6 +- eng/pipelines/common-variables.yml | 4 - eng/pipelines/release-publish-nuget.yml | 264 ++++++++++++------ .../pack-cli-npm-package.pointer.README.md | 99 +++++++ eng/scripts/pack-cli-npm-package.ps1 | 54 ++-- .../pack-cli-npm-package.rid.README.md | 5 + eng/scripts/validate-npm-release-aliases.ps1 | 148 ++++++++++ .../ChannelUpdateWorkflowTests.cs | 38 ++- .../Helpers/CliInstallStrategyTests.cs | 11 + .../Helpers/KubernetesDeployTestHelpers.cs | 4 +- .../Pipelines/NpmCliPackageTests.cs | 249 ++++++++++++++--- .../ReleasePublishNugetPipelineTests.cs | 197 +++++++++++-- .../ValidateNpmReleaseAliasesTests.cs | 198 +++++++++++++ tests/Shared/CliInstallStrategy.cs | 14 + 15 files changed, 1097 insertions(+), 195 deletions(-) create mode 100644 eng/scripts/pack-cli-npm-package.pointer.README.md create mode 100644 eng/scripts/pack-cli-npm-package.rid.README.md create mode 100644 eng/scripts/validate-npm-release-aliases.ps1 create mode 100644 tests/Infrastructure.Tests/PowerShellScripts/ValidateNpmReleaseAliasesTests.cs diff --git a/.gitignore b/.gitignore index 5c06f809418..a5c7be7bb4f 100644 --- a/.gitignore +++ b/.gitignore @@ -203,6 +203,7 @@ extension/.test-artifacts/ extension/.test-extensions/ extension/.test-results/ extension/.test-storage/ +extension/.test-workspace/ extension/.test-workspaces/ extension/node_modules/ extension/.corepack-cache/ diff --git a/docs/specs/npm-cli-package.md b/docs/specs/npm-cli-package.md index dcff80a051d..9b936bc0609 100644 --- a/docs/specs/npm-cli-package.md +++ b/docs/specs/npm-cli-package.md @@ -188,11 +188,11 @@ The release pipeline prepares two npm artifact folders and one validation artifa The package split is intentional. The release job submits RID packages first, waits for the ESRP submission to complete, waits an additional npm registry propagation delay, and then submits the pointer package. Publishing the pointer package last avoids optional dependency resolution races when a user installs the top-level package immediately after release. -Before publishing, the release pipeline validates that exactly one pointer tarball and exactly one tarball for each supported RID are present, that every tarball has a detached `.tgz.sig` sidecar, that all tarballs have one version, and that the `NpmValidationSummary` artifact reports `validatedByPreparePipeline: true` with every required check `passed` for Windows, Linux, and macOS install validation. Non-dry-run npm publishing resolves the default ESRP owner and approver aliases from `eng/pipelines/common-variables.yml`; explicit `NpmPublishOwners` and `NpmPublishApprovers` overrides are allowed only if they still include the required release aliases and do not overlap. +Before publishing, the release pipeline validates that exactly one pointer tarball and exactly one tarball for each supported RID are present, that every tarball has a detached `.tgz.sig` sidecar, that all tarballs have one version, and that the `NpmValidationSummary` artifact reports `validatedByPreparePipeline: true` with every required check `passed` for Windows, Linux, and macOS install validation. npm publishing reads its ESRP identities from the `NpmPublishOwners` and `NpmPublishApprovers` pipeline parameters, which default to working values in `eng/pipelines/release-publish-nuget.yml` so an unattended queue submission does not fail and can be overridden per run: owners must include at least one required release owner alias configured in the pipeline, approvers must contain exactly one Microsoft alias or `@microsoft.com` email address, and the owner and approver sets must not overlap. The pipeline forwards both parameters to the validation step as environment variables (rather than interpolating them into the inline script) so the operator-supplied values are treated as data. The validation logic lives in `eng/scripts/validate-npm-release-aliases.ps1`; the release job runs with `checkout: none`, so the same helpers are mirrored inline in the pipeline and kept in sync by a unit test. -The release pipeline checks only the package groups scheduled for publishing before invoking MicroBuild. If `SkipNpmRidPublish=false`, every staged RID tarball is checked with `npm view @ version`; if `SkipNpmPointerPublish=false`, the pointer tarball is checked the same way. Any scheduled package version that already exists on npm fails before ESRP submission, because npm versions are immutable and a duplicate publish would otherwise fail later in MicroBuild. Re-runs after partial success should use `SkipNpmRidPublish=true` only when every RID package for the selected version is already live, `SkipNpmPointerPublish=true` only when the pointer package is already live, and `SkipNpmPublish=true` only after the entire npm publish path has completed. +The release pipeline checks only the package groups scheduled for publishing before invoking MicroBuild. If `SkipNpmRidPublish=false`, every staged RID tarball is checked with `npm view @ version`; if `SkipNpmPointerPublish=false`, the pointer tarball is checked the same way. Any scheduled package version that already exists on npm fails before ESRP submission, because npm versions are immutable and a duplicate publish would otherwise fail later in MicroBuild. Re-runs after partial success should use `SkipNpmRidPublish=true` only when every RID package for the selected version is already live, `SkipNpmPointerPublish=true` only when the pointer package is already live, and both flags together only after the entire npm publish path has completed. -Stable Aspire npm releases publish through npm's default `latest` dist-tag because MicroBuild's npm publish template does not currently expose a dist-tag parameter. To prevent older servicing releases from moving `@microsoft/aspire-cli@latest` backward, the release pipeline compares the scheduled pointer package version with the current public `@microsoft/aspire-cli@latest` version and fails if the scheduled version is lower. Older servicing releases should set `SkipNpmPublish=true`; `AllowNpmLatestDistTagMove=true` exists only as an emergency release-owner override for an intentional latest-tag move. +Stable Aspire npm releases publish through npm's default `latest` dist-tag because MicroBuild's npm publish template does not currently expose a dist-tag parameter. To prevent older servicing releases from moving `@microsoft/aspire-cli@latest` backward, the release pipeline compares the scheduled pointer package version with the current public `@microsoft/aspire-cli@latest` version and fails if the scheduled version is lower. Older servicing releases should set both `SkipNpmRidPublish=true` and `SkipNpmPointerPublish=true`. MicroBuild's npm publish template documentation does not currently expose an npm `dist-tag` parameter. Non-dry-run prerelease npm publishing is blocked until preview packages can be submitted under a non-`latest` tag; release managers can still use `DryRun=true` to inspect the npm publish set without submitting packages. diff --git a/eng/pipelines/common-variables.yml b/eng/pipelines/common-variables.yml index 0d876ccc8bc..411eb9b8272 100644 --- a/eng/pipelines/common-variables.yml +++ b/eng/pipelines/common-variables.yml @@ -20,10 +20,6 @@ variables: value: npm-validation-summary-linux-x64 - name: NPM_VALIDATION_SUMMARY_OSX_ARTIFACT value: npm-validation-summary-osx - - name: NPM_PUBLISH_REQUIRED_OWNERS - value: joperezr,ankj - - name: NPM_PUBLISH_REQUIRED_APPROVERS - value: adamratzman # Disable the interactive "Do you want to download yarn@x.y.z?" prompt so # pipeline steps don't hang waiting for stdin. - name: COREPACK_ENABLE_DOWNLOAD_PROMPT diff --git a/eng/pipelines/release-publish-nuget.yml b/eng/pipelines/release-publish-nuget.yml index 12b4ea56380..32de8e9326e 100644 --- a/eng/pipelines/release-publish-nuget.yml +++ b/eng/pipelines/release-publish-nuget.yml @@ -53,23 +53,13 @@ parameters: type: boolean default: false - - name: SkipNpmPublish - displayName: 'Skip npm Publishing (set true if already completed)' - type: boolean - default: false - - name: SkipNpmRidPublish - displayName: 'Skip npm RID Package Publishing (set true if RID packages already completed but pointer package did not)' + displayName: '[Advanced] Skip npm RID Package Publishing (set true if RID packages already completed but pointer package did not)' type: boolean default: false - name: SkipNpmPointerPublish - displayName: 'Skip npm Pointer Package Publishing (set true if pointer package already completed)' - type: boolean - default: false - - - name: AllowNpmLatestDistTagMove - displayName: '[Advanced] Allow npm latest dist-tag to move to an older stable version' + displayName: '[Advanced] Skip npm Pointer Package Publishing (set true if pointer package already completed)' type: boolean default: false @@ -98,20 +88,21 @@ parameters: type: boolean default: false - # Azure DevOps manual string parameters cannot be left blank. These defaults - # must include the NPM_PUBLISH_REQUIRED_* aliases from common-variables.yml. + # Azure DevOps manual string parameters cannot be left blank. Keep these + # defaults valid for unattended queue submissions: the owners must include a + # required ESRP owner alias, and the approver must be a single distinct alias. - name: NpmPublishOwners - displayName: 'npm ESRP owners (comma-separated Microsoft aliases or emails; leave unchanged for repo default)' + displayName: '[Advanced] npm ESRP owners (comma-separated Microsoft aliases or emails; must include joperezr or ankj)' type: string default: 'joperezr,ankj' - name: NpmPublishApprovers - displayName: 'npm ESRP approvers (comma-separated Microsoft aliases or emails; leave unchanged for repo default)' + displayName: '[Advanced] npm ESRP approver (single Microsoft alias or email; must differ from the owners)' type: string - default: 'adamratzman,David.Pine' + default: 'adamratzman' - name: NpmRegistryPropagationDelayMinutes - displayName: 'Minutes to wait between npm RID and pointer package submissions' + displayName: '[Advanced] Minutes to wait between npm RID and pointer package submissions' type: number default: 10 @@ -134,6 +125,8 @@ parameters: variables: - template: /eng/pipelines/common-variables.yml@self - template: /eng/common/templates-official/variables/pool-providers.yml@self + - name: NPM_PUBLISH_REQUIRED_OWNERS + value: joperezr,ankj # Variable group containing VscePublishToken for VS Code Marketplace publishing and aspire-repo-bot credentials # Note: NuGet publishing uses service connection 'NuGet.org - dotnet/aspire' instead of API key - group: Aspire-Release-Secrets @@ -205,6 +198,7 @@ extends: os: windows variables: SourceBuildId: $(resources.pipeline.aspire-build.runID) + SourceBuildPipeline: microsoft-aspire templateContext: # Disable the auto-injected MicroBuildAuthorizePublishPlugin@0 task for jobs # that do not perform an ESRP publish. PrepareJob only downloads artifacts @@ -256,7 +250,13 @@ extends: Write-Host "Source Build ID: $(resources.pipeline.aspire-build.runID)" Write-Host "Source Build Name: $(resources.pipeline.aspire-build.runName)" Write-Host "This stage downloads artifacts and re-publishes them so 1ES PT can generate SBOM." - Write-Host "Installer-only mode: ${{ and(eq(parameters.SkipNuGetPublish, true), eq(parameters.SkipNpmPublish, true), eq(parameters.SkipChannelPromotion, true)) }}" + $installerOnlyMode = ( + "${{ parameters.SkipNuGetPublish }}" -eq "true" -and + "${{ parameters.SkipNpmRidPublish }}" -eq "true" -and + "${{ parameters.SkipNpmPointerPublish }}" -eq "true" -and + "${{ parameters.SkipChannelPromotion }}" -eq "true" + ) + Write-Host "Installer-only mode: $installerOnlyMode" Write-Host "Skip VS Code Extension Publishing: ${{ parameters.SkipVSCodeExtensionPublish }}" Write-Host "===============================" displayName: 'Log Stage Info' @@ -361,7 +361,7 @@ extends: artifact: PackageArtifacts patterns: '**/*.nupkg' - - ${{ if eq(parameters.SkipNpmPublish, false) }}: + - ${{ if or(eq(parameters.SkipNpmRidPublish, false), eq(parameters.SkipNpmPointerPublish, false)) }}: - download: aspire-build displayName: 'Download npm packages from Source Build' artifact: BlobArtifacts @@ -369,17 +369,48 @@ extends: **/microsoft-aspire-cli*.tgz **/microsoft-aspire-cli*.tgz.sig - - download: aspire-build + # The source build publishes npm validation summaries with 1ES.PublishBuildArtifacts, + # so they are build/container artifacts. Use DownloadBuildArtifacts explicitly instead + # of the `download:` shortcut, which selects the pipeline-artifact downloader here and + # cannot read container artifact metadata such as #//. + - task: DownloadBuildArtifacts@0 displayName: 'Download npm validation summary from Source Build (win-x64)' - artifact: $(NPM_VALIDATION_SUMMARY_WIN_X64_ARTIFACT) - - - download: aspire-build + inputs: + buildType: specific + project: internal + pipeline: $(SourceBuildPipeline) + buildVersionToDownload: specific + buildId: $(SourceBuildId) + downloadType: single + artifactName: $(NPM_VALIDATION_SUMMARY_WIN_X64_ARTIFACT) + downloadPath: '$(Pipeline.Workspace)/aspire-build' + checkDownloadedFiles: true + + - task: DownloadBuildArtifacts@0 displayName: 'Download npm validation summary from Source Build (linux-x64)' - artifact: $(NPM_VALIDATION_SUMMARY_LINUX_X64_ARTIFACT) - - - download: aspire-build + inputs: + buildType: specific + project: internal + pipeline: $(SourceBuildPipeline) + buildVersionToDownload: specific + buildId: $(SourceBuildId) + downloadType: single + artifactName: $(NPM_VALIDATION_SUMMARY_LINUX_X64_ARTIFACT) + downloadPath: '$(Pipeline.Workspace)/aspire-build' + checkDownloadedFiles: true + + - task: DownloadBuildArtifacts@0 displayName: 'Download npm validation summary from Source Build (macOS)' - artifact: $(NPM_VALIDATION_SUMMARY_OSX_ARTIFACT) + inputs: + buildType: specific + project: internal + pipeline: $(SourceBuildPipeline) + buildVersionToDownload: specific + buildId: $(SourceBuildId) + downloadType: single + artifactName: $(NPM_VALIDATION_SUMMARY_OSX_ARTIFACT) + downloadPath: '$(Pipeline.Workspace)/aspire-build' + checkDownloadedFiles: true - ${{ if eq(parameters.SkipWinGetPublish, false) }}: - download: aspire-build @@ -426,7 +457,7 @@ extends: Write-Host "Installer-only run detected; created empty PackageArtifacts placeholder." displayName: 'Prepare Empty PackageArtifacts Placeholder' - - ${{ if eq(parameters.SkipNpmPublish, false) }}: + - ${{ if or(eq(parameters.SkipNpmRidPublish, false), eq(parameters.SkipNpmPointerPublish, false)) }}: - powershell: | $sourcePath = "$(Pipeline.Workspace)/aspire-build/BlobArtifacts" $ridTargetPath = "$(Pipeline.Workspace)/npm/rid-packages" @@ -457,7 +488,7 @@ extends: "npm CLI packages ship as flat blobs in BlobArtifacts (configured via eng/Publishing.props).", "Possible causes:", " 1. The source build (azure-pipelines.yml) ran before the npm CLI package work landed.", - " Re-run the release pipeline with SkipNpmPublish=true, or rebuild from a newer commit.", + " Re-run the release pipeline with SkipNpmRidPublish=true and SkipNpmPointerPublish=true, or rebuild from a newer commit.", " 2. The source build failed to produce or publish the BlobArtifacts pipeline artifact.", " 3. The wrong source build was selected." ) @@ -598,7 +629,7 @@ extends: Write-Host "Prepared $($ridPackages.Count) RID npm package(s), pointer package version $($versions[0]), and $($signatureSidecars.Count) detached signature sidecar(s)." displayName: 'Prepare npm Artifacts for Publishing' - - ${{ if eq(parameters.SkipNpmPublish, true) }}: + - ${{ if and(eq(parameters.SkipNpmRidPublish, true), eq(parameters.SkipNpmPointerPublish, true)) }}: - powershell: | New-Item -ItemType Directory -Path "$(Pipeline.Workspace)/npm/rid-packages" -Force | Out-Null New-Item -ItemType Directory -Path "$(Pipeline.Workspace)/npm/pointer-package" -Force | Out-Null @@ -607,7 +638,7 @@ extends: New-Item -ItemType Directory -Path "$(Pipeline.Workspace)/npm/validation-summary/linux-x64" -Force | Out-Null New-Item -ItemType Directory -Path "$(Pipeline.Workspace)/npm/validation-summary/osx" -Force | Out-Null New-Item -ItemType Directory -Path "$(Pipeline.Workspace)/npm/signatures" -Force | Out-Null - Write-Host "Created empty npm artifact placeholders because SkipNpmPublish=true." + Write-Host "Created empty npm artifact placeholders because SkipNpmRidPublish=true and SkipNpmPointerPublish=true." displayName: 'Prepare Empty npm Artifact Placeholders' # Copy installer artifacts to expected locations for output. @@ -765,13 +796,18 @@ extends: Write-Host "Is Prerelease: ${{ parameters.IsPrerelease }}" Write-Host "Dry Run: ${{ parameters.DryRun }}" Write-Host "Skip NuGet Publish: ${{ parameters.SkipNuGetPublish }}" - Write-Host "Skip npm Publish: ${{ parameters.SkipNpmPublish }}" Write-Host "Skip npm RID Publish: ${{ parameters.SkipNpmRidPublish }}" Write-Host "Skip npm Pointer Publish: ${{ parameters.SkipNpmPointerPublish }}" Write-Host "Skip Channel Promotion: ${{ parameters.SkipChannelPromotion }}" - Write-Host "Installer-only mode: ${{ and(eq(parameters.SkipNuGetPublish, true), eq(parameters.SkipNpmPublish, true), eq(parameters.SkipChannelPromotion, true)) }}" + $installerOnlyMode = ( + "${{ parameters.SkipNuGetPublish }}" -eq "true" -and + "${{ parameters.SkipNpmRidPublish }}" -eq "true" -and + "${{ parameters.SkipNpmPointerPublish }}" -eq "true" -and + "${{ parameters.SkipChannelPromotion }}" -eq "true" + ) + Write-Host "Installer-only mode: $installerOnlyMode" - if ("${{ parameters.SkipNpmPublish }}" -eq "false") { + if ("${{ parameters.SkipNpmRidPublish }}" -ne "true" -or "${{ parameters.SkipNpmPointerPublish }}" -ne "true") { # Validate ESRP owner/approver configuration on dry runs as well # as real publishes so a misconfigured alias set fails the cheap # dry run instead of only surfacing on the real release. The @@ -779,23 +815,45 @@ extends: # a dry run never actually publishes and is the documented way to # exercise the npm path for a prerelease build. if ("${{ parameters.DryRun }}" -eq "false" -and "${{ parameters.IsPrerelease }}" -eq "true") { - Write-Error "npm publishing is blocked for prerelease runs because the MicroBuild npm publish template does not yet expose a dist-tag parameter. Set SkipNpmPublish=true or run with DryRun=true until prereleases can publish under a non-latest tag." + Write-Error "npm publishing is blocked for prerelease runs because the MicroBuild npm publish template does not yet expose a dist-tag parameter. Set SkipNpmRidPublish=true and SkipNpmPointerPublish=true, or run with DryRun=true until prereleases can publish under a non-latest tag." exit 1 } + # The alias-validation helpers below are mirrored in + # eng/scripts/validate-npm-release-aliases.ps1, which is exercised directly by + # ValidateNpmReleaseAliasesTests. releaseJob runs with `checkout: none`, so these + # functions are inlined here instead of dot-sourced; the + # ReleasePublishNugetPipelineTests.NpmAliasValidationHelpersMatchScript test keeps + # the inline copy and the script in sync. + # >>> BEGIN npm release alias helpers (keep in sync with eng/scripts/validate-npm-release-aliases.ps1) >>> + function Format-NpmReleaseAliasForError([string] $value) { + $escaped = $value.Replace("`r", '\r').Replace("`n", '\n') + return [regex]::Replace($escaped, '##vso\[', '## vso[', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + } + function ConvertTo-NpmReleaseAliasSet([string] $value, [string] $parameterName) { $aliases = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + # Parse values supplied as: + # joperezr, ankj@microsoft.com + # The normalized aliases are later emitted in Azure Pipelines logging commands, so + # accept only a small single-line alias alphabet before writing them back to the log. foreach ($entry in $value -split ',') { $alias = $entry.Trim() if ([string]::IsNullOrWhiteSpace($alias)) { continue } + $originalAlias = $alias if ($alias.EndsWith('@microsoft.com', [StringComparison]::OrdinalIgnoreCase)) { $alias = $alias.Substring(0, $alias.Length - '@microsoft.com'.Length) } elseif ($alias.Contains('@')) { - Write-Error "$parameterName entry '$entry' must be a Microsoft alias or @microsoft.com email address." + Write-Error "$parameterName entry '$(Format-NpmReleaseAliasForError $originalAlias)' must be a Microsoft alias or @microsoft.com email address." + exit 1 + } + + if ([string]::IsNullOrWhiteSpace($alias) -or $alias -notmatch '\A[A-Za-z0-9][A-Za-z0-9._-]*\z') { + Write-Error "$parameterName entry '$(Format-NpmReleaseAliasForError $originalAlias)' must be a non-empty Microsoft alias or @microsoft.com email address containing only letters, digits, '.', '_' or '-'." exit 1 } @@ -805,66 +863,86 @@ extends: return ,$aliases } - function Assert-ContainsRequiredNpmAliases( + function Assert-SingleNpmReleaseAlias( [System.Collections.Generic.HashSet[string]] $actualAliases, - [System.Collections.Generic.HashSet[string]] $requiredAliases, [string] $parameterName) { - $missing = @($requiredAliases | Where-Object { -not $actualAliases.Contains($_) }) - if ($missing.Count -gt 0) { - Write-Error "$parameterName must include required ESRP alias(es): $($missing -join ', ')." + if ($actualAliases.Count -ne 1) { + Write-Error "$parameterName must contain exactly one Microsoft alias or @microsoft.com email address." exit 1 } } - $owners = "${{ parameters.NpmPublishOwners }}" - $approvers = "${{ parameters.NpmPublishApprovers }}" - $requiredNpmOwnersValue = "$(NPM_PUBLISH_REQUIRED_OWNERS)" - $requiredNpmApproversValue = "$(NPM_PUBLISH_REQUIRED_APPROVERS)" - - if ([string]::IsNullOrWhiteSpace($owners)) { - $owners = $requiredNpmOwnersValue - Write-Host "NpmPublishOwners not provided; using NPM_PUBLISH_REQUIRED_OWNERS." + function Assert-ContainsAnyRequiredNpmOwnerAlias( + [System.Collections.Generic.HashSet[string]] $actualAliases, + [System.Collections.Generic.HashSet[string]] $requiredAliases, + [string] $parameterName) { + $matches = @($requiredAliases | Where-Object { $actualAliases.Contains($_) }) + if ($matches.Count -eq 0) { + $requiredAliasList = ($requiredAliases | Sort-Object) -join ', ' + Write-Error "$parameterName must include at least one required ESRP owner alias: $requiredAliasList." + exit 1 + } } - if ([string]::IsNullOrWhiteSpace($approvers)) { - $approvers = $requiredNpmApproversValue - Write-Host "NpmPublishApprovers not provided; using NPM_PUBLISH_REQUIRED_APPROVERS." - } + function Invoke-NpmReleaseAliasValidation( + [string] $owners, + [string] $approvers, + [string] $requiredNpmOwnersValue) { + $requiredNpmOwners = ConvertTo-NpmReleaseAliasSet $requiredNpmOwnersValue 'NPM_PUBLISH_REQUIRED_OWNERS' + $normalizedOwners = ConvertTo-NpmReleaseAliasSet $owners 'NpmPublishOwners' + $normalizedApprovers = ConvertTo-NpmReleaseAliasSet $approvers 'NpmPublishApprovers' - $requiredNpmOwners = ConvertTo-NpmReleaseAliasSet $requiredNpmOwnersValue 'NPM_PUBLISH_REQUIRED_OWNERS' - $requiredNpmApprovers = ConvertTo-NpmReleaseAliasSet $requiredNpmApproversValue 'NPM_PUBLISH_REQUIRED_APPROVERS' - $normalizedOwners = ConvertTo-NpmReleaseAliasSet $owners 'NpmPublishOwners' - $normalizedApprovers = ConvertTo-NpmReleaseAliasSet $approvers 'NpmPublishApprovers' + if ($normalizedOwners.Count -eq 0) { + Write-Error "NpmPublishOwners must contain at least one alias before publishing npm packages." + exit 1 + } - if ($normalizedOwners.Count -eq 0) { - Write-Error "NpmPublishOwners must contain at least one alias before publishing npm packages." - exit 1 - } + if ($normalizedApprovers.Count -eq 0) { + Write-Error "NpmPublishApprovers must contain at least one alias before publishing npm packages." + exit 1 + } - if ($normalizedApprovers.Count -eq 0) { - Write-Error "NpmPublishApprovers must contain at least one alias before publishing npm packages." - exit 1 - } + Assert-ContainsAnyRequiredNpmOwnerAlias $normalizedOwners $requiredNpmOwners 'NpmPublishOwners' + Assert-SingleNpmReleaseAlias $normalizedApprovers 'NpmPublishApprovers' - Assert-ContainsRequiredNpmAliases $normalizedOwners $requiredNpmOwners 'NpmPublishOwners' - Assert-ContainsRequiredNpmAliases $normalizedApprovers $requiredNpmApprovers 'NpmPublishApprovers' + $overlappingAliases = @($normalizedOwners | Where-Object { $normalizedApprovers.Contains($_) }) + if ($overlappingAliases.Count -gt 0) { + Write-Error "NpmPublishOwners and NpmPublishApprovers must not contain the same alias(es): $($overlappingAliases -join ', ')." + exit 1 + } - $overlappingAliases = @($normalizedOwners | Where-Object { $normalizedApprovers.Contains($_) }) - if ($overlappingAliases.Count -gt 0) { - Write-Error "NpmPublishOwners and NpmPublishApprovers must not contain the same alias(es): $($overlappingAliases -join ', ')." - exit 1 + $effectiveOwners = ($normalizedOwners | Sort-Object) -join ',' + $effectiveApprovers = ($normalizedApprovers | Sort-Object) -join ',' + + Write-Host "##vso[task.setvariable variable=NpmPublishOwnersEffective]$effectiveOwners" + Write-Host "##vso[task.setvariable variable=NpmPublishApproversEffective]$effectiveApprovers" + Write-Host "npm ESRP owners and approvers were resolved and include the required release contacts." } + # <<< END npm release alias helpers <<< - $effectiveOwners = ($normalizedOwners | Sort-Object) -join ',' - $effectiveApprovers = ($normalizedApprovers | Sort-Object) -join ',' + # Read queue-time owner/approver values from the environment instead of + # interpolating template expressions into this script. Compile-time string + # interpolation would let an operator-supplied value break out of the quoted + # literal and run as code; passing them through the step's env block keeps the + # values as data until ConvertTo-NpmReleaseAliasSet validates them. + $owners = $env:NPM_PUBLISH_OWNERS + $approvers = $env:NPM_PUBLISH_APPROVERS + $requiredNpmOwnersValue = $env:NPM_PUBLISH_REQUIRED_OWNERS - Write-Host "##vso[task.setvariable variable=NpmPublishOwnersEffective]$effectiveOwners" - Write-Host "##vso[task.setvariable variable=NpmPublishApproversEffective]$effectiveApprovers" - Write-Host "npm ESRP owners and approvers were resolved and include the required release contacts." + Invoke-NpmReleaseAliasValidation $owners $approvers $requiredNpmOwnersValue } Write-Host "===================================" displayName: 'Validate Parameters' + # Forward the queue-time owner/approver values (and the hard-coded required + # owners) as environment variables so the inline script reads them as data. + # Embedding template expressions inside a string scalar keeps Azure Pipelines from + # preserving expression-object typing for the env value while still avoiding + # interpolation into the PowerShell source. + env: + NPM_PUBLISH_OWNERS: '${{ parameters.NpmPublishOwners }}' + NPM_PUBLISH_APPROVERS: '${{ parameters.NpmPublishApprovers }}' + NPM_PUBLISH_REQUIRED_OWNERS: $(NPM_PUBLISH_REQUIRED_OWNERS) # ===== EXTRACT BAR BUILD ID ===== - ${{ if eq(parameters.SkipChannelPromotion, false) }}: @@ -1028,13 +1106,13 @@ extends: displayName: 'Skip NuGet Publish (flagged)' # ===== PUBLISH TO npm ===== - - ${{ if eq(parameters.SkipNpmPublish, false) }}: + - ${{ if or(eq(parameters.SkipNpmRidPublish, false), eq(parameters.SkipNpmPointerPublish, false)) }}: - task: NodeTool@0 displayName: 'Install Node.js for npm Validation' inputs: versionSpec: '22.x' - - ${{ if eq(parameters.SkipNpmPublish, false) }}: + - ${{ if or(eq(parameters.SkipNpmRidPublish, false), eq(parameters.SkipNpmPointerPublish, false)) }}: - powershell: | $ErrorActionPreference = 'Stop' $validationRoot = "$(Pipeline.Workspace)/npm/validation-summary" @@ -1221,8 +1299,9 @@ extends: # every staged RID and pointer tarball must be the exact version # that was install-tested. NpmValidatedExpectedVersion is set by # 'Validate npm Prepare-Stage Summaries' earlier in this job (both - # gated on SkipNpmPublish==false). A missing/empty/unexpanded token - # here means that gate did not run, so we must not publish. + # gated on at least one npm package being scheduled). A missing/ + # empty/unexpanded token here means that gate did not run, so we + # must not publish. $validatedVersion = "$(NpmValidatedExpectedVersion)" if ([string]::IsNullOrWhiteSpace($validatedVersion) -or $validatedVersion -like '$(*)') { Write-Error "NpmValidatedExpectedVersion was not set by the prepare-stage validation gate (got '$validatedVersion'). Refusing to publish npm packages without a verified version." @@ -1285,7 +1364,7 @@ extends: Write-Host "All staged npm tarballs match validated version '$validatedVersion'." displayName: 'Verify Staged npm Package Versions' - - ${{ if and(eq(parameters.DryRun, true), eq(parameters.SkipNpmPublish, false)) }}: + - ${{ if and(eq(parameters.DryRun, true), or(eq(parameters.SkipNpmRidPublish, false), eq(parameters.SkipNpmPointerPublish, false))) }}: - powershell: | Write-Host "=== DRY RUN MODE ===" Write-Host "The following packages would be submitted to ESRP for npm publishing." @@ -1367,7 +1446,7 @@ extends: Write-Host "Dry run skipped installing '$packageSpec' because the package has not been published." displayName: 'Dry Run - Validate npm Registry Reachability' - - ${{ if and(eq(parameters.DryRun, false), eq(parameters.SkipNpmPublish, false), eq(parameters.IsPrerelease, false)) }}: + - ${{ if and(eq(parameters.DryRun, false), or(eq(parameters.SkipNpmRidPublish, false), eq(parameters.SkipNpmPointerPublish, false)), eq(parameters.IsPrerelease, false)) }}: - pwsh: | $ErrorActionPreference = 'Stop' @@ -1451,7 +1530,7 @@ extends: $semverRegex = '^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$' $stableSemverRegex = '^\d+\.\d+\.\d+$' - if ("${{ parameters.AllowNpmLatestDistTagMove }}" -ne "true" -and "${{ parameters.SkipNpmPointerPublish }}" -ne "true") { + if ("${{ parameters.SkipNpmPointerPublish }}" -ne "true") { $pointerPackages = @($scheduledPackages | Where-Object { $_.Name -eq '@microsoft/aspire-cli' }) if ($pointerPackages.Count -ne 1) { Write-Error "Expected exactly one scheduled @microsoft/aspire-cli pointer package while checking the npm latest dist-tag, found $($pointerPackages.Count)." @@ -1475,7 +1554,7 @@ extends: } if ([version]$pointerPackage.Version -lt [version]$latestVersion) { - Write-Error "Publishing $($pointerPackage.Spec) would move the npm latest dist-tag backward from $latestVersion. Set SkipNpmPublish=true for older servicing releases, or set AllowNpmLatestDistTagMove=true only after release-owner approval." + Write-Error "Publishing $($pointerPackage.Spec) would move the npm latest dist-tag backward from $latestVersion. Set SkipNpmRidPublish=true and SkipNpmPointerPublish=true for older servicing releases." exit 1 } @@ -1486,8 +1565,6 @@ extends: Write-Error "Unable to query @microsoft/aspire-cli@latest before publishing. Output: $latestText" exit 1 } - } elseif ("${{ parameters.AllowNpmLatestDistTagMove }}" -eq "true") { - Write-Warning "AllowNpmLatestDistTagMove=true; skipping npm latest dist-tag downgrade guard." } $collisions = [System.Collections.Generic.List[string]]::new() @@ -1527,6 +1604,7 @@ extends: } Write-Host "No scheduled npm package versions already exist on npm." + exit 0 displayName: 'Verify npm Packages Are Not Already Published' - ${{ if eq(parameters.SkipNpmRidPublish, false) }}: @@ -1894,10 +1972,10 @@ extends: } displayName: 'Validate Published npm Package from Registry' - - ${{ if eq(parameters.SkipNpmPublish, true) }}: + - ${{ if and(eq(parameters.SkipNpmRidPublish, true), eq(parameters.SkipNpmPointerPublish, true)) }}: - powershell: | - Write-Host "=== Skipping npm Publishing (SkipNpmPublish=true) ===" - displayName: 'Skip npm Publish (flagged)' + Write-Host "=== Skipping npm Publishing (SkipNpmRidPublish=true and SkipNpmPointerPublish=true) ===" + displayName: 'Skip npm Packages (flagged)' # ===== PROMOTE TO CHANNEL ===== - ${{ if eq(parameters.SkipChannelPromotion, false) }}: @@ -1988,8 +2066,8 @@ extends: } else { Write-Host " (EXECUTED)" } - Write-Host "║ npm Publish: ${{ parameters.SkipNpmPublish }}" -NoNewline - if ("${{ parameters.SkipNpmPublish }}" -eq "true") { + Write-Host "║ npm Publish: RID skip=${{ parameters.SkipNpmRidPublish }}, pointer skip=${{ parameters.SkipNpmPointerPublish }}" -NoNewline + if ("${{ parameters.SkipNpmRidPublish }}" -eq "true" -and "${{ parameters.SkipNpmPointerPublish }}" -eq "true") { Write-Host " (SKIPPED)" } elseif ("${{ parameters.DryRun }}" -eq "true") { Write-Host " (DRY RUN)" @@ -1997,6 +2075,8 @@ extends: Write-Host " (BLOCKED - prerelease requires non-latest npm dist-tag support)" } elseif ("${{ parameters.SkipNpmPointerPublish }}" -eq "true") { Write-Host " (PARTIAL - pointer publish skipped; registry smoke still ran)" + } elseif ("${{ parameters.SkipNpmRidPublish }}" -eq "true") { + Write-Host " (PARTIAL - RID publish skipped; pointer publish still ran)" } else { Write-Host " (EXECUTED)" } diff --git a/eng/scripts/pack-cli-npm-package.pointer.README.md b/eng/scripts/pack-cli-npm-package.pointer.README.md new file mode 100644 index 00000000000..340befe48d4 --- /dev/null +++ b/eng/scripts/pack-cli-npm-package.pointer.README.md @@ -0,0 +1,99 @@ +# __PACKAGE_NAME__ + +[![CI](https://github.com/microsoft/aspire/actions/workflows/ci.yml/badge.svg?branch=main&event=push)](https://github.com/microsoft/aspire/actions/workflows/ci.yml) +[![Tests](https://github.com/microsoft/aspire/actions/workflows/tests.yml/badge.svg?branch=main&event=push)](https://github.com/microsoft/aspire/actions/workflows/tests.yml) + +The Aspire CLI, published for npm-based installs. + +## What is Aspire? + +Your stack, streamlined. Aspire is a multi-language, code-first orchestration and observability layer for building, running, and deploying distributed applications. + +Use an AppHost to describe how services, frontends, containers, databases, caches, and connections fit together in code. The Aspire CLI runs the whole app locally, opens the OpenTelemetry dashboard for logs, traces, metrics, and health checks, and carries the same app model into deployment. + +## A simple app definition + +The same application definition can be written in different languages. + +__C#__ (`apphost.cs`) + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var cache = builder.AddRedis("cache"); + +var api = builder.AddNodeApp("api", "./api", "src/index.ts") + .WithReference(cache) + .WaitFor(cache) + .WithHttpEndpoint(env: "PORT") + .WithExternalHttpEndpoints(); + +builder.AddViteApp("frontend", "./frontend") + .WithReference(api) + .WaitFor(api); + +builder.Build().Run(); +``` + +__TypeScript__ (`apphost.ts`) + +```typescript +import { createBuilder } from './.aspire/modules/aspire.js'; + +const builder = await createBuilder(); + +const cache = await builder.addRedis("cache"); + +const api = await builder + .addNodeApp("api", "./api", "src/index.ts") + .withReference(cache) + .waitFor(cache) + .withHttpEndpoint({ env: "PORT" }) + .withExternalHttpEndpoints(); + +await builder + .addViteApp("frontend", "./frontend") + .withReference(api) + .waitFor(api); + +await builder.build().run(); +``` + +## Install + +This package requires Node.js 20 or later. + +```bash +npm install -g __PACKAGE_NAME__ +``` + +Then verify the install: + +```bash +aspire --version +aspire --help +``` + +Start from a repo with one or more app projects: + +```bash +aspire init +aspire run +``` + +The native platform packages are installed through npm optional dependencies. Do not install this package with optional dependencies disabled, or the `aspire` launcher will not be able to find the native CLI binary. + +## Update + +```bash +npm install -g __PACKAGE_NAME__@latest +``` + +If you run `aspire update --self` from an npm install, the CLI points you back to this npm update command. + +## Learn more + +- [Documentation](https://aspire.dev/docs/) +- [Build your first app](https://aspire.dev/get-started/first-app/) +- [Aspire repository](https://github.com/microsoft/aspire) +- [Aspire samples repository](https://github.com/microsoft/aspire-samples) diff --git a/eng/scripts/pack-cli-npm-package.ps1 b/eng/scripts/pack-cli-npm-package.ps1 index 0edf55cd01a..87e00959b8a 100644 --- a/eng/scripts/pack-cli-npm-package.ps1 +++ b/eng/scripts/pack-cli-npm-package.ps1 @@ -102,6 +102,23 @@ function Write-TextFile([string]$Path, [string]$Value) { [System.IO.File]::WriteAllText($Path, $Value, $utf8NoBom) } +function Read-TemplateFile([string]$Path) { + if (-not (Test-Path -LiteralPath $Path)) { + throw "Template file does not exist: $Path" + } + + return [System.IO.File]::ReadAllText($Path) +} + +function Expand-Template([string]$Template, [hashtable]$Values) { + $result = $Template + foreach ($entry in $Values.GetEnumerator()) { + $result = $result.Replace("__$($entry.Key)__", [string]$entry.Value) + } + + return $result +} + function Invoke-NpmPack([string]$PackageDirectory, [string]$DestinationDirectory) { Write-Host "Packing npm package from $PackageDirectory" & npm pack $PackageDirectory --pack-destination $DestinationDirectory @@ -157,22 +174,12 @@ if ($ridInfo.Contains('Libc')) { } Write-JsonFile (Join-Path $ridPackageRoot 'package.json') $ridPackageJson -# Use a non-expanding here-string so the markdown backticks (`) survive verbatim. -# In a normal (double-quoted) here-string ` is the PowerShell escape character, which -# both swallows the backticks and suppresses $-interpolation; using @'...'@ and a -# manual -replace lets us emit literal `` code spans for $Rid / $PackageName. -$ridReadmeTemplate = @' -# __RID_PACKAGE_NAME__ - -Native Aspire CLI binary for `__RID__`. - -This package is installed as an optional dependency of `__PACKAGE_NAME__`. -'@ - -$ridReadme = $ridReadmeTemplate ` - -replace '__RID_PACKAGE_NAME__', $ridPackageName ` - -replace '__RID__', $Rid ` - -replace '__PACKAGE_NAME__', $PackageName +$ridReadmeTemplate = Read-TemplateFile (Join-Path $PSScriptRoot 'pack-cli-npm-package.rid.README.md') +$ridReadme = Expand-Template $ridReadmeTemplate @{ + RID_PACKAGE_NAME = $ridPackageName + RID = $Rid + PACKAGE_NAME = $PackageName +} Write-TextFile (Join-Path $ridPackageRoot 'README.md') $ridReadme @@ -200,8 +207,10 @@ foreach ($supportedRid in $supportedRids) { $pointerPackageJson = [ordered]@{ name = $PackageName version = $Version - description = 'Command line tool for Aspire developers.' + description = 'The Aspire CLI lets you build, run, manage, and deploy distributed applications in a terminal.' license = 'MIT' + keywords = New-StringList @('aspire', 'typescript', 'dotnet', 'apphost', 'polyglot', 'distributed-applications', 'code-first', 'orchestration', 'observability', 'opentelemetry', 'local-development') + homepage = 'https://aspire.dev' repository = [ordered]@{ type = 'git' url = 'git+https://github.com/microsoft/aspire.git' @@ -227,13 +236,12 @@ $pointerPackageJson = [ordered]@{ Write-JsonFile (Join-Path $pointerPackageRoot 'package.json') $pointerPackageJson Write-JsonFile (Join-Path $pointerPackageBin 'aspire-package-map.json') $ridPackageMap -Write-TextFile (Join-Path $pointerPackageRoot 'README.md') @" -# $PackageName - -Npm package for the Aspire CLI. +$pointerReadmeTemplate = Read-TemplateFile (Join-Path $PSScriptRoot 'pack-cli-npm-package.pointer.README.md') +$pointerReadme = Expand-Template $pointerReadmeTemplate @{ + PACKAGE_NAME = $PackageName +} -This package installs a small JavaScript launcher and resolves the matching native Aspire CLI package for the current platform. -"@ +Write-TextFile (Join-Path $pointerPackageRoot 'README.md') $pointerReadme Invoke-NpmPack $ridPackageRoot $OutputPath Invoke-NpmPack $pointerPackageRoot $OutputPath diff --git a/eng/scripts/pack-cli-npm-package.rid.README.md b/eng/scripts/pack-cli-npm-package.rid.README.md new file mode 100644 index 00000000000..c84212434a4 --- /dev/null +++ b/eng/scripts/pack-cli-npm-package.rid.README.md @@ -0,0 +1,5 @@ +# __RID_PACKAGE_NAME__ + +Native Aspire CLI binary for `__RID__`. + +This package is installed as an optional dependency of `__PACKAGE_NAME__`. diff --git a/eng/scripts/validate-npm-release-aliases.ps1 b/eng/scripts/validate-npm-release-aliases.ps1 new file mode 100644 index 00000000000..8f20c034fda --- /dev/null +++ b/eng/scripts/validate-npm-release-aliases.ps1 @@ -0,0 +1,148 @@ +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. + +<# +.SYNOPSIS +Validates the npm ESRP owner and approver aliases used by the release pipeline. + +.DESCRIPTION +The Aspire release pipeline (eng/pipelines/release-publish-nuget.yml) submits the +@microsoft/aspire-cli npm packages through MicroBuild's ESRP publish template. ESRP +requires a set of owner aliases and a single approver alias. This script normalizes +those values, enforces the release rules (owners must include at least one required +owner alias, approvers must be a single alias, and the two sets must not overlap), and +emits the normalized ("effective") sets so the pipeline can forward them to the publish +template. + +The release job runs with `checkout: none`, so the pipeline cannot dot-source this file +at runtime. The helper functions are mirrored inline in the pipeline YAML and the +ReleasePublishNugetPipelineTests.NpmAliasValidationHelpersMatchScript test keeps the two +copies in sync. This script exists so the same logic can be executed directly in unit +tests (ValidateNpmReleaseAliasesTests). + +Dot-source the script to import only the helper functions without running validation: + + . ./validate-npm-release-aliases.ps1 + +.PARAMETER Owners +Comma-separated owner aliases or @microsoft.com email addresses. Defaults to the +NPM_PUBLISH_OWNERS environment variable. + +.PARAMETER Approvers +A single approver alias or @microsoft.com email address. Defaults to the +NPM_PUBLISH_APPROVERS environment variable. + +.PARAMETER RequiredOwners +Comma-separated list of owner aliases, at least one of which must appear in Owners. +Defaults to the NPM_PUBLISH_REQUIRED_OWNERS environment variable. +#> +[CmdletBinding()] +param( + [string] $Owners = $env:NPM_PUBLISH_OWNERS, + [string] $Approvers = $env:NPM_PUBLISH_APPROVERS, + [string] $RequiredOwners = $env:NPM_PUBLISH_REQUIRED_OWNERS +) + +# >>> BEGIN npm release alias helpers (keep in sync with eng/pipelines/release-publish-nuget.yml) >>> +function Format-NpmReleaseAliasForError([string] $value) { + $escaped = $value.Replace("`r", '\r').Replace("`n", '\n') + return [regex]::Replace($escaped, '##vso\[', '## vso[', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) +} + +function ConvertTo-NpmReleaseAliasSet([string] $value, [string] $parameterName) { + $aliases = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + + # Parse values supplied as: + # joperezr, ankj@microsoft.com + # The normalized aliases are later emitted in Azure Pipelines logging commands, so + # accept only a small single-line alias alphabet before writing them back to the log. + foreach ($entry in $value -split ',') { + $alias = $entry.Trim() + if ([string]::IsNullOrWhiteSpace($alias)) { + continue + } + + $originalAlias = $alias + if ($alias.EndsWith('@microsoft.com', [StringComparison]::OrdinalIgnoreCase)) { + $alias = $alias.Substring(0, $alias.Length - '@microsoft.com'.Length) + } elseif ($alias.Contains('@')) { + Write-Error "$parameterName entry '$(Format-NpmReleaseAliasForError $originalAlias)' must be a Microsoft alias or @microsoft.com email address." + exit 1 + } + + if ([string]::IsNullOrWhiteSpace($alias) -or $alias -notmatch '\A[A-Za-z0-9][A-Za-z0-9._-]*\z') { + Write-Error "$parameterName entry '$(Format-NpmReleaseAliasForError $originalAlias)' must be a non-empty Microsoft alias or @microsoft.com email address containing only letters, digits, '.', '_' or '-'." + exit 1 + } + + [void]$aliases.Add($alias.ToLowerInvariant()) + } + + return ,$aliases +} + +function Assert-SingleNpmReleaseAlias( + [System.Collections.Generic.HashSet[string]] $actualAliases, + [string] $parameterName) { + if ($actualAliases.Count -ne 1) { + Write-Error "$parameterName must contain exactly one Microsoft alias or @microsoft.com email address." + exit 1 + } +} + +function Assert-ContainsAnyRequiredNpmOwnerAlias( + [System.Collections.Generic.HashSet[string]] $actualAliases, + [System.Collections.Generic.HashSet[string]] $requiredAliases, + [string] $parameterName) { + $matches = @($requiredAliases | Where-Object { $actualAliases.Contains($_) }) + if ($matches.Count -eq 0) { + $requiredAliasList = ($requiredAliases | Sort-Object) -join ', ' + Write-Error "$parameterName must include at least one required ESRP owner alias: $requiredAliasList." + exit 1 + } +} + +function Invoke-NpmReleaseAliasValidation( + [string] $owners, + [string] $approvers, + [string] $requiredNpmOwnersValue) { + $requiredNpmOwners = ConvertTo-NpmReleaseAliasSet $requiredNpmOwnersValue 'NPM_PUBLISH_REQUIRED_OWNERS' + $normalizedOwners = ConvertTo-NpmReleaseAliasSet $owners 'NpmPublishOwners' + $normalizedApprovers = ConvertTo-NpmReleaseAliasSet $approvers 'NpmPublishApprovers' + + if ($normalizedOwners.Count -eq 0) { + Write-Error "NpmPublishOwners must contain at least one alias before publishing npm packages." + exit 1 + } + + if ($normalizedApprovers.Count -eq 0) { + Write-Error "NpmPublishApprovers must contain at least one alias before publishing npm packages." + exit 1 + } + + Assert-ContainsAnyRequiredNpmOwnerAlias $normalizedOwners $requiredNpmOwners 'NpmPublishOwners' + Assert-SingleNpmReleaseAlias $normalizedApprovers 'NpmPublishApprovers' + + $overlappingAliases = @($normalizedOwners | Where-Object { $normalizedApprovers.Contains($_) }) + if ($overlappingAliases.Count -gt 0) { + Write-Error "NpmPublishOwners and NpmPublishApprovers must not contain the same alias(es): $($overlappingAliases -join ', ')." + exit 1 + } + + $effectiveOwners = ($normalizedOwners | Sort-Object) -join ',' + $effectiveApprovers = ($normalizedApprovers | Sort-Object) -join ',' + + Write-Host "##vso[task.setvariable variable=NpmPublishOwnersEffective]$effectiveOwners" + Write-Host "##vso[task.setvariable variable=NpmPublishApproversEffective]$effectiveApprovers" + Write-Host "npm ESRP owners and approvers were resolved and include the required release contacts." +} +# <<< END npm release alias helpers <<< + +# Importing the helpers (dot-sourcing) should not trigger validation. When the script is +# dot-sourced, $MyInvocation.InvocationName is '.'; when it is run via -File it is the +# script path. See https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_scripts#script-scope-and-dot-sourcing +if ($MyInvocation.InvocationName -eq '.') { + return +} + +Invoke-NpmReleaseAliasValidation $Owners $Approvers $RequiredOwners diff --git a/tests/Aspire.Cli.EndToEnd.Tests/ChannelUpdateWorkflowTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/ChannelUpdateWorkflowTests.cs index 91dc30edf4c..7efb52c4392 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/ChannelUpdateWorkflowTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/ChannelUpdateWorkflowTests.cs @@ -415,6 +415,7 @@ private static async Task PreviewStableUpdateAndDeclineAsync( var updatePrompt = new CellPatternSearcher().Find("Perform updates?"); var upToDateMessage = new CellPatternSearcher().Find("Project is up to date! (no updates necessary)"); var channelUpdateLine = new CellPatternSearcher().Find("aspire.config.json#channel"); + var cliUpdatePrompt = new CellPatternSearcher().Find("Update the Aspire CLI now and re-run"); var expectedPackageLine = expectedPackageInPlan is not null ? new CellPatternSearcher().Find(expectedPackageInPlan) : null; @@ -422,20 +423,41 @@ private static async Task PreviewStableUpdateAndDeclineAsync( var sawChannelUpdateLine = false; var sawExpectedPackageLine = expectedPackageLine is null; var sawUpdatePrompt = false; + var sawUpToDateMessage = false; + var sawCliUpdatePrompt = false; await auto.TypeAsync("aspire update --channel stable --nuget-config-dir ."); await auto.EnterAsync(); - await auto.WaitUntilAsync(snapshot => + + async Task WaitForStableUpdatePreviewAsync(bool allowCliUpdatePrompt) { - sawChannelUpdateLine |= channelUpdateLine.Search(snapshot).Count > 0; - if (expectedPackageLine is not null && expectedPackageLine.Search(snapshot).Count > 0) + await auto.WaitUntilAsync(snapshot => { - sawExpectedPackageLine = true; - } - sawUpdatePrompt |= updatePrompt.Search(snapshot).Count > 0; + sawChannelUpdateLine |= channelUpdateLine.Search(snapshot).Count > 0; + if (expectedPackageLine is not null && expectedPackageLine.Search(snapshot).Count > 0) + { + sawExpectedPackageLine = true; + } + sawUpdatePrompt |= updatePrompt.Search(snapshot).Count > 0; + sawUpToDateMessage |= upToDateMessage.Search(snapshot).Count > 0; + var foundCliUpdatePrompt = cliUpdatePrompt.Search(snapshot).Count > 0; + sawCliUpdatePrompt |= foundCliUpdatePrompt; + + return sawUpdatePrompt || sawUpToDateMessage || (allowCliUpdatePrompt && foundCliUpdatePrompt); + }, TimeSpan.FromMinutes(3), description: "waiting for stable update preview"); + } + + await WaitForStableUpdatePreviewAsync(allowCliUpdatePrompt: true); - return sawUpdatePrompt || upToDateMessage.Search(snapshot).Count > 0; - }, TimeSpan.FromMinutes(3), description: "waiting for stable update preview"); + if (sawCliUpdatePrompt && !sawUpdatePrompt && !sawUpToDateMessage) + { + // Stable release versions sort higher than same-base PR prerelease versions + // (for example, 13.4.3 > 13.4.3-pr.18093.g...). Decline the CLI self-update + // prompt so the project update preview can continue. The prompt remains in the + // terminal snapshot after the key is accepted, so the second wait must ignore it. + await auto.TypeAsync("n"); + await WaitForStableUpdatePreviewAsync(allowCliUpdatePrompt: false); + } Assert.False(sawChannelUpdateLine, "Stable channel updates should not enqueue an aspire.config.json#channel rewrite."); Assert.True(sawExpectedPackageLine, $"Expected the stable update preview to include '{expectedPackageInPlan}'."); diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs index 65060ccc578..b5291469121 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliInstallStrategyTests.cs @@ -720,6 +720,17 @@ public void GetDotnetToolInstallCommandInDocker_WithPrerelease() Assert.Equal("dotnet tool install --global Aspire.Cli --prerelease --configfile '/opt/aspire-scripts/NuGet.config'", command); } + [Fact] + public void GetDotnetAddPackageCommand_UsesLocalHivePackageVersionWhenPresent() + { + var command = AspireCliShellCommandHelpers.GetDotnetAddPackageCommand("K8sDeployTest.ApiService", "Aspire.StackExchange.Redis"); + + Assert.Contains("PKG_PATH=$(find \"$HOME/.aspire/hives\" -path \"*/packages/$PKG.[0-9]*.nupkg\"", command); + Assert.Contains("PKG_VERSION=${PKG_FILE#\"$PKG.\"}", command); + Assert.Contains("dotnet add \"$TARGET\" package \"$PKG\" --version \"$PKG_VERSION\"", command); + Assert.Contains("else dotnet add \"$TARGET\" package \"$PKG\" --prerelease", command); + } + [Fact] public void Detect_ReturnsLocalArchive_WhenArchiveDirIsSetInCIWithoutPrMetadata() { diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs index 92749d5ed41..a72758801d5 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/KubernetesDeployTestHelpers.cs @@ -245,10 +245,10 @@ await auto.WaitUntilAsync( await auto.WaitForAspireAddCompletionAsync(counter, TimeSpan.FromSeconds(180)); } - // Step 4: Add client NuGet packages to ApiService (--prerelease needed for PR builds) + // Step 4: Add client NuGet packages to ApiService (uses local hive version when available, otherwise falls back to --prerelease) foreach (var package in apiClientPackages) { - await auto.TypeAsync($"dotnet add {projectName}.ApiService package {package} --prerelease"); + await auto.TypeAsync(AspireCliShellCommandHelpers.GetDotnetAddPackageCommand($"{projectName}.ApiService", package)); await auto.EnterAsync(); await auto.WaitForSuccessPromptAsync(counter, TimeSpan.FromSeconds(180)); } diff --git a/tests/Infrastructure.Tests/Pipelines/NpmCliPackageTests.cs b/tests/Infrastructure.Tests/Pipelines/NpmCliPackageTests.cs index 017df7f4a21..7cc7f1b9677 100644 --- a/tests/Infrastructure.Tests/Pipelines/NpmCliPackageTests.cs +++ b/tests/Infrastructure.Tests/Pipelines/NpmCliPackageTests.cs @@ -1,14 +1,41 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Text.Json.Nodes; using System.Xml.Linq; +using Aspire.TestUtilities; using Xunit; namespace Infrastructure.Tests; -public sealed class NpmCliPackageTests +public sealed class NpmCliPackageTests : IDisposable { + private const string PackageName = "@microsoft/aspire-cli"; + private const string PackageVersion = "13.4.0-test.1"; + + private static readonly RidPackageExpectation[] s_supportedRids = + [ + new("win-x64", "aspire.exe", ["win32"], ["x64"], null), + new("win-arm64", "aspire.exe", ["win32"], ["arm64"], null), + new("linux-x64", "aspire", ["linux"], ["x64"], ["glibc"]), + new("linux-arm64", "aspire", ["linux"], ["arm64"], ["glibc"]), + new("linux-musl-x64", "aspire", ["linux"], ["x64"], ["musl"]), + new("osx-x64", "aspire", ["darwin"], ["x64"], null), + new("osx-arm64", "aspire", ["darwin"], ["arm64"], null) + ]; + + private readonly TestTempDirectory _tempDirectory = new(); + private readonly ITestOutputHelper _output; private readonly string _repoRoot = RepoRoot.Path; + private readonly string _packScriptPath; + + public NpmCliPackageTests(ITestOutputHelper output) + { + _output = output; + _packScriptPath = Path.Combine(_repoRoot, "eng", "scripts", "pack-cli-npm-package.ps1"); + } + + public void Dispose() => _tempDirectory.Dispose(); [Fact] public async Task LauncherDetectsMuslArm64AndThrowsUnsupported() @@ -129,48 +156,78 @@ public async Task LauncherLoadsRidPackageMapInsideErrorHandler() } [Fact] - public async Task PackScriptUsesLiteralHereStringForRidReadme() + [RequiresTools(["pwsh", "npm"])] + public async Task PackScriptGeneratesPointerPackageMetadataMapAndReadme() { - var packScript = await ReadRepoFileAsync("eng/scripts/pack-cli-npm-package.ps1"); - - // Previously the RID-package README used an expandable here-string with - // `$Rid` and `$PackageName`. In PowerShell expandable here-strings the - // backtick is the escape character, so the markdown code-span backticks - // were both stripped AND the $-interpolation was suppressed. Result: - // shipped READMEs read `Native Aspire CLI binary for $Rid.` with no - // backticks. Verify the script now uses a literal here-string (@'...'@) - // with explicit -replace placeholders so backticks survive verbatim. - Assert.Contains("$ridReadmeTemplate = @'", packScript); - Assert.Contains("Native Aspire CLI binary for `__RID__`.", packScript); - Assert.Contains("This package is installed as an optional dependency of `__PACKAGE_NAME__`.", packScript); - Assert.Contains("-replace '__RID__', $Rid", packScript); - Assert.Contains("-replace '__PACKAGE_NAME__', $PackageName", packScript); - - // The original broken sequence (expandable here-string with `$Rid`) must - // not be reintroduced. - Assert.DoesNotContain("Native Aspire CLI binary for `$Rid`.", packScript); + var package = await PackCliNpmPackageAsync("linux-musl-x64"); + + var packageJson = ReadJsonObject(Path.Combine(package.PointerPackageRoot, "package.json")); + + Assert.Equal(PackageName, GetString(packageJson, "name")); + Assert.Equal(PackageVersion, GetString(packageJson, "version")); + Assert.Equal("The Aspire CLI lets you build, run, manage, and deploy distributed applications in a terminal.", GetString(packageJson, "description")); + Assert.Equal("https://aspire.dev", GetString(packageJson, "homepage")); + Assert.Equal( + ["aspire", "typescript", "dotnet", "apphost", "polyglot", "distributed-applications", "code-first", "orchestration", "observability", "opentelemetry", "local-development"], + GetStringArray(packageJson["keywords"])); + Assert.Equal(">=20", GetString(GetObject(packageJson, "engines"), "node")); + Assert.Equal( + s_supportedRids.ToDictionary(rid => $"{PackageName}-{rid.Rid}", _ => PackageVersion, StringComparer.Ordinal), + GetStringMap(GetObject(packageJson, "optionalDependencies"))); + + var packageMap = ReadJsonObject(Path.Combine(package.PointerPackageRoot, "bin", "aspire-package-map.json")); + Assert.Equal( + s_supportedRids.ToDictionary(rid => rid.Rid, rid => $"{PackageName}-{rid.Rid}", StringComparer.Ordinal), + GetStringMap(packageMap)); + + var readme = await File.ReadAllTextAsync(Path.Combine(package.PointerPackageRoot, "README.md")); + Assert.Equal(await RenderTemplateAsync("eng/scripts/pack-cli-npm-package.pointer.README.md", ("PACKAGE_NAME", PackageName)), readme); + Assert.Contains("This package requires Node.js 20 or later.", readme); + Assert.Contains($"npm install -g {PackageName}", readme); + Assert.Contains("The native platform packages are installed through npm optional dependencies.", readme); + Assert.Contains("If you run `aspire update --self` from an npm install, the CLI points you back to this npm update command.", readme); + Assert.DoesNotContain("__PACKAGE_NAME__", readme); } - [Fact] - public async Task PointerPackageRequiresNode20OrLater() + [Theory] + [MemberData(nameof(GetSupportedRidData))] + [RequiresTools(["pwsh", "npm"])] + public async Task PackScriptGeneratesRidPackageMetadataAndReadme(RidPackageExpectation expectation) { - var packScript = await ReadRepoFileAsync("eng/scripts/pack-cli-npm-package.ps1"); - - // The launcher (`bin/aspire.js`) uses the Error options-bag - // `new Error(msg, { cause: err })` which was added in Node 16.9.0. - // Node 16.0–16.8.x would throw `TypeError: Unknown option 'cause'` - // at module load before the friendly "Aspire CLI installation is - // corrupted" message could be printed. - // The per-RID `libc` selector in optionalDependencies relies on - // npm >= 10.7 which ships with Node 20.10+. Node 18 reaches end - // of life on 2025-04-30, so Node 20 is the lowest LTS we should - // pin at GA. See https://nodejs.org/en/about/previous-releases. - // Guard against accidental regression to `>=16` (or any earlier - // version) which would let the launcher crash on supported Node - // engines. - Assert.Contains("node = '>=20'", packScript); - Assert.DoesNotContain("node = '>=16'", packScript); - Assert.DoesNotContain("node = '>=18'", packScript); + var package = await PackCliNpmPackageAsync(expectation.Rid); + + var packageJson = ReadJsonObject(Path.Combine(package.RidPackageRoot, "package.json")); + + Assert.Equal($"{PackageName}-{expectation.Rid}", GetString(packageJson, "name")); + Assert.Equal(PackageVersion, GetString(packageJson, "version")); + Assert.Equal($"Native Aspire CLI binary for {expectation.Rid}.", GetString(packageJson, "description")); + Assert.Equal(expectation.Os, GetStringArray(packageJson["os"])); + Assert.Equal(expectation.Cpu, GetStringArray(packageJson["cpu"])); + Assert.Equal(["bin", "README.md"], GetStringArray(packageJson["files"])); + + if (expectation.Libc is null) + { + Assert.False(packageJson.ContainsKey("libc")); + } + else + { + Assert.Equal(expectation.Libc, GetStringArray(packageJson["libc"])); + } + + Assert.True(File.Exists(Path.Combine(package.RidPackageRoot, "bin", expectation.BinaryName))); + + var readme = await File.ReadAllTextAsync(Path.Combine(package.RidPackageRoot, "README.md")); + Assert.Equal( + await RenderTemplateAsync( + "eng/scripts/pack-cli-npm-package.rid.README.md", + ("RID_PACKAGE_NAME", $"{PackageName}-{expectation.Rid}"), + ("RID", expectation.Rid), + ("PACKAGE_NAME", PackageName)), + readme); + Assert.Contains($"Native Aspire CLI binary for `{expectation.Rid}`.", readme); + Assert.Contains($"This package is installed as an optional dependency of `{PackageName}`.", readme); + Assert.DoesNotContain("__RID__", readme); + Assert.DoesNotContain("__PACKAGE_NAME__", readme); } [Fact] @@ -196,6 +253,17 @@ public async Task NpmInstallValidationJobsUseExplicitJobsSharedStepsTemplateAndC Assert.DoesNotContain("artifact: npm-validation-summary-win-x64", releasePipeline); Assert.DoesNotContain("artifact: npm-validation-summary-linux-x64", releasePipeline); Assert.DoesNotContain("artifact: npm-validation-summary-osx", releasePipeline); + Assert.DoesNotContain("artifact: $(NPM_VALIDATION_SUMMARY_WIN_X64_ARTIFACT)", releasePipeline); + Assert.DoesNotContain("artifact: $(NPM_VALIDATION_SUMMARY_LINUX_X64_ARTIFACT)", releasePipeline); + Assert.DoesNotContain("artifact: $(NPM_VALIDATION_SUMMARY_OSX_ARTIFACT)", releasePipeline); + Assert.Equal(3, CountOccurrences(releasePipeline, "task: DownloadBuildArtifacts@0")); + Assert.Equal(3, CountOccurrences(releasePipeline, "pipeline: $(SourceBuildPipeline)")); + Assert.Equal(3, CountOccurrences(releasePipeline, "buildId: $(SourceBuildId)")); + Assert.Equal(3, CountOccurrences(releasePipeline, "downloadPath: '$(Pipeline.Workspace)/aspire-build'")); + Assert.Contains("SourceBuildPipeline: microsoft-aspire", releasePipeline); + Assert.Contains("artifactName: $(NPM_VALIDATION_SUMMARY_WIN_X64_ARTIFACT)", releasePipeline); + Assert.Contains("artifactName: $(NPM_VALIDATION_SUMMARY_LINUX_X64_ARTIFACT)", releasePipeline); + Assert.Contains("artifactName: $(NPM_VALIDATION_SUMMARY_OSX_ARTIFACT)", releasePipeline); Assert.Contains("$(NPM_VALIDATION_SUMMARY_WIN_X64_ARTIFACT)", releasePipeline); Assert.Contains("$(NPM_VALIDATION_SUMMARY_LINUX_X64_ARTIFACT)", releasePipeline); Assert.Contains("$(NPM_VALIDATION_SUMMARY_OSX_ARTIFACT)", releasePipeline); @@ -242,10 +310,10 @@ public async Task ReleasePipelineGuardsNpmLatestDistTagAgainstServicingDowngrade { var releasePipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); - Assert.Contains("AllowNpmLatestDistTagMove", releasePipeline); + Assert.DoesNotContain("AllowNpmLatestDistTagMove", releasePipeline); Assert.Contains("npm view @microsoft/aspire-cli@latest version", releasePipeline); Assert.Contains("would move the npm latest dist-tag backward", releasePipeline); - Assert.Contains("SkipNpmPublish=true for older servicing releases", releasePipeline); + Assert.Contains("Set SkipNpmRidPublish=true and SkipNpmPointerPublish=true for older servicing releases", releasePipeline); } [Fact] @@ -254,8 +322,13 @@ public async Task ReleasePipelineUsesEffectiveNpmOwnersAndApproversFromSingleSou var commonVariables = await ReadRepoFileAsync("eng/pipelines/common-variables.yml"); var releasePipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); - Assert.Contains("NPM_PUBLISH_REQUIRED_OWNERS", commonVariables); - Assert.Contains("NPM_PUBLISH_REQUIRED_APPROVERS", commonVariables); + Assert.DoesNotContain("NPM_PUBLISH_REQUIRED_OWNERS", commonVariables); + Assert.Contains("NPM_PUBLISH_REQUIRED_OWNERS", releasePipeline); + Assert.DoesNotContain("NPM_PUBLISH_REQUIRED_APPROVERS", commonVariables); + Assert.DoesNotContain("NPM_PUBLISH_REQUIRED_APPROVERS", releasePipeline); + Assert.DoesNotContain("requiredNpmApprovers", releasePipeline); + Assert.Contains("default: 'joperezr,ankj'", releasePipeline); + Assert.Contains("default: 'adamratzman'", releasePipeline); Assert.Contains("NpmPublishOwnersEffective", releasePipeline); Assert.Contains("NpmPublishApproversEffective", releasePipeline); Assert.Contains("owners: '$(NpmPublishOwnersEffective)'", releasePipeline); @@ -268,6 +341,92 @@ public async Task ReleasePipelineUsesEffectiveNpmOwnersAndApproversFromSingleSou private Task ReadRepoFileAsync(string relativePath) => File.ReadAllTextAsync(Path.Combine(_repoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar))); + public static TheoryData GetSupportedRidData() + { + var data = new TheoryData(); + foreach (var rid in s_supportedRids) + { + data.Add(rid); + } + + return data; + } + + private async Task PackCliNpmPackageAsync(string rid) + { + var testRoot = Path.Combine(_tempDirectory.Path, Path.GetRandomFileName()); + var stagingRoot = Path.Combine(testRoot, "staging"); + var outputPath = Path.Combine(testRoot, "output"); + var nativeBinaryPath = Path.Combine(testRoot, "native-aspire-stub"); + + Directory.CreateDirectory(testRoot); + await File.WriteAllTextAsync(nativeBinaryPath, "native binary stub"); + + using var cmd = new PowerShellCommand(_packScriptPath, _output) + .WithTimeout(TimeSpan.FromMinutes(2)); + + var result = await cmd.ExecuteAsync( + "-Rid", rid, + "-Version", PackageVersion, + "-NativeBinaryPath", $"\"{nativeBinaryPath}\"", + "-OutputPath", $"\"{outputPath}\"", + "-StagingRoot", $"\"{stagingRoot}\"", + "-PackageName", PackageName); + + result.EnsureSuccessful(); + + Assert.Equal(2, Directory.GetFiles(outputPath, "*.tgz").Length); + + return new PackedNpmPackage( + Path.Combine(stagingRoot, "rid"), + Path.Combine(stagingRoot, "pointer")); + } + + private async Task RenderTemplateAsync(string templateRelativePath, params (string Name, string Value)[] values) + { + var template = await ReadRepoFileAsync(templateRelativePath); + + foreach (var (name, value) in values) + { + template = template.Replace($"__{name}__", value, System.StringComparison.Ordinal); + } + + return template; + } + + private static JsonObject ReadJsonObject(string path) + { + var json = File.ReadAllText(path); + return JsonNode.Parse(json)?.AsObject() + ?? throw new InvalidOperationException($"Failed to parse JSON object from {path}"); + } + + private static JsonObject GetObject(JsonObject jsonObject, string propertyName) + { + return jsonObject[propertyName]?.AsObject() + ?? throw new InvalidOperationException($"Missing JSON object property '{propertyName}'."); + } + + private static string GetString(JsonObject jsonObject, string propertyName) + { + return jsonObject[propertyName]?.GetValue() + ?? throw new InvalidOperationException($"Missing JSON string property '{propertyName}'."); + } + + private static string[] GetStringArray(JsonNode? jsonNode) + { + return jsonNode?.AsArray().Select(value => value?.GetValue() ?? throw new InvalidOperationException("JSON array contains a null value.")).ToArray() + ?? throw new InvalidOperationException("Missing JSON string array."); + } + + private static Dictionary GetStringMap(JsonObject jsonObject) + { + return jsonObject.ToDictionary( + property => property.Key, + property => property.Value?.GetValue() ?? throw new InvalidOperationException($"JSON property '{property.Key}' is not a string."), + StringComparer.Ordinal); + } + private static int CountOccurrences(string value, string substring) { var count = 0; @@ -296,4 +455,8 @@ private static void AssertScopedSigningRule(XDocument document, string elementNa matchingRules.Length == 1, $"Expected exactly one {elementName} for '{include}' using '{certificateName}' in the AspireCliNpmPackage signing scope, but found {matchingRules.Length}."); } + + public sealed record RidPackageExpectation(string Rid, string BinaryName, string[] Os, string[] Cpu, string[]? Libc); + + private sealed record PackedNpmPackage(string RidPackageRoot, string PointerPackageRoot); } diff --git a/tests/Infrastructure.Tests/Pipelines/ReleasePublishNugetPipelineTests.cs b/tests/Infrastructure.Tests/Pipelines/ReleasePublishNugetPipelineTests.cs index 82af554720a..a913054bde3 100644 --- a/tests/Infrastructure.Tests/Pipelines/ReleasePublishNugetPipelineTests.cs +++ b/tests/Infrastructure.Tests/Pipelines/ReleasePublishNugetPipelineTests.cs @@ -22,12 +22,12 @@ public async Task ValidatesNpmPublishPreconditionsBeforeNuGetPublish() AssertBefore( pipeline, - "$parameterName must include required ESRP alias(es)", + "$parameterName must include at least one required ESRP owner alias", nuGetPublishIndex); AssertBefore( pipeline, - "Assert-ContainsRequiredNpmAliases $normalizedApprovers $requiredNpmApprovers 'NpmPublishApprovers'", + "Assert-SingleNpmReleaseAlias $normalizedApprovers 'NpmPublishApprovers'", nuGetPublishIndex); } @@ -88,34 +88,191 @@ public async Task RoutesMicroBuildPublishAuthPluginToDncengFeedOrDisablesIt() pipeline); } + [Fact] + public async Task AlreadyPublishedNpmPreflightExitsZeroAfterHandledRegistryMisses() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + + var successIndex = FindRequiredText(pipeline, "No scheduled npm package versions already exist on npm."); + var displayNameIndex = FindRequiredText(pipeline, "displayName: 'Verify npm Packages Are Not Already Published'"); + var successTail = pipeline[successIndex..displayNameIndex]; + + // Azure Pipelines' PowerShell task exits with $LASTEXITCODE after the inline script. + // `npm view` returns 1 for E404, which this script handles as success, so the success + // path must override that stale native exit code. + Assert.Contains("exit 0", successTail); + } + + [Fact] + public async Task NpmPublishUsesOnlyRidAndPointerSkipParameters() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + var spec = await ReadRepoFileAsync("docs/specs/npm-cli-package.md"); + + Assert.DoesNotContain("SkipNpmPublish", pipeline); + Assert.DoesNotContain("Skip npm Publish", pipeline); + Assert.DoesNotContain("SkipNpmPublish", spec); + Assert.Contains("displayName: '[Advanced] Skip npm RID Package Publishing", pipeline); + Assert.Contains("displayName: '[Advanced] Skip npm Pointer Package Publishing", pipeline); + Assert.Contains("or(eq(parameters.SkipNpmRidPublish, false), eq(parameters.SkipNpmPointerPublish, false))", pipeline); + Assert.Contains("and(eq(parameters.SkipNpmRidPublish, true), eq(parameters.SkipNpmPointerPublish, true))", pipeline); + } + + [Fact] + public async Task NpmLatestDistTagDowngradeGuardHasNoOverrideParameter() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + var spec = await ReadRepoFileAsync("docs/specs/npm-cli-package.md"); + + Assert.DoesNotContain("AllowNpmLatestDistTagMove", pipeline); + Assert.DoesNotContain("AllowNpmLatestDistTagMove", spec); + Assert.DoesNotContain("skipping npm latest dist-tag downgrade guard", pipeline); + Assert.Contains("Publishing $($pointerPackage.Spec) would move the npm latest dist-tag backward", pipeline); + } + [Fact] public async Task UsesRequiredNpmEsrpOwnersAndApprover() { var commonVariables = await ReadRepoFileAsync("eng/pipelines/common-variables.yml"); var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); - Assert.Contains("- name: NPM_PUBLISH_REQUIRED_OWNERS", commonVariables); - Assert.Contains("value: joperezr,ankj", commonVariables); - Assert.Contains("- name: NPM_PUBLISH_REQUIRED_APPROVERS", commonVariables); - Assert.Contains("value: adamratzman", commonVariables); - Assert.Contains("displayName: 'npm ESRP owners (comma-separated Microsoft aliases or emails; leave unchanged for repo default)'", pipeline); - Assert.Contains("displayName: 'npm ESRP approvers (comma-separated Microsoft aliases or emails; leave unchanged for repo default)'", pipeline); - Assert.DoesNotContain("leave blank for repo default", pipeline); + Assert.DoesNotContain("NPM_PUBLISH_REQUIRED_OWNERS", commonVariables); + Assert.DoesNotContain("NPM_PUBLISH_DEFAULT_APPROVER", commonVariables); + Assert.DoesNotContain("NPM_PUBLISH_REQUIRED_APPROVERS", commonVariables); + Assert.Contains("- name: NPM_PUBLISH_REQUIRED_OWNERS", pipeline); + Assert.Equal("joperezr,ankj", FindYamlVariableValue(pipeline, "NPM_PUBLISH_REQUIRED_OWNERS")); + Assert.Contains("displayName: '[Advanced] npm ESRP owners (comma-separated Microsoft aliases or emails; must include joperezr or ankj)'", pipeline); + Assert.Contains("displayName: '[Advanced] npm ESRP approver (single Microsoft alias or email; must differ from the owners)'", pipeline); + AssertContainsRequiredAliases( - FindYamlVariableValue(commonVariables, "NPM_PUBLISH_REQUIRED_OWNERS"), + FindYamlVariableValue(pipeline, "NPM_PUBLISH_REQUIRED_OWNERS"), FindYamlParameterDefault(pipeline, "NpmPublishOwners"), "NpmPublishOwners"); - AssertContainsRequiredAliases( - FindYamlVariableValue(commonVariables, "NPM_PUBLISH_REQUIRED_APPROVERS"), - FindYamlParameterDefault(pipeline, "NpmPublishApprovers"), - "NpmPublishApprovers"); - Assert.Contains("$requiredNpmOwnersValue = \"$(NPM_PUBLISH_REQUIRED_OWNERS)\"", pipeline); - Assert.Contains("$requiredNpmApproversValue = \"$(NPM_PUBLISH_REQUIRED_APPROVERS)\"", pipeline); + Assert.Equal("adamratzman", FindYamlParameterDefault(pipeline, "NpmPublishApprovers")); + + Assert.Contains("$requiredNpmOwnersValue = $env:NPM_PUBLISH_REQUIRED_OWNERS", pipeline); + Assert.DoesNotContain("NPM_PUBLISH_DEFAULT_APPROVER", pipeline); + Assert.DoesNotContain("NPM_PUBLISH_REQUIRED_APPROVERS", pipeline); + Assert.DoesNotContain("requiredNpmApprovers", pipeline); Assert.Contains("owners: '$(NpmPublishOwnersEffective)'", pipeline); Assert.Contains("approvers: '$(NpmPublishApproversEffective)'", pipeline); Assert.Contains("NpmPublishOwners and NpmPublishApprovers must not contain the same alias(es)", pipeline); } + [Fact] + public async Task NpmEsrpOwnersRequireAnyConfiguredOwnerAlias() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + + Assert.Contains("Assert-ContainsAnyRequiredNpmOwnerAlias $normalizedOwners $requiredNpmOwners 'NpmPublishOwners'", pipeline); + Assert.DoesNotContain("Assert-ContainsRequiredNpmAliases $normalizedOwners $requiredNpmOwners 'NpmPublishOwners'", pipeline); + Assert.Contains("Assert-SingleNpmReleaseAlias $normalizedApprovers 'NpmPublishApprovers'", pipeline); + Assert.DoesNotContain("Assert-ContainsRequiredNpmAliases $normalizedApprovers", pipeline); + Assert.DoesNotContain("NpmPublishOwners not provided; using NPM_PUBLISH_REQUIRED_OWNERS.", pipeline); + Assert.DoesNotContain("NpmPublishApprovers not provided; using NPM_PUBLISH_DEFAULT_APPROVER.", pipeline); + } + + [Fact] + public async Task ForwardsNpmOwnerAndApproverParametersAsEnvironmentVariables() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + + // The queue-time owner/approver values must reach the validation script as environment + // variables (data) rather than being interpolated into the inline PowerShell source, where + // a hostile value could break out of the quoted literal. Keep the template expression inside + // a string scalar; using the raw expression makes Azure Pipelines preserve expression-object + // typing and fail release-job expansion with "Unable to convert from Object to String." + Assert.Contains("NPM_PUBLISH_OWNERS: '${{ parameters.NpmPublishOwners }}'", pipeline); + Assert.Contains("NPM_PUBLISH_APPROVERS: '${{ parameters.NpmPublishApprovers }}'", pipeline); + Assert.Contains("$owners = $env:NPM_PUBLISH_OWNERS", pipeline); + Assert.Contains("$approvers = $env:NPM_PUBLISH_APPROVERS", pipeline); + Assert.DoesNotContain("NPM_PUBLISH_OWNERS: ${{ parameters.NpmPublishOwners }}", pipeline); + Assert.DoesNotContain("NPM_PUBLISH_APPROVERS: ${{ parameters.NpmPublishApprovers }}", pipeline); + Assert.DoesNotContain("$owners = \"${{ parameters.NpmPublishOwners }}\"", pipeline); + Assert.DoesNotContain("$approvers = \"${{ parameters.NpmPublishApprovers }}\"", pipeline); + } + + [Fact] + public async Task ComputesInstallerOnlyModeInsidePowerShell() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + + // Azure Pipelines reports the start of the `powershell: |` scalar when an embedded + // template expression evaluates to a non-string object. Keep the composed boolean + // calculation in PowerShell and substitute only the primitive parameter values. + Assert.DoesNotContain("Installer-only mode: ${{ and(", pipeline); + Assert.Contains("$installerOnlyMode = (", pipeline); + Assert.Contains("Write-Host \"Installer-only mode: $installerOnlyMode\"", pipeline); + } + + [Fact] + public async Task DoesNotUseWildcardTemplateParameterExpressionLiteral() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + + // Azure Pipelines expands template expressions inside block scalars even when the text is + // inside a PowerShell comment. The literal wildcard expression evaluates to the parameters + // object, which fails release-job parsing with "Unable to convert from Object to String." + Assert.DoesNotContain("${{ parameters.* }}", pipeline); + } + + [Fact] + public async Task NpmPublishOwnerAndApproverParametersHaveWorkingDefaults() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + + // Defaults let an unattended queue submission pass validation without operator input: + // owners include a required owner alias, the approver is a single distinct alias, and the + // per-run override parameters are marked advanced. + Assert.Contains("- name: NpmPublishOwners", pipeline); + Assert.Contains("default: 'joperezr,ankj'", pipeline); + Assert.Contains("- name: NpmPublishApprovers", pipeline); + Assert.Contains("default: 'adamratzman'", pipeline); + Assert.Contains("[Advanced] npm ESRP owners", pipeline); + Assert.Contains("[Advanced] npm ESRP approver", pipeline); + Assert.Contains("[Advanced] Minutes to wait between npm RID and pointer package submissions", pipeline); + } + + [Fact] + public async Task NpmAliasValidationHelpersMatchScript() + { + var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml"); + var script = await ReadRepoFileAsync("eng/scripts/validate-npm-release-aliases.ps1"); + + // releaseJob runs with `checkout: none`, so the pipeline cannot dot-source the script and + // instead inlines the same helper functions. Keep the two copies identical (ignoring + // indentation) so the behavior verified by ValidateNpmReleaseAliasesTests against the + // script also holds for the inlined release-pipeline copy. + var pipelineHelpers = ExtractHelperRegion(pipeline); + var scriptHelpers = ExtractHelperRegion(script); + + Assert.NotEmpty(pipelineHelpers); + Assert.Equal(scriptHelpers, pipelineHelpers); + } + + private static IReadOnlyList ExtractHelperRegion(string contents) + { + const string begin = ">>> BEGIN npm release alias helpers"; + const string end = "<<< END npm release alias helpers"; + + var beginIndex = contents.IndexOf(begin, StringComparison.Ordinal); + var endIndex = contents.IndexOf(end, StringComparison.Ordinal); + + Assert.True(beginIndex >= 0, $"Expected to find '{begin}'."); + Assert.True(endIndex > beginIndex, $"Expected to find '{end}' after '{begin}'."); + + // Take the lines between the begin- and end-marker lines, trim the (differing) indentation, + // and drop blank lines so only the helper-function content is compared. + var regionStart = contents.IndexOf('\n', beginIndex) + 1; + var regionEnd = contents.LastIndexOf('\n', endIndex); + + return contents[regionStart..regionEnd] + .Split('\n') + .Select(line => line.Trim()) + .Where(line => line.Length > 0) + .ToArray(); + } + [Fact] public async Task ValidatesPublishedNpmPackageFromRegistryAfterPublish() { @@ -364,10 +521,10 @@ public async Task AspireVersionCaptureStripsCarriageReturnForWindowsRunner() // This regressed in commit debf4ebf38 ("Harden npm prepare/publish // validation against partial-failure leakage"), which replaced the // earlier `tr -d '[:space:]'` form with a `grep -Eo`+`$` form. The dry - // run on 2987740 did NOT exercise this path because SkipNpmPublish=true - // skips the release-pipeline consumer that reads the win-x64 validation - // summary; the Monday real publish would have hit the bug at the - // first source-build Windows install validation. + // run on 2987740 did NOT exercise this path because npm publishing was + // skipped, bypassing the release-pipeline consumer that reads the + // win-x64 validation summary; the Monday real publish would have hit + // the bug at the first source-build Windows install validation. Assert.Contains("aspire --version 2>&1 | tr -d '\\r'", template); } diff --git a/tests/Infrastructure.Tests/PowerShellScripts/ValidateNpmReleaseAliasesTests.cs b/tests/Infrastructure.Tests/PowerShellScripts/ValidateNpmReleaseAliasesTests.cs new file mode 100644 index 00000000000..a7f6d5d6ead --- /dev/null +++ b/tests/Infrastructure.Tests/PowerShellScripts/ValidateNpmReleaseAliasesTests.cs @@ -0,0 +1,198 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.RegularExpressions; +using Aspire.TestUtilities; +using Xunit; + +namespace Infrastructure.Tests; + +/// +/// Executes eng/scripts/validate-npm-release-aliases.ps1 (the canonical copy of the npm ESRP +/// owner/approver validation that the release pipeline mirrors inline) against sample inputs. +/// The script reads its inputs from environment variables, exactly like the release pipeline's +/// "Validate Parameters" step. +/// +public sealed class ValidateNpmReleaseAliasesTests +{ + private const string RequiredOwners = "joperezr,ankj"; + + private readonly ITestOutputHelper _output; + private readonly string _scriptPath; + + public ValidateNpmReleaseAliasesTests(ITestOutputHelper output) + { + _output = output; + _scriptPath = Path.Combine(FindRepoRoot(), "eng", "scripts", "validate-npm-release-aliases.ps1"); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task FailsWhenOwnersHasNoAliases() + { + var result = await RunValidation(owners: "", approvers: "ankj"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains( + "NpmPublishOwners must contain at least one alias before publishing npm packages.", + Flatten(result.Output)); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task FailsWhenOwnersHasOnlyWhitespaceEntries() + { + var result = await RunValidation(owners: " , ", approvers: "ankj"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains( + "NpmPublishOwners must contain at least one alias before publishing npm packages.", + Flatten(result.Output)); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task FailsWhenApproversHasMultipleAliases() + { + var result = await RunValidation(owners: "joperezr", approvers: "ankj,octocat"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains( + "NpmPublishApprovers must contain exactly one Microsoft alias or @microsoft.com email address.", + Flatten(result.Output)); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task FailsWhenOwnersMissingEveryRequiredAlias() + { + var result = await RunValidation(owners: "octocat", approvers: "ankj"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains( + "NpmPublishOwners must include at least one required ESRP owner alias: ankj, joperezr.", + Flatten(result.Output)); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task FailsWhenOwnerAndApproverOverlap() + { + var result = await RunValidation(owners: "joperezr", approvers: "joperezr"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains( + "NpmPublishOwners and NpmPublishApprovers must not contain the same alias(es): joperezr.", + Flatten(result.Output)); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task FailsWhenAliasIsNotAMicrosoftEmail() + { + var result = await RunValidation(owners: "joperezr@example.com", approvers: "ankj"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains( + "NpmPublishOwners entry 'joperezr@example.com' must be a Microsoft alias or @microsoft.com email address.", + Flatten(result.Output)); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task FailsWhenMicrosoftEmailHasEmptyAlias() + { + var result = await RunValidation(owners: "@microsoft.com", approvers: "ankj"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains( + "NpmPublishOwners entry '@microsoft.com' must be a non-empty Microsoft alias or @microsoft.com email address containing only letters, digits, '.', '_' or '-'.", + Flatten(result.Output)); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task FailsWhenAliasContainsNewlineLoggingCommand() + { + var maliciousApprover = "adamratzman\n##vso[task.setvariable variable=NpmPublishOwnersEffective]attacker"; + + var result = await RunValidation(owners: "joperezr", approvers: maliciousApprover); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains( + @"NpmPublishApprovers entry 'adamratzman\n## vso[task.setvariable variable=NpmPublishOwnersEffective]attacker' must be a non-empty Microsoft alias or @microsoft.com email address containing only letters, digits, '.', '_' or '-'.", + Flatten(result.Output)); + Assert.DoesNotContain("##vso[", result.Output); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task FailsWhenMicrosoftEmailSuffixWouldLeaveTrailingNewline() + { + var result = await RunValidation(owners: "joperezr,other\n@microsoft.com", approvers: "ankj"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains( + @"NpmPublishOwners entry 'other\n@microsoft.com' must be a non-empty Microsoft alias or @microsoft.com email address containing only letters, digits, '.', '_' or '-'.", + Flatten(result.Output)); + Assert.DoesNotContain("variable=NpmPublishOwnersEffective", result.Output); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task StripsMicrosoftEmailSuffixFromOwnerAliases() + { + var result = await RunValidation(owners: "JOPEREZR@microsoft.com", approvers: "ankj"); + + result.EnsureSuccessful(); + Assert.Contains("variable=NpmPublishOwnersEffective]joperezr", result.Output); + Assert.Contains("variable=NpmPublishApproversEffective]ankj", result.Output); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task EmitsSortedDeduplicatedEffectiveAliasesOnSuccess() + { + var result = await RunValidation(owners: "ankj,joperezr,ANKJ", approvers: "adamratzman"); + + result.EnsureSuccessful(); + Assert.Contains("variable=NpmPublishOwnersEffective]ankj,joperezr", result.Output); + Assert.Contains("variable=NpmPublishApproversEffective]adamratzman", result.Output); + } + + private async Task RunValidation(string owners, string approvers, string requiredOwners = RequiredOwners) + { + using var cmd = new PowerShellCommand(_scriptPath, _output) + .WithTimeout(TimeSpan.FromMinutes(2)) + .WithEnvironmentVariable("NPM_PUBLISH_OWNERS", owners) + .WithEnvironmentVariable("NPM_PUBLISH_APPROVERS", approvers) + .WithEnvironmentVariable("NPM_PUBLISH_REQUIRED_OWNERS", requiredOwners); + + return await cmd.ExecuteAsync(); + } + + // PowerShell's default ConciseView wraps Write-Error messages across multiple lines with + // "|" gutters whose layout depends on the console width, and colorizes them with ANSI escape + // sequences. Strip the escape sequences and gutters and collapse whitespace so assertions can + // match the full message regardless of where it wrapped. + private static string Flatten(string output) + { + // Matches ANSI SGR sequences such as ESC[31;1m that PowerShell emits when colorizing errors. + var withoutAnsi = Regex.Replace(output, @"\u001b\[[0-9;]*m", string.Empty); + return Regex.Replace(withoutAnsi.Replace("|", " "), @"\s+", " "); + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + if (File.Exists(Path.Combine(dir.FullName, "Aspire.slnx"))) + { + return dir.FullName; + } + dir = dir.Parent; + } + throw new InvalidOperationException("Could not find repository root"); + } +} diff --git a/tests/Shared/CliInstallStrategy.cs b/tests/Shared/CliInstallStrategy.cs index 17a30ce1942..87f27c0d571 100644 --- a/tests/Shared/CliInstallStrategy.cs +++ b/tests/Shared/CliInstallStrategy.cs @@ -161,6 +161,20 @@ internal static string GetDotnetToolInstallCommandInDocker(CliInstallStrategy st return $"dotnet tool install {GetDotnetToolInstallArgs(strategy, nupkgSourcePath, nuGetConfigPath)}"; } + internal static string GetDotnetAddPackageCommand(string projectPath, string packageId) + { + return + $"PKG={QuoteBashArg(packageId)}; " + + $"TARGET={QuoteBashArg(projectPath)}; " + + "PKG_PATH=$(find \"$HOME/.aspire/hives\" -path \"*/packages/$PKG.[0-9]*.nupkg\" -type f 2>/dev/null | sort -V | tail -n 1); " + + "if [ -n \"$PKG_PATH\" ]; then " + + "PKG_FILE=$(basename \"$PKG_PATH\"); " + + "PKG_VERSION=${PKG_FILE#\"$PKG.\"}; " + + "PKG_VERSION=${PKG_VERSION%.nupkg}; " + + "dotnet add \"$TARGET\" package \"$PKG\" --version \"$PKG_VERSION\"; " + + "else dotnet add \"$TARGET\" package \"$PKG\" --prerelease; fi"; + } + private static string GetDotnetToolInstallArgs(CliInstallStrategy strategy, string? nupkgSourcePath, string? nuGetConfigPath = null) { var args = "--global Aspire.Cli"; From fb58ca849f3ed3f6da59beaf358c038c72ac624a Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 12 Jun 2026 12:53:25 -0400 Subject: [PATCH 2/3] Address npm port review feedback Update the main release-process docs for the new npm skip parameters and fix the TypeScript npm README sample path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/release-process.md | 19 ++++++++----------- .../pack-cli-npm-package.pointer.README.md | 4 ++-- .../Pipelines/NpmCliPackageTests.cs | 4 ++++ 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/release-process.md b/docs/release-process.md index c78c211464d..0f427867b04 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -90,7 +90,6 @@ Before starting a release: | Parameter | Description | Default | |-----------|-------------|---------| | `SkipNuGetPublish` | Set `true` if re-running after NuGet success. | `false` | - | `SkipNpmPublish` | Set `true` if re-running after all npm packages are published. | `false` | | `SkipNpmRidPublish` | Set `true` if npm RID packages published but the pointer package did not. | `false` | | `SkipNpmPointerPublish` | Set `true` if the pointer package published but a later validation or promotion step failed. Registry validation still runs. | `false` | | `SkipChannelPromotion` | Set `true` if re-running after darc success. | `false` | @@ -99,10 +98,9 @@ Before starting a release: | `SkipReleaseAssets` | Set `true` to skip uploading `aspire-cli-*` assets to the GitHub release. | `false` | | `SkipHomebrewValidation` | Set `true` if re-running after a successful Homebrew cask validation against the live GitHub release. | `false` | | `SkipVSCodeExtensionPublish` | Set `false` to publish the signed `aspire-vscode-extension` artifact to the Visual Studio Marketplace. | `true` | - | `NpmPublishOwners` | Optional comma-separated ESRP owner aliases or emails. Leave empty for the repo default; overrides must still include the required owner aliases from `eng/pipelines/common-variables.yml`. | empty | - | `NpmPublishApprovers` | Optional comma-separated ESRP approver aliases or emails. Leave empty for the repo default; overrides must still include the required approver aliases from `eng/pipelines/common-variables.yml` and must not overlap owners. | empty | + | `NpmPublishOwners` | Comma-separated ESRP owner aliases or emails. Overrides must include `joperezr` or `ankj`, matching the required owner aliases in `eng/pipelines/release-publish-nuget.yml`. | `joperezr,ankj` | + | `NpmPublishApprovers` | Single ESRP approver alias or email. The approver must be a Microsoft address and must not overlap owners. | `adamratzman` | | `NpmRegistryPropagationDelayMinutes` | Delay between npm RID package and pointer package submissions. | `10` | - | `AllowNpmLatestDistTagMove` | Emergency override for intentionally moving npm `latest` to an older stable version. Older servicing releases should normally use `SkipNpmPublish=true`. | `false` | | `GitHubTasksWorkflowRef` | Ref to load `release-github-tasks.yml` from when dispatching. Only affects the workflow source; the release branch and commit are passed via inputs. Override only when testing pipeline changes on a topic branch. | `main` | 4. Select the **Resources** button in the bottom right, then select the source build from the `aspire-build` dropdown. @@ -119,7 +117,6 @@ To publish only the VS Code extension after merging an extension release PR, run | `IsPrerelease` | `false` for stable, `true` for pre-release | | `DryRun` | `false` | | `SkipNuGetPublish` | `true` | -| `SkipNpmPublish` | `true` | | `SkipNpmRidPublish` | `true` | | `SkipNpmPointerPublish` | `true` | | `SkipChannelPromotion` | `true` | @@ -133,7 +130,7 @@ To publish only the VS Code extension after merging an extension release PR, run For a full Aspire release that should also publish the extension, keep the normal NuGet/channel/GitHub task settings and set `SkipVSCodeExtensionPublish` to `false`. `IsPrerelease` also controls whether extension publishing passes `--pre-release` to `vsce`; for a pre-release extension, the selected source build must also have been queued with `Package VS Code Extension as Pre-Release=true`. -The npm release path validates Windows, Linux, and macOS install summaries, publishes the seven RID packages first, waits for ESRP completion, waits for the configured propagation delay, and then publishes the top-level `@microsoft/aspire-cli` pointer package. After the pointer package publishes, the pipeline installs it from the live npm registry and runs `aspire --version` before channel promotion. This avoids installing a pointer package whose optional RID dependencies are not visible yet and catches registry propagation issues before the release is promoted. For prereleases, set `SkipNpmPublish=true` unless the npm publishing path has gained explicit non-`latest` dist-tag support. +The npm release path validates Windows, Linux, and macOS install summaries, publishes the seven RID packages first, waits for ESRP completion, waits for the configured propagation delay, and then publishes the top-level `@microsoft/aspire-cli` pointer package. After the pointer package publishes, the pipeline installs it from the live npm registry and runs `aspire --version` before channel promotion. This avoids installing a pointer package whose optional RID dependencies are not visible yet and catches registry propagation issues before the release is promoted. For prereleases, set `SkipNpmRidPublish=true` and `SkipNpmPointerPublish=true` unless the npm publishing path has gained explicit non-`latest` dist-tag support. `commit_sha` and `release_branch` for the GitHub workflow are derived automatically from the source build resource, so there is no need to copy them by hand. @@ -160,7 +157,7 @@ Run this step only when releasing the VS Code extension independently of the nor The GitHub workflow is normally dispatched by the AzDO pipeline as the `aspire-repo-bot` GitHub App, with its `authorize` job bypassed for the bot. If a GitHub-side step fails partway through and you need to re-run only the GitHub work, you can: -1. Re-run the AzDO pipeline with completed AzDO-side work skipped, such as `SkipNuGetPublish`, `SkipNpmPublish`, `SkipNpmRidPublish`, `SkipChannelPromotion`, `SkipWinGetPublish`, `SkipHomebrewValidation`, and `SkipReleaseAssets` set as appropriate, keeping `SkipGitHubTasks: false`. The `GitHubTasks` stage will dispatch the workflow again with the right inputs, and the workflow's own `skip_*` idempotency makes the completed steps no-ops. +1. Re-run the AzDO pipeline with completed AzDO-side work skipped, such as `SkipNuGetPublish`, `SkipNpmRidPublish`, `SkipNpmPointerPublish`, `SkipChannelPromotion`, `SkipWinGetPublish`, `SkipHomebrewValidation`, and `SkipReleaseAssets` set as appropriate, keeping `SkipGitHubTasks: false`. The `GitHubTasks` stage will dispatch the workflow again with the right inputs, and the workflow's own `skip_*` idempotency makes the completed steps no-ops. 2. Or, navigate to Actions → **Release GitHub Tasks**, click **Run workflow**, and fill in the parameters manually: | Parameter | Description | Example | @@ -207,7 +204,7 @@ Both automations are designed to be idempotent and safe to re-run. | Prepare/List/Verify NuGet Packages | Check that the selected source build produced `PackageArtifacts`. | | Prepare/List npm Packages | Check that the selected source build produced all eight `microsoft-aspire-cli*.tgz` tarballs and matching `.tgz.sig` sidecars in `BlobArtifacts`. | | Push Packages to NuGet.org | Check NuGet.org for partial success, then re-run with already-completed steps skipped as needed. | -| MicroBuild npm Publish | Check the ESRP release result. If RID packages published but the pointer package did not, re-run with `SkipNuGetPublish: true`, `SkipNpmRidPublish: true`, and `SkipChannelPromotion: true`; do not set `SkipNpmPublish` until the pointer package is published. | +| MicroBuild npm Publish | Check the ESRP release result. If RID packages published but the pointer package did not, re-run with `SkipNuGetPublish: true`, `SkipNpmRidPublish: true`, `SkipNpmPointerPublish: false`, and `SkipChannelPromotion: true`; do not set `SkipNpmPointerPublish` until the pointer package is published. | | Validate Published npm Package from Registry | Confirm the pointer package is visible on npm and that `npm install -g @microsoft/aspire-cli@` works. If registry propagation is slow, re-run with completed publish steps skipped after the package is visible. | | Promote Build to Channel | Re-run with completed publish steps skipped. | | WinGet publishing / Homebrew validation | Re-run with the corresponding skip flags for completed work. | @@ -286,10 +283,10 @@ If ESRP published the RID packages but failed before publishing `@microsoft/aspi 1. Verify the RID packages are visible on npm. 2. Re-run the release pipeline with completed non-npm steps skipped. -3. Set `SkipNpmRidPublish: true` and keep `SkipNpmPublish: false` so only the pointer package is submitted. -4. Set `SkipNpmPublish: true` only after the pointer package is visible. +3. Set `SkipNpmRidPublish: true` and keep `SkipNpmPointerPublish: false` so only the pointer package is submitted. +4. Set `SkipNpmPointerPublish: true` only after the pointer package is visible. -If the pointer package published but the live npm registry validation failed afterward, re-run with `SkipNpmRidPublish: true`, `SkipNpmPointerPublish: true`, and `SkipNpmPublish: false` so the pipeline retries the install smoke without resubmitting already-published packages. +If the pointer package published but the live npm registry validation failed afterward, re-run with `SkipNpmRidPublish: true` and `SkipNpmPointerPublish: true` so the pipeline retries the install smoke without resubmitting already-published packages. ### Tag already exists but points to different commit diff --git a/eng/scripts/pack-cli-npm-package.pointer.README.md b/eng/scripts/pack-cli-npm-package.pointer.README.md index 340befe48d4..6c86e43592f 100644 --- a/eng/scripts/pack-cli-npm-package.pointer.README.md +++ b/eng/scripts/pack-cli-npm-package.pointer.README.md @@ -35,10 +35,10 @@ builder.AddViteApp("frontend", "./frontend") builder.Build().Run(); ``` -__TypeScript__ (`apphost.ts`) +__TypeScript__ (`apphost.mts`) ```typescript -import { createBuilder } from './.aspire/modules/aspire.js'; +import { createBuilder } from './.aspire/modules/aspire.mjs'; const builder = await createBuilder(); diff --git a/tests/Infrastructure.Tests/Pipelines/NpmCliPackageTests.cs b/tests/Infrastructure.Tests/Pipelines/NpmCliPackageTests.cs index 7cc7f1b9677..8cfff4e92b1 100644 --- a/tests/Infrastructure.Tests/Pipelines/NpmCliPackageTests.cs +++ b/tests/Infrastructure.Tests/Pipelines/NpmCliPackageTests.cs @@ -186,6 +186,10 @@ public async Task PackScriptGeneratesPointerPackageMetadataMapAndReadme() Assert.Contains($"npm install -g {PackageName}", readme); Assert.Contains("The native platform packages are installed through npm optional dependencies.", readme); Assert.Contains("If you run `aspire update --self` from an npm install, the CLI points you back to this npm update command.", readme); + Assert.Contains("__TypeScript__ (`apphost.mts`)", readme); + Assert.Contains("import { createBuilder } from './.aspire/modules/aspire.mjs';", readme); + Assert.DoesNotContain("apphost.ts", readme); + Assert.DoesNotContain("./.aspire/modules/aspire.js", readme); Assert.DoesNotContain("__PACKAGE_NAME__", readme); } From 21244d4206439c2b13b6641c69615e122366cff0 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Fri, 12 Jun 2026 13:00:22 -0400 Subject: [PATCH 3/3] Apply npm alias validation review feedback Use the shared Infrastructure.Tests repo-root helper in the npm alias validation tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ValidateNpmReleaseAliasesTests.cs | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/tests/Infrastructure.Tests/PowerShellScripts/ValidateNpmReleaseAliasesTests.cs b/tests/Infrastructure.Tests/PowerShellScripts/ValidateNpmReleaseAliasesTests.cs index a7f6d5d6ead..13fc6c70945 100644 --- a/tests/Infrastructure.Tests/PowerShellScripts/ValidateNpmReleaseAliasesTests.cs +++ b/tests/Infrastructure.Tests/PowerShellScripts/ValidateNpmReleaseAliasesTests.cs @@ -23,7 +23,7 @@ public sealed class ValidateNpmReleaseAliasesTests public ValidateNpmReleaseAliasesTests(ITestOutputHelper output) { _output = output; - _scriptPath = Path.Combine(FindRepoRoot(), "eng", "scripts", "validate-npm-release-aliases.ps1"); + _scriptPath = Path.Combine(RepoRoot.Path, "eng", "scripts", "validate-npm-release-aliases.ps1"); } [Fact] @@ -182,17 +182,4 @@ private static string Flatten(string output) return Regex.Replace(withoutAnsi.Replace("|", " "), @"\s+", " "); } - private static string FindRepoRoot() - { - var dir = new DirectoryInfo(AppContext.BaseDirectory); - while (dir is not null) - { - if (File.Exists(Path.Combine(dir.FullName, "Aspire.slnx"))) - { - return dir.FullName; - } - dir = dir.Parent; - } - throw new InvalidOperationException("Could not find repository root"); - } }