// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Xunit;
namespace Infrastructure.Tests;
public sealed class ReleasePublishNugetPipelineTests
{
private readonly string _repoRoot = RepoRoot.Path;
[Fact]
public async Task ValidatesNpmPublishPreconditionsBeforeNuGetPublish()
{
var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml");
var nuGetPublishIndex = FindRequiredText(pipeline, "task: 1ES.PublishNuget@1");
AssertBefore(
pipeline,
"npm publishing is blocked for prerelease runs because the MicroBuild npm publish template does not yet expose a dist-tag parameter.",
nuGetPublishIndex);
AssertBefore(
pipeline,
"$parameterName must include at least one required ESRP owner alias",
nuGetPublishIndex);
AssertBefore(
pipeline,
"Assert-SingleNpmReleaseAlias $normalizedApprovers 'NpmPublishApprovers'",
nuGetPublishIndex);
}
[Fact]
public async Task UsesEsrpPublishTemplateForNpmPublishing()
{
var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml");
// The MicroBuild. prefix is REQUIRED for ESRP-based publishing — it wires the MicroBuild
// signing/publish credential context so MicroBuild.Publish.yml and the auto-injected
// MicroBuildAuthorizePublishPlugin task can authenticate against the
// devdiv.pkgs.visualstudio.com/_packaging/MicroBuildToolset feed.
// Plain `1ES.Official.Publish.yml@MicroBuildTemplate` (no `MicroBuild.` prefix) injects
// the authorize task without supplying credentials, causing a 401.
// See microsoft/vscode-azuretools, microsoft/pyright, microsoft/vscode-python-environments.
Assert.Contains("template: azure-pipelines/MicroBuild.1ES.Official.Publish.yml@MicroBuildTemplate", pipeline);
Assert.DoesNotContain("template: v1/1ES.Official.PipelineTemplate.yml@1ESPipelineTemplates", pipeline);
// Guard against accidental regression to the plain template (without the MicroBuild. prefix)
Assert.DoesNotContain("template: azure-pipelines/1ES.Official.Publish.yml@MicroBuildTemplate", pipeline);
}
[Fact]
public async Task DefinesTeamNameVariableForMicroBuildTelemetry()
{
var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml");
// MicroBuild.1ES.Official.Publish.yml@MicroBuildTemplate auto-injects MicroBuildCleanup@1
// (displayName "🔩 MicroBuild Telemetry") at the END of every job. That task hard-requires
// a variable literally named `TeamName`; if absent the task fails with:
// "The TeamName variable is required to use MicroBuild. Please update your definition
// variables to include your team name in the 'TeamName' variable."
// common-variables.yml defines `_TeamName: dotnet-aspire` for Arcade conventions but
// MicroBuild reads the unprefixed name, so we must declare TeamName at pipeline scope.
Assert.Contains("- name: TeamName", pipeline);
Assert.Contains("value: dotnet-aspire", pipeline);
}
[Fact]
public async Task RoutesMicroBuildPublishAuthPluginToDncengFeedOrDisablesIt()
{
var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml");
// MicroBuild.1ES.Official.Publish.yml@MicroBuildTemplate -> Stages/PublishStage.yml
// -> Jobs/PublishJob.yml auto-injects MicroBuildAuthorizePublishPlugin@0 at the START
// of every job. By default that task pulls its nuget package from
// `devdiv.pkgs.visualstudio.com/_packaging/MicroBuildToolset`, which is NOT accessible
// from the dnceng collection -> 401 -> stage fails before any customer step runs.
// Two valid escapes from MicroBuildTemplate are required:
// 1) templateContext.mb.publish.enabled: false (for jobs that don't ESRP-publish)
// 2) templateContext.mb.publish.feedSource: <dnceng mirror> (for the publishing job)
// Both must be present in this pipeline:
// - non-publishing jobs (PrepareJob, WinGetJob, DispatchGitHubTasksJob,
// PublishReleaseAssetsJob, UpdateNixPackageJob) -> enabled: false
// - ReleaseJob (the only job that actually publishes) -> feedSource = dnceng mirror
Assert.Contains("enabled: false", pipeline);
Assert.Contains(
"feedSource: 'https://pkgs.dev.azure.com/dnceng/_packaging/MicroBuildToolset/nuget/v3/index.json'",
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.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 owner (single Microsoft alias or email; must be joperezr or ankj)'", pipeline);
Assert.Contains("displayName: '[Advanced] npm ESRP approver (single Microsoft alias or email; must differ from the owner)'", pipeline);
AssertOwnerDefaultIsSingleRequiredAlias(
FindYamlVariableValue(pipeline, "NPM_PUBLISH_REQUIRED_OWNERS"),
FindYamlParameterDefault(pipeline, "NpmPublishOwners"),
"NpmPublishOwners");
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-SingleNpmReleaseAlias $normalizedOwners 'NpmPublishOwners'", pipeline);
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:
// the owner is a single 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'", pipeline);
Assert.Contains("- name: NpmPublishApprovers", pipeline);
Assert.Contains("default: 'adamratzman'", pipeline);
Assert.Contains("[Advanced] npm ESRP owner", 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<string> 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()
{
var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml");
var pointerPublishIndex = FindRequiredText(pipeline, "folderLocation: '$(Pipeline.Workspace)\\npm\\pointer-package'");
var registryValidationIndex = FindRequiredText(pipeline, "npm install -g --foreground-scripts=true --no-audit --no-fund --loglevel=warn --registry=https://registry.npmjs.org/ $packageSpec");
var channelPromotionIndex = FindRequiredText(pipeline, "# ===== PROMOTE TO CHANNEL =====");
var nodeToolIndex = FindRequiredText(pipeline, "task: NodeTool@0");
var dryRunReachabilityIndex = FindRequiredText(pipeline, "Dry Run - Validate npm Registry Reachability");
var pointerSkipIndex = FindRequiredText(pipeline, "SkipNpmPointerPublish");
Assert.True(
pointerPublishIndex < registryValidationIndex,
"Expected registry validation to happen after the npm pointer package is published.");
Assert.True(
registryValidationIndex < channelPromotionIndex,
"Expected registry validation to happen before channel promotion.");
Assert.True(
nodeToolIndex < registryValidationIndex,
"Expected Node.js to be installed before registry validation uses npm.");
Assert.True(
dryRunReachabilityIndex < registryValidationIndex,
"Expected dry-run registry reachability validation to exercise npm before the actual publish-only install smoke.");
Assert.True(
pointerSkipIndex < registryValidationIndex,
"Expected pointer package publishing to be independently skippable so registry validation can be retried without republishing.");
Assert.Contains("aspire --version output matched the published npm package version", pipeline);
Assert.Contains("npm view $packageSpec version --registry=https://registry.npmjs.org/", pipeline);
Assert.Contains("Registry validation will still install the selected source build's pointer package version from npm.", pipeline);
}
[Fact]
public async Task PrepareNpmCliPackagesScriptIsBash32Compatible()
{
var template = await ReadRepoFileAsync("eng/pipelines/templates/prepare-npm-cli-packages.yml");
// macOS AzDO runners execute bash@3 tasks with /bin/bash which is still
// Bash 3.2 on every shipping macOS release. These constructs are Bash 4+
// and silently break the install/uninstall smoke that gates the npm release.
// See dry-run build 2987449 where `shopt: globstar: invalid shell option name`
// killed `🟣Locate pointer and RID tarballs` on macOS.
Assert.DoesNotContain("shopt -s globstar", template);
Assert.DoesNotContain("mapfile ", template);
Assert.DoesNotContain("readarray ", template);
// declare -A (associative arrays) is also Bash 4+.
Assert.DoesNotContain("declare -A", template);
}
[Fact]
public async Task PrepareNpmCliPackagesScriptInstallsOfflineWithTimeout()
{
var template = await ReadRepoFileAsync("eng/pipelines/templates/prepare-npm-cli-packages.yml");
// The pointer package declares every supported RID as an optionalDependency
// pinned to the just-built version, which does not yet exist in the public
// npm registry. Even with --omit=optional, npm still resolves optional dep
// metadata while building the dep tree. In 1ES Linux/Windows pools the
// registry call is blackholed by network isolation rules and each of 7
// lookups burns the full fetch-timeout — that's the 9-minute pointer install
// hang observed in dry-run build 2987581. Pair --omit=optional with --offline
// (no registry traffic at all) and cap any accidental fetch with a short
// --fetch-timeout. NPM_CONFIG_CACHE points at a fresh empty directory so
// --offline cannot reuse a poisoned cache.
Assert.Contains("--offline", template);
Assert.Contains("--fetch-timeout=", template);
}
[Fact]
public async Task PointerPublishPreflightsRidPackagesAreOnRegistry()
{
var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml");
// The pointer pins each RID package via optionalDependencies. If any
// RID dep is missing on npm at pointer-publish time (operator set
// SkipNpmRidPublish=true; only some RIDs landed in an earlier attempt;
// ESRP partial failure), end-user `npm install -g @microsoft/aspire-cli`
// succeeds but the launcher throws "The Aspire CLI native package '…'
// was not installed" on first invocation. The post-publish smoke only
// covers the publish-pool's own RID, so missing other-RID tarballs
// reach customers invisibly without this preflight.
Assert.Contains("Verify npm RID Packages Present Before Pointer Publish", pipeline);
Assert.Contains("Refusing to publish pointer package", pipeline);
var preflightIndex = pipeline.IndexOf("Verify npm RID Packages Present Before Pointer Publish", StringComparison.Ordinal);
Assert.True(preflightIndex > 0);
// The preflight must precede the actual pointer publish so it can gate
// submission.
var pointerPublishIndex = pipeline.IndexOf(
"folderLocation: '$(Pipeline.Workspace)\\npm\\pointer-package'",
StringComparison.Ordinal);
Assert.True(pointerPublishIndex > preflightIndex,
"Preflight RID-check must appear before the pointer-publish step.");
}
[Fact]
public async Task VerifiesStagedNpmPackageVersionsBeforeRidPublish()
{
var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml");
// An npm publish is unrevocable. The pointer preflight cross-checks the
// pointer version against the prepare-stage validated version, but that
// runs AFTER the 7 RID tarballs are already submitted to ESRP. So every
// staged RID and pointer tarball's own package.json version must be
// asserted against NpmValidatedExpectedVersion BEFORE the RID publish, or
// a wrong-version build that slipped into staging would leak onto the
// public registry before any version gate fires.
Assert.Contains("Verify Staged npm Package Versions", pipeline);
Assert.Contains("does not match the prepare-stage validated version", pipeline);
var versionCheckIndex = pipeline.IndexOf("Verify Staged npm Package Versions", StringComparison.Ordinal);
Assert.True(versionCheckIndex > 0);
// The version check must precede the RID-package publish so it can gate
// submission to ESRP.
var ridPublishIndex = pipeline.IndexOf(
"folderLocation: '$(Pipeline.Workspace)\\npm\\rid-packages'",
StringComparison.Ordinal);
Assert.True(ridPublishIndex > versionCheckIndex,
"Staged-version check must appear before the RID-publish step.");
}
[Fact]
public async Task PostPublishSmokeRejectsEmptyAspireVersionOutput()
{
var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml");
// Without an explicit empty-stdout check, `@(...)` wraps an empty
// version line into an empty array and PowerShell's `-notmatch`
// against an empty array silently returns an empty array (falsy),
// letting an `aspire --version` that exits 0 with no output slip past
// the version-pattern check. Assert the explicit guard is present.
Assert.Contains("$versionLine.Count -eq 0", pipeline);
Assert.Contains("produced no output.", pipeline);
}
[Fact]
public async Task PointerPreflightPinsPublicNpmRegistry()
{
var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml");
// Every npm command in the publish flow MUST explicitly pin
// `--registry=https://registry.npmjs.org/`. The release agent's
// ambient registry is not guaranteed to be public npmjs — an
// internal mirror may be configured via .npmrc or
// npm_config_registry. Without the explicit pin, the preflight
// could (a) spuriously fail after a successful public publish
// if the mirror lacks the new package, or (b) pass against a
// stale mirror and let the pointer publish reference RIDs the
// public registry can't serve. Guard against future drift by
// asserting the preflight `npm view` is registry-pinned.
Assert.Contains(
"npm view $spec version --registry=https://registry.npmjs.org/",
pipeline);
}
[Fact]
public async Task PointerPreflightRetriesForPropagationLag()
{
var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml");
// The post-publish smoke uses 10×30s retry loops to ride out npm
// CDN propagation. The pre-pointer RID preflight must do the same
// because npm propagation of 7 freshly-published scoped tarballs
// can exceed the fixed NpmRegistryPropagationDelayMinutes wait.
// A single-shot preflight would fail closed AFTER all 7 RID
// packages are already published, forcing a manual re-run with
// SkipNpmRidPublish=true. Assert the preflight has its own
// retry loop.
Assert.Contains("$preflightAttempts = 10", pipeline);
Assert.Contains("$preflightDelaySeconds = 30", pipeline);
Assert.Contains("for ($preflightAttempt = 1; $preflightAttempt -le $preflightAttempts;", pipeline);
}
[Fact]
public async Task NpmViewParsingFiltersToSemverShape()
{
var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml");
// `npm view --loglevel=warn` merges deprecation / peer-dep /
// EBADENGINE warnings onto stderr. With `2>&1`, taking
// `Select-Object -First 1` could latch a warning line as the
// version, burn all 10 retries, and fail the release even though
// the publish succeeded. Both the preflight and post-publish
// smoke filter to lines that match a semver shape before
// comparing.
var semverRegexUses = System.Text.RegularExpressions.Regex.Matches(
pipeline,
@"\$semverRegex\s*=\s*'\^\\d\+\\\.\\d\+\\\.\\d\+");
Assert.True(
semverRegexUses.Count >= 2,
$"Expected the semver regex to be defined in both the preflight and post-publish smoke; found {semverRegexUses.Count} occurrence(s).");
}
[Fact]
public async Task NpmSignatureSidecarsAreContentSanityChecked()
{
// release-publish-nuget.yml inlines a content sanity check on every
// microsoft-aspire-cli*.tgz.sig sidecar. The check exists to catch
// the most likely silent failure mode in Arcade/ESRP signing: the
// sidecar file gets emitted (so a file-existence check passes) but
// the content is empty or garbage. A real PGP signature is hundreds
// of bytes and starts with either the ASCII-armored header
// `-----BEGIN PGP SIGNATURE-----` (RFC 9580 §6) or an OpenPGP binary
// signature packet (tag 2: old-format 0x88..0x8B or new-format 0xC2,
// RFC 9580 §4.3 / §5.2).
//
// Behavioral coverage of the same logic in eng/scripts/validate-npm-package-signatures.ps1
// lives in ValidateNpmPackageSignaturesTests; if release-publish-nuget.yml
// is ever refactored to call that script instead of inlining the
// bytes, assert the script invocation here and drop these literal
// marker assertions.
var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml");
Assert.Contains("'-----BEGIN PGP SIGNATURE-----'", pipeline);
Assert.Contains("0x8B", pipeline);
Assert.Contains("0xC2", pipeline);
Assert.Contains("content sanity check", pipeline);
}
[Fact]
public async Task AspireVersionCaptureStripsCarriageReturnForWindowsRunner()
{
var template = await ReadRepoFileAsync("eng/pipelines/templates/prepare-npm-cli-packages.yml");
// Regression guard for the CRLF-stripping fix surfaced by opus-4.7 review.
//
// On Windows runners the prepare-npm step runs under Git Bash, which
// launches `aspire.exe` as a Windows console process. System.CommandLine
// 2.x's VersionOption writes through Console.Out.WriteLine, which
// terminates lines with Environment.NewLine = "\r\n" on Windows. Bash
// command substitution `$(...)` strips trailing LF but NOT CR, so the
// captured variable ends with "\r". The semver capture regex used by
// the install validation is anchored with `$` (end-of-line), which does
// not match a literal CR — so without `tr -d '\r'` on the version
// capture, the entire install validation silently fails on Windows with
// "##[error]aspire --version reported '' but expected '<version>'".
//
// Verified locally: `printf 'X\r\n' | grep -Eo '^X$'` produces NO match.
//
// 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 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);
}
[Fact]
public async Task PointerPreflightExplicitlyPinsRegistryOnSpecLine()
{
var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml");
// The preflight that gates the pointer publish runs `npm view $spec ...`
// (note: `$spec`, not `$packageSpec` — the latter is the post-publish
// smoke). A separate test asserts the post-publish line is registry-
// pinned; this one asserts the preflight line is also pinned, so that
// a future refactor that drops `--registry=https://registry.npmjs.org/`
// from the preflight call would be caught at PR-time rather than
// silently letting a stale internal-mirror result decide whether to
// ship a broken pointer to npmjs.
Assert.Contains("npm view $spec version --registry=https://registry.npmjs.org/", pipeline);
}
[Fact]
public async Task WinGetPublishingRunsOnlyForStableReleases()
{
var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml");
Assert.Equal("false", FindYamlParameterDefault(pipeline, "SkipWinGetPublish"));
const string stableReleaseGate = "${{ if and(eq(parameters.SkipWinGetPublish, false), eq(parameters.IsPrerelease, false)) }}:";
Assert.Equal(2, pipeline.Split(stableReleaseGate, StringSplitOptions.None).Length - 1);
var winGetJob = ExtractSection(
pipeline,
"# ===== WINGET PUBLISHING =====",
"# ===== STAGE 3: GITHUB TASKS =====");
Assert.Contains("eq('${{ parameters.SkipWinGetPublish }}', 'false')", winGetJob);
Assert.Contains("eq('${{ parameters.IsPrerelease }}', 'false')", winGetJob);
var releaseSummary = ExtractSection(
pipeline,
"# ===== SUMMARY =====",
"# ===== VS CODE EXTENSION PUBLISHING =====");
var winGetSummary = ExtractSection(
releaseSummary,
"""Write-Host "║ WinGet:""",
"""Write-Host "║ GitHub Tasks:""");
Assert.Contains("""if ("${{ parameters.SkipWinGetPublish }}" -eq "true")""", winGetSummary);
Assert.Contains("""elseif ("${{ parameters.IsPrerelease }}" -eq "true")""", winGetSummary);
Assert.Contains("Write-Host \" (SKIPPED - prerelease)\"", winGetSummary);
}
[Fact]
public async Task VSCodeExtensionPublishUsesAzureCredential()
{
var pipeline = await ReadRepoFileAsync("eng/pipelines/release-publish-nuget.yml");
var job = ExtractSection(
pipeline,
"# ===== VS CODE EXTENSION PUBLISHING =====",
"# ===== WINGET PUBLISHING =====");
Assert.Contains("task: AzureCLI@2", job);
// The service connection name must match the connection whose identity is authorized on
// the microsoft-aspire Marketplace publisher. A mismatch fails only at publish time,
// which is the last step of a release.
Assert.Contains("azureSubscription: 'AspireSecurePublishPipelineMarketplaceConnectionWithManagedIdentity'", job);
Assert.Contains("vsce verify-pat --azure-credential $publisher", job);
Assert.Contains("""$publishArgs = @("publish", "--azure-credential", "--packagePath", $vsix.FullName, "--manifestPath", $manifestPath, "--signaturePath", $signaturePath)""", job);
Assert.Contains("vsce @publishArgs", job);
var secretReferenceMatches = System.Text.RegularExpressions.Regex.Matches(job, @"\b(VSCE_PAT|VscePublishToken)\b");
Assert.Empty(secretReferenceMatches);
}
[Fact]
public async Task ExtensionReleaseInstructionsSkipNonExtensionReleaseLegs()
{
var workflow = await ReadRepoFileAsync(".github/workflows/extension-release.yml");
var instructions = ExtractSection(
workflow,
"For an extension-only release, use these parameters:",
"For a full Aspire release");
Assert.Contains("| \\`SkipNuGetPublish\\` | \\`true\\` |", instructions);
Assert.Contains("| \\`SkipNpmRidPublish\\` | \\`true\\` |", instructions);
Assert.Contains("| \\`SkipNpmPointerPublish\\` | \\`true\\` |", instructions);
Assert.Contains("| \\`SkipChannelPromotion\\` | \\`true\\` |", instructions);
Assert.Contains("| \\`SkipWinGetPublish\\` | \\`true\\` |", instructions);
Assert.Contains("| \\`SkipGitHubTasks\\` | \\`true\\` |", instructions);
Assert.Contains("| \\`SkipReleaseAssets\\` | \\`true\\` |", instructions);
Assert.Contains("| \\`SkipNixPackageUpdate\\` | \\`true\\` |", instructions);
Assert.Contains("| \\`SkipVSCodeExtensionPublish\\` | \\`false\\` |", instructions);
}
[Fact]
public async Task ExtensionReleaseDryRunInstructionsDoNotOverstatePublisherRoleValidation()
{
var workflow = await ReadRepoFileAsync(".github/workflows/extension-release.yml");
Assert.Contains("can acquire an Azure credential and read publisher role assignments", workflow);
Assert.Contains("Separately confirm the service connection identity is a Contributor", workflow);
}
[Fact]
public async Task WinGetJobVerifiesWingetCreateBeforeConditionalSubmission()
{
var template = await ReadRepoFileAsync("eng/pipelines/templates/publish-winget.yml");
var runtimeInstallIndex = FindRequiredText(template, "- task: UseDotNet@2");
var wingetCreateInstallIndex = FindRequiredText(template, "Write-Host \"Downloading wingetcreate...\"");
var submitIndex = FindRequiredText(template, "Write-Host \"Submitting WinGet manifests");
var runtimeInstall = template[runtimeInstallIndex..wingetCreateInstallIndex];
var wingetCreateInstall = template[wingetCreateInstallIndex..submitIndex];
var submission = template[submitIndex..];
Assert.Contains("packageType: 'runtime'", runtimeInstall);
Assert.Contains("version: '9.0.x'", runtimeInstall);
Assert.Contains("condition: succeeded()", runtimeInstall);
Assert.Contains("wingetcreate.exe\" info", wingetCreateInstall);
Assert.Contains("condition: succeeded()", wingetCreateInstall);
Assert.Contains("eq('${{ parameters.dryRun }}', 'false')", submission);
Assert.Contains("eq(variables['_IsProductionBranch'], 'true')", submission);
}
[Fact]
public async Task WinGetPreparationExercisesWingetCreate()
{
var template = await ReadRepoFileAsync("eng/pipelines/templates/prepare-winget-manifest.yml");
Assert.Contains("- task: UseDotNet@2", template);
Assert.Contains("packageType: 'runtime'", template);
Assert.Contains("version: '9.0.x'", template);
Assert.Contains("https://aka.ms/wingetcreate/latest", template);
Assert.Contains("wingetcreate.exe\" info", template);
}
[Fact]
public async Task MarketplacePublishingDocumentationKeepsIdentityDetailsInternalAndRetiresPat()
{
var documentation = await ReadRepoFileAsync("docs/release-process.md");
var identitySection = ExtractSection(
documentation,
"#### Marketplace publishing identity",
"### Approved GitHub Actions");
Assert.Contains(
"[Azure DevOps service connections](https://dev.azure.com/dnceng/internal/_settings/adminservices)",
identitySection);
Assert.DoesNotMatch(
@"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b",
documentation);
Assert.Contains(
"If the variable is still present in `Aspire-Release-Secrets`, revoke the PAT and delete the variable.",
documentation);
}
private static string ExtractSection(string contents, string begin, string end)
{
var beginIndex = FindRequiredText(contents, begin);
var endIndex = FindRequiredText(contents, end);
Assert.True(endIndex > beginIndex, $"Expected '{end}' after '{begin}'.");
return contents[beginIndex..endIndex];
}
private static void AssertBefore(string contents, string text, int boundaryIndex)
{
var textIndex = FindRequiredText(contents, text);
Assert.True(
textIndex < boundaryIndex,
$"Expected '{text}' to appear before 'task: 1ES.PublishNuget@1'.");
}
private static int FindRequiredText(string contents, string text)
{
var index = contents.IndexOf(text, StringComparison.Ordinal);
Assert.True(index >= 0, $"Expected to find '{text}'.");
return index;
}
private static void AssertOwnerDefaultIsSingleRequiredAlias(string requiredAliasesValue, string actualAliasesValue, string parameterName)
{
// The single-owner rule means the default must normalize to exactly one alias, and that
// alias must be one of the required ESRP owner aliases so unattended runs pass validation.
var actualAliases = ParseNpmReleaseAliasSet(actualAliasesValue);
Assert.True(
actualAliases.Count == 1,
$"{parameterName} default must be a single alias, but was '{actualAliasesValue}'.");
var requiredAliases = ParseNpmReleaseAliasSet(requiredAliasesValue);
Assert.True(
actualAliases.All(requiredAliases.Contains),
$"{parameterName} default '{actualAliasesValue}' must be one of the required ESRP owner aliases: {requiredAliasesValue}.");
}
private static HashSet<string> ParseNpmReleaseAliasSet(string value)
{
var aliases = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var alias = entry;
if (alias.EndsWith("@microsoft.com", StringComparison.OrdinalIgnoreCase))
{
alias = alias[..^"@microsoft.com".Length];
}
aliases.Add(alias.ToLowerInvariant());
}
return aliases;
}
private static string FindYamlVariableValue(string contents, string variableName)
=> FindYamlValueAfterMarker(contents, $"- name: {variableName}", "value:");
private static string FindYamlParameterDefault(string contents, string parameterName)
=> FindYamlValueAfterMarker(contents, $"- name: {parameterName}", "default:");
private static string FindYamlValueAfterMarker(string contents, string marker, string valueKey)
{
var lines = contents.Split('\n');
var markerLineIndex = Array.FindIndex(lines, line => line.TrimEnd('\r').Trim() == marker);
Assert.True(markerLineIndex >= 0, $"Expected to find '{marker}'.");
var markerIndent = CountLeadingWhitespace(lines[markerLineIndex]);
for (var i = markerLineIndex + 1; i < lines.Length; i++)
{
var rawLine = lines[i].TrimEnd('\r');
var line = rawLine.Trim();
if (line.Length == 0)
{
continue;
}
var indent = CountLeadingWhitespace(rawLine);
if (indent == markerIndent && line.StartsWith("- ", StringComparison.Ordinal))
{
break;
}
if (indent > markerIndent && line.StartsWith(valueKey, StringComparison.Ordinal))
{
return TrimYamlQuotes(line[valueKey.Length..].Trim());
}
}
throw new Xunit.Sdk.XunitException($"Expected to find '{valueKey}' after '{marker}'.");
}
private static int CountLeadingWhitespace(string value)
{
var count = 0;
while (count < value.Length && char.IsWhiteSpace(value[count]))
{
count++;
}
return count;
}
private static string TrimYamlQuotes(string value)
{
if (value.Length >= 2 &&
((value[0] == '\'' && value[^1] == '\'') ||
(value[0] == '"' && value[^1] == '"')))
{
return value[1..^1];
}
return value;
}
private Task<string> ReadRepoFileAsync(string relativePath)
=> File.ReadAllTextAsync(Path.Combine(_repoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)));
}