From 18df3d2b29d99a51a2cab9a87c75ef2f4cb91a45 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Tue, 14 Jul 2026 20:11:11 +0100 Subject: [PATCH 1/8] Add Preview workload installability readiness check Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3b2e86e0-ce82-4e1b-94ec-d554098fcf02 --- .github/skills/release-readiness/SKILL.md | 94 +- .../references/methodology.md | 129 +- .../scripts/Get-PreviewReadiness.ps1 | 100 +- .../scripts/PreviewInstallability.ps1 | 1191 +++++++++++++++++ .../tests/Test-ReleaseReadiness.ps1 | 321 +++++ 5 files changed, 1830 insertions(+), 5 deletions(-) create mode 100644 .github/skills/release-readiness/scripts/PreviewInstallability.ps1 diff --git a/.github/skills/release-readiness/SKILL.md b/.github/skills/release-readiness/SKILL.md index ff0d4aefcc08..1a12c3cfde3c 100644 --- a/.github/skills/release-readiness/SKILL.md +++ b/.github/skills/release-readiness/SKILL.md @@ -4,7 +4,7 @@ description: Assesses ship-readiness for .NET MAUI release branches β€” Servicin metadata: author: dotnet-maui version: "2.0" -compatibility: Requires `gh` CLI authenticated with `repo` + `read:org` scopes. `az` CLI is optional but recommended for internal pipeline status. Run from a checkout of `dotnet/maui`. +compatibility: Requires `gh` CLI authenticated with `repo` + `read:org` scopes. `az` CLI is optional but recommended for internal pipeline status. Preview installability uses NuGet v3 feeds; an optional short-lived Azure DevOps PAT with Packaging Read scope may be needed for an authenticated shipping feed. Run from a checkout of `dotnet/maui`. --- # Release Readiness @@ -29,13 +29,14 @@ This skill **reports**. It does **not** execute release operations against dotne ## Architecture -This skill has **three** PowerShell entry points and one workflow: +This skill has **three** PowerShell entry points, one Preview helper, and one workflow: | Script | Branch type | Purpose | |--------|-------------|---------| | [`Find-ReleaseReadinessTrackers.ps1`](scripts/Find-ReleaseReadinessTrackers.ps1) | both | Detects active in-flight & candidate trackers (SR and Preview) across all active majors using a four-lane algorithm and the **tag-existence rule** ("a release is in flight unless its tag already exists"). Emits a single tracker JSON consumed by the workflow. | | [`Get-ReleaseReadiness.ps1`](scripts/Get-ReleaseReadiness.ps1) | SR | Full readiness report for a single SR branch (in-flight, `-Candidate`, or `-Shipped`). `-Shipped` is a display-only relabel β€” it surveys the SR branch exactly like in-flight but renders the header as `mode=shipped` for the post-ship tracker. | -| [`Get-PreviewReadiness.ps1`](scripts/Get-PreviewReadiness.ps1) | Preview | Full readiness report for a single Preview branch (in-flight or candidate via `-Mode candidate -SurveyRef net.0`). | +| [`Get-PreviewReadiness.ps1`](scripts/Get-PreviewReadiness.ps1) | Preview | Full readiness report for a single Preview branch (in-flight or candidate via `-Mode candidate -SurveyRef net.0`), including consumer-installability evidence. | +| [`PreviewInstallability.ps1`](scripts/PreviewInstallability.ps1) | Preview helper | Resolves the workload-set package, validates branch-pin coherence, probes manifest and representative pack availability, extracts platform prerequisites, and emits an isolated NuGet configuration for local validation. | | [`release-readiness.yml`](../../workflows/release-readiness.yml) | both | Daily cron + manual dispatch + PR validation. Runs `Find-Trackers -AllActiveMajors`, fans out a matrix job per tracker, and writes idempotent `[Release Readiness]` issues per branch. | ### Tag-existence rule (canonical signal) @@ -106,6 +107,11 @@ pwsh .github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 \ -OutputDir CustomAgentLogsTmp/release-readiness/preview6-candidate ``` +The unattended public survey does not know the release-owner-confirmed workload-set +version or private shipping source. It therefore keeps **Consumer installability** +`UNKNOWN` rather than guessing that the newest coherent package is the blessed one. +Complete the local gate below before declaring a Preview ready. + ### Preview: authoritative blessed-build source (.NET Release Tracker) For **Previews**, this skill's public survey (CI health + regression classification on `net.0` or the preview branch) tells you whether the code is *ready*, but it **cannot on its own name which staged build is the official, blessed preview** β€” that designation lives in the private **.NET Release Tracker** plugin. So when answering *"run release readiness … is net11 preview6 ready?"* / *"which build is the official preview6?"*, consult that authoritative source **in addition to** running `Get-PreviewReadiness.ps1`: @@ -129,6 +135,85 @@ The full tier table, the user-scope opt-in snippet, and the privacy guardrails l **Don't maintain a standing "🏷️ Official (blessed) preview build" table in the tracker.** The deterministic CI body already owns the public blessed-build handling: its **"🏷️ Preview N component build β€” branch pins + inferred sub health"** section states the pins are explicitly *not* the blessed build, carries the drift-proof "verify locally" prompt, and infers subscription health from the public PR trail. Because the blessed build number is embargoed (withheld from the public issue), a standing public table just renders "πŸ”’ withheld" and duplicates that callout. So a local run with tracker access should **report the blessed SDK/runtime build in its conversational answer**, and only add a line to _Release Captain Notes_ when there's a **decision or exception worth persisting** β€” e.g. the blessed build differs from the branch pin, a promoted build was rejected, or a subscription is confirmed broken. Don't re-create the section the CI body already renders. +### Preview: consumer-installability gate + +The branch being green is insufficient: a customer must be able to acquire the +exact SDK workload set, its component manifests, and representative Android, +Apple, MAUI, and runtime packs from a clean source configuration. + +Use the exact workload-set **CLI version** confirmed by the release owner. Do not +substitute the branch SDK version, and do not assume the newest coherent package +is blessed. Workload-set CLI and NuGet versions have different normalization: +`11.0.100-preview.6.26363.2` maps to +`11.100.0-preview.6.26363.2` for the NuGet package. + +If all assets are public, the confirmed version is enough: + +```bash +pwsh .github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 \ + -Branch release/11.0.1xx-preview6 \ + -Mode in-flight \ + -ConfirmedWorkloadSetVersion 11.0.100-preview.6.26363.2 \ + '-PublicSafe:$false' \ + -OutputDir CustomAgentLogsTmp/release-readiness/preview6-local +``` + +If an authenticated shipping feed is required: + +1. Create a short-lived PAT at + [`https://dev.azure.com/dnceng/_usersSettings/tokens`](https://dev.azure.com/dnceng/_usersSettings/tokens). + Select the `dnceng` organization and grant only **Packaging > Read**. Use the + shortest practical expiration. Never paste the PAT into a command argument, + NuGet.Config, report, issue, PR, chat transcript, or repository file. +2. Put the credential in NuGet's standard environment variable. The suffix must + exactly match the source name passed to `-AdditionalPackageSource`. + + ```bash + read -s -p "dnceng Packaging Read PAT: " DNCENG_PACKAGING_PAT; echo + export NuGetPackageSourceCredentials_internal_preview6="Username=release-readiness;Password=${DNCENG_PACKAGING_PAT};ValidAuthenticationTypes=Basic" + unset DNCENG_PACKAGING_PAT + ``` + +3. Run the local report with the source in `name=https://...` form: + + ```bash + pwsh .github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 \ + -Branch release/11.0.1xx-preview6 \ + -Mode in-flight \ + -ConfirmedWorkloadSetVersion 11.0.100-preview.6.26363.2 \ + -AdditionalPackageSource 'internal_preview6=' \ + '-PublicSafe:$false' \ + -OutputDir CustomAgentLogsTmp/release-readiness/preview6-local + ``` + +4. Use the generated local-only `` NuGet configuration and install + command from `preview-readiness.md`. Then remove the credential: + + ```bash + unset NuGetPackageSourceCredentials_internal_preview6 + ``` + +`-PublicSafe $false` intentionally includes exact source URLs and installation +instructions, so keep that output local. The default public-safe report removes +additional source names, URLs, nested source metadata, credentials, and the +generated NuGet configuration. + +The gate classifies evidence as follows: + +| Installability | Readiness | Meaning | +|----------------|-----------|---------| +| `installable` | `READY` | Confirmed CLI version, branch pins, required manifests, and representative packs all agree and resolve. | +| `missing` | `BLOCKED` | A confirmed package or asset is absent from every accessible supplied source. | +| `mismatched` | `BLOCKED` | The workload set disagrees with the branch SDK, Android, Apple, or runtime pins, or with the target MAUI Preview train. | +| `unknown` | `UNKNOWN` | Version is unconfirmed, a source is inaccessible, or evidence could not be read. HTTP 401/403 is never treated as proof that a package is missing. | + +The isolated source set is deliberate: `dotnet-workloads` owns the workload-set +package, `dotnet-workloads` owns platform manifests/assets, +`dotnet` owns MAUI and Apple manifests/assets, +`dotnet-transport` owns runtime transport assets, and `dotnet-public` +plus NuGet.org provide shared dependencies. Do not inherit stale feeds from a +machine-wide NuGet.Config. + ### Preview: is the branch actually plumbed? (subscription wiring + feed drift) A preview can pass CI and even have a blessed build yet still not be *ship-wired* β€” @@ -207,6 +292,8 @@ the inferred signal, report it conversationally and leave the tracker to the CI | `-OutputFormat` | No | `markdown` | `markdown`, `json`, or `both`. | | `-IncludeInternal`, `-InternalBuildId` | No | β€” | Release-captain only β€” augments report with internal pipeline status when AzDO auth is available. | | `-PublicSafe` | No | `$true` | Sanitizes non-READY internal status from public output. | +| `-ConfirmedWorkloadSetVersion` | No | β€” | Exact release-owner-confirmed workload-set CLI version. Required before Consumer installability can become `READY`. | +| `-AdditionalPackageSource` | No | β€” | Repeatable `name=https://...` authenticated source. Credentials come from `NuGetPackageSourceCredentials_`, never from the argument. | ## Outputs @@ -366,3 +453,4 @@ The harness covers: - **`Get-ReleaseReadiness`** verdict classification using known-answer data from the SR7 readiness analysis (e.g. #35313 β†’ `in-sr-active`, #35344 β†’ `in-sr-active` via the SafeArea follow-on fix, #35771 β†’ `no-fix-yet`) - **Idempotent body hash** stability across re-runs β€” **SR trackers only** (the daily workflow compares the embedded `` marker against the live issue and skips the edit when the semantic content is unchanged, so re-runs don't churn the tracker). Preview trackers carry no hash marker and are refreshed on every scheduled run. - **Nightly dogfood feed banner** (`NightlyFeed.ps1`) β€” offline unit coverage for the lane-label honest-labeling rule (`Format-NightlyFeedLaneLabel`), the `ci.inflight`-first / `ci.main`-false-green resolver, ageβ†’tier bucketing, the fail-open feed query (mocked `-Fetcher`), and the banner's fold into `Get-ReportSemanticHash` (tier change refreshes, same-tier day tick does not). All network-free via injected fixtures and explicit `-Now`. +- **Preview consumer installability** (`PreviewInstallability.ps1`) β€” offline fixtures cover CLI/NuGet version conversion, workload-set discovery with MSI exclusion, branch-pin coherence, source-role resolution, required manifest and representative pack probes, platform prerequisites, isolated `` configuration, 401/403=`UNKNOWN` semantics, verdict mapping, and public-output redaction. diff --git a/.github/skills/release-readiness/references/methodology.md b/.github/skills/release-readiness/references/methodology.md index 6a7628b6731d..bfb22e08fae2 100644 --- a/.github/skills/release-readiness/references/methodology.md +++ b/.github/skills/release-readiness/references/methodology.md @@ -1,6 +1,6 @@ # Release Readiness β€” Methodology -This document captures the algorithms used by `Get-ReleaseReadiness.ps1` and **the three gotchas** discovered through real SR analysis that the algorithms exist to prevent. +This document captures the deterministic algorithms used by the SR and Preview readiness engines, including **the three gotchas** discovered through real SR analysis and the Preview **consumer-installability gate**. ## Gotcha #1: Cherry-Pick Number Swap @@ -173,6 +173,133 @@ The skill must derive which `regressed-in-X.Y.Z` labels matter for a given SR: | `closed-fix-unlinked` | `no-fix-yet` would apply, BUT the issue is CLOSED and a closing comment **explicitly names** a fix PR (fix/resolve/close language) that is MERGED and whose commit is on `$SrBranch` (verified by SHA-ancestry OR the `(#)` squash-subject token). A bare PR mention (the cause-PR blame pattern) is rejected. Surfaces a missing PR↔issue link rather than a false "no fix" alarm. Non-blocking (Tier 3) | | `needs-human-review` | Only weak evidence; OR multiple candidate PRs with conflicting verdicts | +## Preview Consumer Installability + +A green Preview branch or promoted SDK build does not prove that a customer can +install the exact workload set from a clean machine. The gate in +`PreviewInstallability.ps1` evaluates package identity, branch pins, source +availability, and platform prerequisites before contributing to the Preview +verdict. + +### Inputs and Trust Boundaries + +The evaluator uses: + +1. Branch pins for the VMR/runtime SDK, Android, and Apple. The VMR pin also + anchors Mono toolchain and Emscripten; MAUI is checked against the target + Preview train because the MAUI branch does not statically pin its own build + version. +2. Public NuGet v3 sources derived from the SDK major version. +3. An optional release-owner-confirmed workload-set CLI version. +4. Optional additional HTTPS package sources in `name=https://...` form. + +Only a release owner can identify the blessed workload-set version. Discovery +may find several coherent candidates, but "newest coherent" does not mean +"approved for release." An unconfirmed version therefore cannot produce +`READY`. + +Additional sources are accepted only for NuGet.org or dnceng Azure Artifacts +HTTPS endpoints. User information embedded in a URL is rejected. Credentials +come from NuGet's `NuGetPackageSourceCredentials_` environment variable, +where `` exactly matches the source name. The value must contain non-empty +`Username` and `Password` fields and `ValidAuthenticationTypes=Basic`. + +### Workload-Set Version Normalization + +The workload-set CLI version and NuGet package version differ: + +```text +CLI: .0.-preview.. +NuGet: ..0-preview.. +``` + +For example: + +```text +11.0.100-preview.6.26363.2 + -> 11.100.0-preview.6.26363.2 +``` + +The package ID is derived from the SDK feature band and Preview number: + +```text +Microsoft.NET.Workloads..0.-preview. +``` + +MSI packages are excluded from discovery because they are installer artifacts, +not workload-set metadata packages. + +### Source Roles and Isolation + +The evaluator builds a clean source list instead of inheriting machine-wide +NuGet configuration: + +| Source role | Expected contents | +|-------------|-------------------| +| `dotnet-workloads` | Workload-set metadata package | +| `dotnet-workloads` | Android and other platform manifest assets | +| `dotnet` | MAUI and Apple manifests and packs | +| `dotnet-transport` | Runtime transport packs | +| `dotnet-public` | Shared dotnet dependencies | +| NuGet.org | Public ecosystem dependencies | +| Additional authenticated source | Release-specific assets not yet present on public feeds | + +The generated local NuGet configuration starts with ``. This prevents +an old major-version or stale shipping feed from making an installation appear +to work accidentally. + +### Evaluation Algorithm + +1. Derive the SDK major, feature band, Preview number, package ID, CLI version, + and normalized NuGet version. +2. If no version is confirmed, discover prerelease candidates from accessible + sources as diagnostic evidence and return `unknown`. +3. Download and extract the exact confirmed workload-set package. +4. Compare its Android, Apple, runtime/VMR, and Emscripten versions with the + branch pins, and verify that its MAUI manifest targets the expected Preview + train. +5. Probe every required manifest package. +6. Probe representative Android, Apple, MAUI, and runtime transport packs. +7. Extract the JDK, Android SDK, Xcode, Apple SDK, and Windows App SDK + prerequisites when present. +8. Emit an isolated local NuGet configuration and exact install command only + when public-safe mode is disabled. + +Package probes distinguish absence from unavailable evidence: + +- A not-found response from all accessible sources can establish `missing`. +- HTTP 401/403, an inaccessible authenticated source, timeout, malformed + metadata, or another read failure establishes only `unknown`. +- A package found on one supplied source is available even if another source is + inaccessible. + +### State and Verdict Mapping + +| Gate state | Readiness check | Rule | +|------------|-----------------|------| +| `installable` | `READY` | The version is confirmed, pins agree, and all required manifests and representative packs resolve. | +| `missing` | `BLOCKED` | A confirmed package or asset is absent from every accessible supplied source. | +| `mismatched` | `BLOCKED` | Workload-set component versions disagree with branch pins or the target MAUI Preview train. | +| `unknown` | `UNKNOWN` | Confirmation or trustworthy package evidence is unavailable. | + +`BLOCKED` prevents readiness; `UNKNOWN` prevents an unconditional `READY`. + +### Public-Output Safety + +Local release-captain output may contain the exact source list, generated NuGet +configuration, resolved-source diagnostics, and install command. Public-safe +serialization recursively removes: + +- additional source names and URLs +- private source metadata, including nested resolved-source objects +- generated NuGet configuration paths and content +- authentication state and credential details +- local installation commands that reference private sources + +The public report retains the state, safe package identities and versions, pin +comparison, prerequisite summary, and remediation category needed to explain +the readiness result. + ## CI Freshness A passing CI build is only meaningful if it ran **at or after** the current SR HEAD. The skill records: diff --git a/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 b/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 index 6db53fde4bd1..9f0b9f2098ae 100644 --- a/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 +++ b/.github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 @@ -76,6 +76,18 @@ When true (default), any non-READY internal status is sanitized to omit raw error/log payloads before being included in the report. +.PARAMETER ConfirmedWorkloadSetVersion + Exact workload-set CLI version confirmed by the release owner, for + example 11.0.100-preview.6.26363.2. Without this value, the script may + identify a coherent candidate but will not mark consumer installability + READY because a newer coherent package is not necessarily the blessed one. + +.PARAMETER AdditionalPackageSource + Optional authenticated package source in name=https://... form. Repeat for + multiple sources. Credentials are read from the standard NuGet environment + variable NuGetPackageSourceCredentials_; never put a PAT in this + argument, the generated report, or the repository. + .NOTES Faithfully ports the logic from the prior `.github/skills/net11-release-readiness/scripts/Get-Net11ReleaseReadiness.ps1` @@ -117,6 +129,12 @@ param( [Parameter(Mandatory = $false)] [bool]$PublicSafe = $true, + [Parameter(Mandatory = $false)] + [string]$ConfirmedWorkloadSetVersion, + + [Parameter(Mandatory = $false)] + [string[]]$AdditionalPackageSource = @(), + [Parameter(Mandatory = $false)] [int]$MaxBodyBytes = 60000 ) @@ -137,6 +155,17 @@ if (Test-Path $nightlyFeedHelperPath) { Write-Warning "NightlyFeed.ps1 helper not found at $nightlyFeedHelperPath β€” nightly-feed banner disabled." -WarningAction Continue } +# Consumer installability helpers. This is a verdict-bearing signal, so a +# missing helper becomes UNKNOWN in the main driver instead of being ignored. +$Script:PreviewInstallabilityHelperLoaded = $false +$previewInstallabilityHelperPath = Join-Path $PSScriptRoot 'PreviewInstallability.ps1' +if (Test-Path $previewInstallabilityHelperPath) { + . $previewInstallabilityHelperPath + $Script:PreviewInstallabilityHelperLoaded = $true +} else { + Write-Warning "PreviewInstallability.ps1 helper not found at $previewInstallabilityHelperPath β€” consumer installability will be UNKNOWN." -WarningAction Continue +} + # =================================================================== # BRANCH PARSING # =================================================================== @@ -1459,6 +1488,31 @@ function New-Check { } } +function New-PreviewInstallabilityFallback { + param( + [Parameter(Mandatory)][string]$Summary, + [string]$CliVersion + ) + + return [PSCustomObject]@{ + Status = 'unknown' + Summary = $Summary + SdkVersion = $null + SdkFeatureBand = $null + PackageId = $null + CliVersion = $CliVersion + NuGetVersion = $null + VersionConfirmed = -not [string]::IsNullOrWhiteSpace($CliVersion) + PinComparisons = @() + ManifestPackages = @() + PackProbes = @() + RequiredSources = @() + PlatformRequirements = $null + NuGetConfig = $null + InstallCommand = $null + } +} + function Get-OverallStatus { param([array]$Checks) @@ -1935,6 +1989,44 @@ $requiredXcode = if ($xcodeRequirements.RequiredXcode) { $xcodeRequirements.Requ $deviceXcode = if ($xcodeRequirements.DeviceTestsRequiredXcode) { $xcodeRequirements.DeviceTestsRequiredXcode } else { "unknown" } $checks += New-Check -Area "Xcode / ICM" -Status "UNKNOWN" -Details "REQUIRED_XCODE=$requiredXcode; DEVICETESTS_REQUIRED_XCODE=$deviceXcode." -NextAction "Verify hosted Mac pool support and file/update ICM immediately when public Xcode availability requires it." +# --- Consumer installability --- +# Reuse the branch pins for both this gate and the component-build section. +# A coherent but unconfirmed workload-set candidate remains UNKNOWN: the +# newest coherent package is not necessarily the release-owner-blessed build. +$componentPins = if ($surveyExists) { + Get-BranchComponentPins -Ref $SurveyRef -Major $majorVersion +} else { + $null +} +$consumerInstallability = New-PreviewInstallabilityFallback ` + -Summary 'Consumer installability could not be evaluated.' ` + -CliVersion $ConfirmedWorkloadSetVersion +if ($Script:PreviewInstallabilityHelperLoaded) { + try { + $consumerInstallability = Get-PreviewConsumerInstallability ` + -Major $majorVersion ` + -Preview $previewNumber ` + -Pins $componentPins ` + -WorkloadSetCliVersion $ConfirmedWorkloadSetVersion ` + -AdditionalPackageSource $AdditionalPackageSource ` + -PublicSafe $PublicSafe + } catch { + $warningDetail = if ($PublicSafe) { '' } else { ": $($_.Exception.Message)" } + Write-Warning "Consumer installability check failed (non-fatal)$warningDetail" -WarningAction Continue + $consumerInstallability = New-PreviewInstallabilityFallback ` + -Summary 'Consumer installability evaluation failed; no readiness claim can be made.' ` + -CliVersion $ConfirmedWorkloadSetVersion + } +} + +if ($Script:PreviewInstallabilityHelperLoaded) { + $checks += ConvertTo-PreviewInstallabilityCheck -Result $consumerInstallability +} else { + $checks += New-Check -Area 'Consumer installability' -Status 'UNKNOWN' ` + -Details $consumerInstallability.Summary ` + -NextAction 'Restore PreviewInstallability.ps1 and rerun the preview readiness report.' +} + # --- Internal release pipelines (sanitized) --- $internalStatus = "UNKNOWN" $internalDetails = "Internal dnceng pipeline details are not queried in public workflow mode." @@ -2023,6 +2115,7 @@ $report = [PSCustomObject]@{ PriorityIssues = $priorityIssues KnownBuildErrorIssues = $kbeIssues CiScanIssues = $ciScanIssues + ConsumerInstallability = $consumerInstallability NightlyFeed = $null } @@ -2198,7 +2291,6 @@ $notesBlockText = $notesSb.ToString() # preview subscriptions are both LOCAL tasks β€” the callout below points the captain # at the exact local prompt to run. Rendered OUTSIDE the human-notes markers so it # self-refreshes on every automated re-run. -$componentPins = Get-BranchComponentPins -Ref $SurveyRef -Major $majorVersion if ($componentPins) { # --- Inferred subscription health (public PR trail) --- # We can't read Maestro subscription config from CI, but a *working* sub @@ -2293,6 +2385,12 @@ if ($componentPins) { } } +if ($consumerInstallability -and + (Get-Command Format-PreviewInstallabilityMarkdown -ErrorAction SilentlyContinue)) { + [void]$md.Append((Format-PreviewInstallabilityMarkdown ` + -Result $consumerInstallability ` + -PublicSafe $PublicSafe)) +} # === BLOCKING SUMMARY (hoisted to top) === # Surface aggregate BLOCKED checks (e.g. CI red, versions.props not bumped). diff --git a/.github/skills/release-readiness/scripts/PreviewInstallability.ps1 b/.github/skills/release-readiness/scripts/PreviewInstallability.ps1 new file mode 100644 index 000000000000..ae9d8ab99bfe --- /dev/null +++ b/.github/skills/release-readiness/scripts/PreviewInstallability.ps1 @@ -0,0 +1,1191 @@ +#!/usr/bin/env pwsh +#Requires -Version 7.0 + +Set-StrictMode -Version Latest + +function Get-InstallabilityProperty { + param( + [AllowNull()]$Object, + [Parameter(Mandatory)][string]$Name + ) + + if ($null -eq $Object) { return $null } + if ($Object -is [System.Collections.IDictionary]) { + foreach ($key in $Object.Keys) { + if ([string]$key -ieq $Name) { return $Object[$key] } + } + return $null + } + if ($Object.PSObject -and $Object.PSObject.Properties[$Name]) { + return $Object.$Name + } + return $null +} + +function Get-PreviewSdkFeatureBand { + param([Parameter(Mandatory)][string]$SdkVersion) + + if ($SdkVersion -notmatch '^(?\d+)\.(?\d+)\.(?\d+)') { + throw "SDK version '$SdkVersion' does not start with major.minor.patch." + } + + $band = [int]([Math]::Floor(([int]$Matches.patch) / 100) * 100) + return "$($Matches.major).$($Matches.minor).$band" +} + +function ConvertTo-WorkloadSetNuGetVersion { + param([Parameter(Mandatory)][string]$CliVersion) + + if ($CliVersion -notmatch '^(?\d+)\.0\.(?\d+)(?-.+)?$') { + throw "Workload-set CLI version '$CliVersion' is not in '.0.[-suffix]' form." + } + + return "$($Matches.major).$($Matches.feature).0$($Matches.suffix)" +} + +function ConvertTo-WorkloadSetCliVersion { + param( + [Parameter(Mandatory)][string]$NuGetVersion, + [Parameter(Mandatory)][string]$SdkFeatureBand + ) + + if ($NuGetVersion -notmatch '^\d+\.\d+\.\d+(?-.+)?$') { + throw "Workload-set NuGet version '$NuGetVersion' is not a semantic version." + } + + return "$SdkFeatureBand$($Matches.suffix)" +} + +function ConvertFrom-PreviewPackageSourceSpec { + param( + [Parameter(Mandatory)][int]$Major, + [string[]]$AdditionalPackageSource = @() + ) + + $sources = [System.Collections.Generic.List[object]]::new() + $add = { + param($Name, $Uri, $Role, [bool]$IsAdditional) + [void]$sources.Add([PSCustomObject]@{ + Name = $Name + Uri = $Uri + Role = $Role + IsAdditional = $IsAdditional + IsInternal = ($Uri -match '^https://pkgs\.dev\.azure\.com/dnceng/internal/') + }) + } + + & $add 'dotnet-workloads' "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-workloads/nuget/v3/index.json" 'workload-set' $false + & $add "dotnet$Major-workloads" "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet$Major-workloads/nuget/v3/index.json" 'platform' $false + & $add "dotnet$Major" "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet$Major/nuget/v3/index.json" 'product' $false + & $add "dotnet$Major-transport" "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet$Major-transport/nuget/v3/index.json" 'transport' $false + & $add 'dotnet-public' "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/index.json" 'shared' $false + & $add 'nuget.org' 'https://api.nuget.org/v3/index.json' 'shared' $false + + foreach ($spec in @($AdditionalPackageSource)) { + if ([string]::IsNullOrWhiteSpace($spec)) { continue } + if ($spec -notmatch '^(?[A-Za-z0-9._-]+)=(?https://.+)$') { + throw "Additional package source '$spec' must use name=https://... syntax." + } + + $name = $Matches.name + $uriText = $Matches.uri + $uri = [Uri]$uriText + if ($uri.Scheme -ne 'https' -or + $uri.Host -notin @('pkgs.dev.azure.com', 'api.nuget.org') -or + -not [string]::IsNullOrEmpty($uri.UserInfo)) { + throw "Additional package source '$name' must be an HTTPS dnceng Azure Artifacts or NuGet.org service index without embedded credentials." + } + if ($uri.Host -eq 'pkgs.dev.azure.com' -and -not $uri.AbsolutePath.StartsWith('/dnceng/', [StringComparison]::OrdinalIgnoreCase)) { + throw "Additional Azure Artifacts source '$name' must belong to the dnceng organization." + } + if (@($sources | Where-Object { $_.Name -ieq $name }).Count -gt 0) { + throw "Package source name '$name' is already defined." + } + + & $add $name $uri.AbsoluteUri 'additional' $true + } + + return @($sources) +} + +function Get-PackageSourceHeaders { + param([Parameter(Mandatory)]$Source) + + $variableName = "NuGetPackageSourceCredentials_$($Source.Name)" + $credential = [Environment]::GetEnvironmentVariable($variableName) + if ([string]::IsNullOrWhiteSpace($credential)) { return @{} } + + $username = 'unused' + $password = $null + foreach ($part in ($credential -split ';')) { + if ($part -match '^\s*Username=(.*)$') { + $username = $Matches[1] + } elseif ($part -match '^\s*Password=(.*)$') { + $password = $Matches[1] + } + } + if ([string]::IsNullOrEmpty($password)) { return @{} } + + $bytes = [Text.Encoding]::UTF8.GetBytes("${username}:$password") + return @{ Authorization = "Basic $([Convert]::ToBase64String($bytes))" } +} + +function Get-InstallabilityHttpStatus { + param([AllowNull()]$Exception) + + if ($null -eq $Exception) { return $null } + $status = Get-InstallabilityProperty $Exception 'StatusCode' + if ($null -ne $status) { + try { return [int]$status } catch { } + } + $response = Get-InstallabilityProperty $Exception 'Response' + $status = Get-InstallabilityProperty $response 'StatusCode' + if ($null -ne $status) { + try { return [int]$status } catch { } + } + return $null +} + +function Invoke-InstallabilityJson { + param( + [Parameter(Mandatory)][string]$Url, + [Parameter(Mandatory)]$Source, + [scriptblock]$Fetcher, + [int]$TimeoutSec = 20 + ) + + if ($Fetcher) { + return & $Fetcher $Url $Source + } + + $headers = Get-PackageSourceHeaders -Source $Source + return Invoke-RestMethod -Uri $Url -Headers $headers -TimeoutSec $TimeoutSec -ErrorAction Stop +} + +function Resolve-PreviewPackageSource { + param( + [Parameter(Mandatory)]$Source, + [scriptblock]$Fetcher + ) + + try { + $index = Invoke-InstallabilityJson -Url $Source.Uri -Source $Source -Fetcher $Fetcher + $search = $null + $flat = $null + foreach ($resource in @(Get-InstallabilityProperty $index 'resources')) { + $type = [string](Get-InstallabilityProperty $resource '@type') + $id = [string](Get-InstallabilityProperty $resource '@id') + if (-not $search -and $type.StartsWith('SearchQueryService', [StringComparison]::OrdinalIgnoreCase)) { + $search = $id + } + if (-not $flat -and $type.StartsWith('PackageBaseAddress', [StringComparison]::OrdinalIgnoreCase)) { + $flat = $id + } + } + + if ([string]::IsNullOrWhiteSpace($flat)) { + return [PSCustomObject]@{ + Source = $Source + Available = $false + AuthenticationLost = $false + SearchUrl = $search + FlatUrl = $null + Reason = 'service index has no PackageBaseAddress resource' + } + } + + return [PSCustomObject]@{ + Source = $Source + Available = $true + AuthenticationLost = $false + SearchUrl = $search + FlatUrl = $flat.TrimEnd('/') + Reason = $null + } + } catch { + $status = Get-InstallabilityHttpStatus $_.Exception + return [PSCustomObject]@{ + Source = $Source + Available = $false + AuthenticationLost = ($status -in @(401, 403)) + SearchUrl = $null + FlatUrl = $null + Reason = if ($status) { "HTTP $status" } else { 'source query failed' } + } + } +} + +function Sort-PreviewNuGetVersions { + param([string[]]$Version) + + return @($Version | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Select-Object -Unique | + Sort-Object -Property @{ + Expression = { + try { [System.Management.Automation.SemanticVersion]::Parse($_) } + catch { [System.Management.Automation.SemanticVersion]::new(0, 0, 0) } + } + Descending = $true + }) +} + +function Find-PreviewWorkloadSetPackage { + param( + [Parameter(Mandatory)][array]$ResolvedSources, + [Parameter(Mandatory)][string]$SdkFeatureBand, + [Parameter(Mandatory)][int]$Preview, + [scriptblock]$Fetcher + ) + + $query = "Microsoft.NET.Workloads.$SdkFeatureBand-preview.$Preview" + $exactId = "^Microsoft\.NET\.Workloads\.$([regex]::Escape($SdkFeatureBand))-preview\.$Preview$" + $candidates = [System.Collections.Generic.List[object]]::new() + + foreach ($resolved in @($ResolvedSources | Where-Object { $_.Available -and $_.SearchUrl })) { + try { + $separator = if ($resolved.SearchUrl.Contains('?')) { '&' } else { '?' } + $url = "$($resolved.SearchUrl.TrimEnd('/'))/$separator" + + "q=$([Uri]::EscapeDataString($query))&prerelease=true&semVerLevel=2.0.0&take=100" + $search = Invoke-InstallabilityJson -Url $url -Source $resolved.Source -Fetcher $Fetcher + foreach ($package in @(Get-InstallabilityProperty $search 'data')) { + $id = [string](Get-InstallabilityProperty $package 'id') + if ($id -notmatch $exactId -or $id -match '\.Msi\.') { continue } + + $versions = [System.Collections.Generic.List[string]]::new() + foreach ($versionEntry in @(Get-InstallabilityProperty $package 'versions')) { + $value = [string](Get-InstallabilityProperty $versionEntry 'version') + if ($value -match "-preview\.$Preview(?:\.|$)") { + [void]$versions.Add($value) + } + } + $latest = [string](Get-InstallabilityProperty $package 'version') + if ($latest -match "-preview\.$Preview(?:\.|$)") { + [void]$versions.Add($latest) + } + + [void]$candidates.Add([PSCustomObject]@{ + Id = $id + Source = $resolved + Versions = @(Sort-PreviewNuGetVersions -Version $versions) + }) + } + } catch { + continue + } + } + + if ($candidates.Count -eq 0) { return $null } + + # Prefer the dedicated workload-set feed over an upstream copy exposed by + # another feed, then prefer the candidate carrying the most versions. + return @($candidates | Sort-Object -Property @( + @{ Expression = { if ($_.Source.Source.Role -eq 'workload-set') { 0 } else { 1 } }; Descending = $false }, + @{ Expression = { $_.Versions.Count }; Descending = $true } + ))[0] +} + +function Read-PreviewNuGetPackageEntries { + param( + [Parameter(Mandatory)]$ResolvedSource, + [Parameter(Mandatory)][string]$PackageId, + [Parameter(Mandatory)][string]$Version, + [Parameter(Mandatory)][string[]]$EntryName, + [scriptblock]$Downloader + ) + + $id = $PackageId.ToLowerInvariant() + $versionLower = $Version.ToLowerInvariant() + $url = "$($ResolvedSource.FlatUrl)/$id/$versionLower/$id.$versionLower.nupkg" + $tempFile = [IO.Path]::Combine([IO.Path]::GetTempPath(), "$([Guid]::NewGuid()).nupkg") + $zip = $null + + try { + if ($Downloader) { + & $Downloader $url $tempFile $ResolvedSource.Source + } else { + $headers = Get-PackageSourceHeaders -Source $ResolvedSource.Source + Invoke-WebRequest -Uri $url -Headers $headers -OutFile $tempFile -TimeoutSec 60 -ErrorAction Stop + } + + $zip = [IO.Compression.ZipFile]::OpenRead($tempFile) + $result = [ordered]@{} + foreach ($wanted in $EntryName) { + $entry = @($zip.Entries | Where-Object { $_.FullName -ieq $wanted }) | Select-Object -First 1 + if (-not $entry) { + $result[$wanted] = $null + continue + } + + $reader = [IO.StreamReader]::new($entry.Open()) + try { + $result[$wanted] = $reader.ReadToEnd() | ConvertFrom-Json -AsHashtable + } finally { + $reader.Dispose() + } + } + return $result + } finally { + if ($zip) { $zip.Dispose() } + if (Test-Path -LiteralPath $tempFile) { + Remove-Item -LiteralPath $tempFile -Force + } + } +} + +function Get-WorkloadSetManifestVersion { + param( + [Parameter(Mandatory)]$Manifest, + [Parameter(Mandatory)][string]$WorkloadId + ) + + $raw = [string](Get-InstallabilityProperty $Manifest $WorkloadId) + if ([string]::IsNullOrWhiteSpace($raw)) { + return [PSCustomObject]@{ Version = $null; SdkBand = $null; Raw = $null } + } + + $parts = $raw -split '/', 2 + return [PSCustomObject]@{ + Version = $parts[0] + SdkBand = if ($parts.Count -gt 1) { $parts[1] } else { $null } + Raw = $raw + } +} + +function Compare-PreviewWorkloadSetPins { + param( + [Parameter(Mandatory)]$Manifest, + [AllowNull()]$Pins, + [Parameter(Mandatory)][int]$Major, + [Parameter(Mandatory)][int]$Preview, + [string]$ExpectedMauiManifestVersion + ) + + $android = Get-InstallabilityProperty (Get-InstallabilityProperty $Pins 'Android') 'Version' + $macios = Get-InstallabilityProperty (Get-InstallabilityProperty $Pins 'Macios') 'Version' + $vmr = Get-InstallabilityProperty (Get-InstallabilityProperty $Pins 'Vmr') 'Version' + $mauiPattern = '^' + [regex]::Escape("$Major.0.0-preview.$Preview") + '(?:\.|$)' + $expectations = @( + @{ Id = 'Microsoft.NET.Sdk.Android'; Expected = [string]$android; Pattern = $null }, + @{ Id = 'Microsoft.NET.Sdk.iOS'; Expected = [string]$macios; Pattern = $null }, + @{ Id = 'Microsoft.NET.Sdk.MacCatalyst'; Expected = [string]$macios; Pattern = $null }, + @{ Id = 'Microsoft.NET.Sdk.macOS'; Expected = [string]$macios; Pattern = $null }, + @{ Id = 'Microsoft.NET.Sdk.tvOS'; Expected = [string]$macios; Pattern = $null }, + @{ Id = 'Microsoft.NET.Workload.Mono.ToolChain.Current'; Expected = [string]$vmr; Pattern = $null }, + @{ Id = 'Microsoft.NET.Workload.Emscripten.Current'; Expected = [string]$vmr; Pattern = $null }, + @{ + Id = 'Microsoft.NET.Sdk.Maui' + Expected = $ExpectedMauiManifestVersion + Pattern = if ([string]::IsNullOrWhiteSpace($ExpectedMauiManifestVersion)) { + $mauiPattern + } else { $null } + } + ) + + $comparisons = [System.Collections.Generic.List[object]]::new() + foreach ($expectation in $expectations) { + $actual = (Get-WorkloadSetManifestVersion -Manifest $Manifest -WorkloadId $expectation.Id).Version + $status = 'unverified' + if ([string]::IsNullOrWhiteSpace($actual)) { + $status = 'missing' + } elseif (-not [string]::IsNullOrWhiteSpace($expectation.Expected)) { + $status = if ($actual -eq $expectation.Expected) { 'match' } else { 'mismatch' } + } elseif ($expectation.Pattern) { + $status = if ($actual -match $expectation.Pattern) { 'match' } else { 'mismatch' } + } + + [void]$comparisons.Add([PSCustomObject]@{ + WorkloadId = $expectation.Id + Expected = if ($expectation.Expected) { $expectation.Expected } else { "preview.$Preview build" } + Actual = $actual + Status = $status + }) + } + + return @($comparisons) +} + +function Get-PreviewManifestPackageRequests { + param([Parameter(Mandatory)]$Manifest) + + $requiredWorkloads = @( + 'Microsoft.NET.Sdk.Android', + 'Microsoft.NET.Sdk.iOS', + 'Microsoft.NET.Sdk.MacCatalyst', + 'Microsoft.NET.Sdk.macOS', + 'Microsoft.NET.Sdk.Maui', + 'Microsoft.NET.Workload.Mono.ToolChain.Current' + ) + + $requests = [System.Collections.Generic.List[object]]::new() + $seen = @{} + foreach ($workloadId in $requiredWorkloads) { + $value = Get-WorkloadSetManifestVersion -Manifest $Manifest -WorkloadId $workloadId + if ([string]::IsNullOrWhiteSpace($value.Version) -or [string]::IsNullOrWhiteSpace($value.SdkBand)) { + continue + } + + $packageId = "$workloadId.Manifest-$($value.SdkBand)" + $key = "$($packageId.ToLowerInvariant())|$($value.Version.ToLowerInvariant())" + if ($seen.ContainsKey($key)) { continue } + $seen[$key] = $true + [void]$requests.Add([PSCustomObject]@{ + WorkloadId = $workloadId + PackageId = $packageId + Version = $value.Version + }) + } + return @($requests) +} + +function Get-PreviewSourceOrder { + param( + [Parameter(Mandatory)][array]$ResolvedSources, + [Parameter(Mandatory)][string]$PackageId + ) + + $id = $PackageId.ToLowerInvariant() + $roleOrder = if ($id -match '^microsoft\.net\.workloads\.') { + @('workload-set', 'additional', 'product', 'platform', 'transport', 'shared') + } elseif ($id -match '^microsoft\.net\.sdk\.android\.manifest-' -or $id -match '^microsoft\.android\.') { + @('platform', 'additional', 'product', 'transport', 'shared') + } elseif ($id -match '^microsoft\.netcore\.app\.runtime\.' -or $id -match '^microsoft\.net\.runtime\.') { + @('additional', 'transport', 'product', 'platform', 'shared') + } elseif ($id -match '^microsoft\.(ios|maccatalyst|macos|tvos)\.') { + @('product', 'additional', 'platform', 'transport', 'shared') + } elseif ($id -match '^microsoft\.net\..+\.manifest-' -or $id -match '^microsoft\.maui\.') { + @('product', 'additional', 'platform', 'transport', 'shared') + } else { + @('additional', 'product', 'platform', 'transport', 'shared') + } + + return @( + $ResolvedSources | + Where-Object { $_.Source.Role -in $roleOrder } | + Sort-Object -Property @{ + Expression = { [Array]::IndexOf($roleOrder, $_.Source.Role) } + Descending = $false + } + ) +} + +function Find-PreviewPackageLocation { + param( + [Parameter(Mandatory)][array]$ResolvedSources, + [Parameter(Mandatory)][string]$PackageId, + [Parameter(Mandatory)][string]$Version, + [scriptblock]$Fetcher + ) + + $unknownSources = [System.Collections.Generic.List[string]]::new() + foreach ($resolved in @(Get-PreviewSourceOrder -ResolvedSources $ResolvedSources -PackageId $PackageId)) { + if (-not $resolved.Available -or [string]::IsNullOrWhiteSpace($resolved.FlatUrl)) { + if ($resolved.AuthenticationLost -or $resolved.Source.IsAdditional) { + [void]$unknownSources.Add($resolved.Source.Name) + } + continue + } + + $id = $PackageId.ToLowerInvariant() + try { + $index = Invoke-InstallabilityJson -Url "$($resolved.FlatUrl)/$id/index.json" -Source $resolved.Source -Fetcher $Fetcher + $versions = @(Get-InstallabilityProperty $index 'versions') + if (@($versions | Where-Object { [string]$_ -ieq $Version }).Count -gt 0) { + return [PSCustomObject]@{ + Status = 'found' + PackageId = $PackageId + Version = $Version + ResolvedSource = $resolved + UnknownSources = @($unknownSources) + } + } + } catch { + $status = Get-InstallabilityHttpStatus $_.Exception + if ($status -in @(401, 403) -or $resolved.Source.IsAdditional) { + [void]$unknownSources.Add($resolved.Source.Name) + } elseif ($status -ne 404) { + [void]$unknownSources.Add($resolved.Source.Name) + } + } + } + + return [PSCustomObject]@{ + Status = if ($unknownSources.Count -gt 0) { 'unknown' } else { 'missing' } + PackageId = $PackageId + Version = $Version + ResolvedSource = $null + UnknownSources = @($unknownSources | Select-Object -Unique) + } +} + +function Get-PreviewPlatformRequirements { + param([array]$ManifestEvidence) + + $requirements = [ordered]@{ + Jdk = $null + AndroidSdk = @() + Xcode = $null + AppleSdk = $null + WindowsAppSdk = $null + } + + foreach ($evidence in @($ManifestEvidence)) { + $dependencies = Get-InstallabilityProperty $evidence 'Dependencies' + if ($null -eq $dependencies) { continue } + + $android = Get-InstallabilityProperty $dependencies 'microsoft.net.sdk.android' + if ($android) { + $jdk = Get-InstallabilityProperty $android 'jdk' + if ($jdk) { + $requirements.Jdk = [PSCustomObject]@{ + Version = [string](Get-InstallabilityProperty $jdk 'version') + RecommendedVersion = [string](Get-InstallabilityProperty $jdk 'recommendedVersion') + } + } + + $androidSdk = Get-InstallabilityProperty $android 'androidsdk' + $packages = [System.Collections.Generic.List[string]]::new() + foreach ($package in @(Get-InstallabilityProperty $androidSdk 'packages')) { + $sdkPackage = Get-InstallabilityProperty $package 'sdkPackage' + $id = Get-InstallabilityProperty $sdkPackage 'id' + if ($id -is [string]) { + [void]$packages.Add($id) + } + } + $requirements.AndroidSdk = @($packages) + } + + $ios = Get-InstallabilityProperty $dependencies 'microsoft.net.sdk.ios' + if ($ios) { + $xcode = Get-InstallabilityProperty $ios 'xcode' + if ($xcode) { + $requirements.Xcode = [PSCustomObject]@{ + Version = [string](Get-InstallabilityProperty $xcode 'version') + RecommendedVersion = [string](Get-InstallabilityProperty $xcode 'recommendedVersion') + } + } + $sdk = Get-InstallabilityProperty $ios 'sdk' + if ($sdk) { + $requirements.AppleSdk = [string](Get-InstallabilityProperty $sdk 'version') + } + } + + $maui = Get-InstallabilityProperty $dependencies 'microsoft.net.sdk.maui' + if ($maui) { + $windows = Get-InstallabilityProperty $maui 'windowsAppSdk' + if ($windows) { + $requirements.WindowsAppSdk = [string](Get-InstallabilityProperty $windows 'recommendedVersion') + } + } + } + + return [PSCustomObject]$requirements +} + +function Get-PreviewRepresentativePackRequests { + param( + [array]$ManifestEvidence, + [Parameter(Mandatory)][int]$Major + ) + + $requests = [System.Collections.Generic.List[object]]::new() + $representatives = @( + @{ Category = 'android-sdk'; Pattern = "^Microsoft\.Android\.Sdk\.net$Major$" }, + @{ Category = 'ios-sdk'; Pattern = "^Microsoft\.iOS\.Sdk\.net$Major\.0_" }, + @{ Category = 'maccatalyst-sdk'; Pattern = "^Microsoft\.MacCatalyst\.Sdk\.net$Major\.0_" }, + @{ Category = 'android-runtime'; Pattern = "^Microsoft\.NETCore\.App\.Runtime\.Mono\.net$Major\.android-arm64$" }, + @{ Category = 'ios-runtime'; Pattern = "^Microsoft\.NETCore\.App\.Runtime\.Mono\.net$Major\.ios-arm64$" }, + @{ Category = 'maccatalyst-runtime'; Pattern = "^Microsoft\.NETCore\.App\.Runtime\.Mono\.net$Major\.maccatalyst-arm64$" }, + @{ Category = 'maui-controls'; Pattern = '^Microsoft\.Maui\.Controls$' } + ) + $seen = @{} + + foreach ($evidence in @($ManifestEvidence)) { + $manifest = Get-InstallabilityProperty $evidence 'Manifest' + $packs = Get-InstallabilityProperty $manifest 'packs' + if ($null -eq $packs) { continue } + + $entries = if ($packs -is [System.Collections.IDictionary]) { + @($packs.GetEnumerator() | ForEach-Object { + [PSCustomObject]@{ Name = [string]$_.Key; Value = $_.Value } + }) + } else { + @($packs.PSObject.Properties | ForEach-Object { + [PSCustomObject]@{ Name = $_.Name; Value = $_.Value } + }) + } + + foreach ($entry in $entries) { + $representative = $representatives | + Where-Object { $entry.Name -match $_.Pattern } | + Select-Object -First 1 + if (-not $representative) { continue } + $version = [string](Get-InstallabilityProperty $entry.Value 'version') + if ([string]::IsNullOrWhiteSpace($version)) { continue } + $key = "$($entry.Name.ToLowerInvariant())|$($version.ToLowerInvariant())" + if ($seen.ContainsKey($key)) { continue } + $seen[$key] = $true + [void]$requests.Add([PSCustomObject]@{ + Category = $representative.Category + PackageId = $entry.Name + Version = $version + }) + } + } + + foreach ($representative in $representatives) { + if (@($requests | Where-Object { $_.Category -eq $representative.Category }).Count -eq 0) { + [void]$requests.Add([PSCustomObject]@{ + Category = $representative.Category + PackageId = $null + Version = $null + }) + } + } + + return @($requests) +} + +function ConvertTo-IsolatedNuGetConfig { + param([Parameter(Mandatory)][array]$Sources) + + $builder = [Text.StringBuilder]::new() + [void]$builder.AppendLine('') + [void]$builder.AppendLine('') + [void]$builder.AppendLine(' ') + [void]$builder.AppendLine(' ') + foreach ($source in @($Sources | Sort-Object -Property Name -Unique)) { + $name = [Security.SecurityElement]::Escape([string]$source.Name) + $uri = [Security.SecurityElement]::Escape([string]$source.Uri) + [void]$builder.AppendLine(" ") + } + [void]$builder.AppendLine(' ') + [void]$builder.AppendLine('') + return $builder.ToString() +} + +function ConvertTo-PublicInstallabilityResult { + param([Parameter(Mandatory)]$Result) + + $sensitiveSourceNames = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($source in @($Result.RequiredSources)) { + if ([bool](Get-InstallabilityProperty $source 'IsAdditional') -or + [bool](Get-InstallabilityProperty $source 'IsInternal')) { + [void]$sensitiveSourceNames.Add([string](Get-InstallabilityProperty $source 'Name')) + } + } + + $sanitizeLocation = { + param($Location) + + $resolvedSource = Get-InstallabilityProperty $Location 'ResolvedSource' + $source = Get-InstallabilityProperty $resolvedSource 'Source' + $sourceName = [string](Get-InstallabilityProperty $source 'Name') + $isSensitive = [bool](Get-InstallabilityProperty $source 'IsAdditional') -or + [bool](Get-InstallabilityProperty $source 'IsInternal') + $unknownSources = @( + @(Get-InstallabilityProperty $Location 'UnknownSources') | + ForEach-Object { + if ($sensitiveSourceNames.Contains([string]$_)) { 'authenticated-source' } else { [string]$_ } + } | + Select-Object -Unique + ) + + return [PSCustomObject]@{ + Category = Get-InstallabilityProperty $Location 'Category' + WorkloadId = Get-InstallabilityProperty $Location 'WorkloadId' + PackageId = Get-InstallabilityProperty $Location 'PackageId' + Version = Get-InstallabilityProperty $Location 'Version' + Status = Get-InstallabilityProperty $Location 'Status' + ContentStatus = Get-InstallabilityProperty $Location 'ContentStatus' + Reason = Get-InstallabilityProperty $Location 'Reason' + SourceName = if ($isSensitive) { 'authenticated-source' } else { $sourceName } + SourceRole = if ($isSensitive) { 'additional' } else { Get-InstallabilityProperty $source 'Role' } + UnknownSources = $unknownSources + } + } + + $copy = [ordered]@{} + foreach ($property in $Result.PSObject.Properties) { + $copy[$property.Name] = $property.Value + } + + $copy.NuGetConfig = $null + $copy.InstallCommand = if ($Result.CliVersion) { + "dotnet workload install maui --version $($Result.CliVersion) --configfile " + } else { $null } + $copy.ManifestPackages = @($Result.ManifestPackages | ForEach-Object { & $sanitizeLocation $_ }) + $copy.PackProbes = @($Result.PackProbes | ForEach-Object { & $sanitizeLocation $_ }) + $copy.RequiredSources = @( + $Result.RequiredSources | ForEach-Object { + if ($_.IsAdditional -or $_.IsInternal) { + [PSCustomObject]@{ + Name = 'authenticated-source' + Role = 'additional' + Uri = $null + IsInternal = $true + } + } else { + [PSCustomObject]@{ + Name = $_.Name + Role = $_.Role + Uri = $_.Uri + IsInternal = $false + } + } + } | Sort-Object -Property Name -Unique + ) + return [PSCustomObject]$copy +} + +function Complete-PreviewInstallabilityResult { + param( + [Parameter(Mandatory)]$Result, + [bool]$PublicSafe + ) + + if ($PublicSafe) { + return ConvertTo-PublicInstallabilityResult -Result $Result + } + return $Result +} + +function ConvertTo-PreviewInstallabilityCheck { + param([Parameter(Mandatory)]$Result) + + $status = switch ($Result.Status) { + 'installable' { 'READY' } + 'missing' { 'BLOCKED' } + 'mismatched' { 'BLOCKED' } + default { 'UNKNOWN' } + } + $nextAction = switch ($Result.Status) { + 'installable' { 'No installability action needed.' } + 'missing' { 'Publish or add the missing workload assets, then rerun this check with an isolated package-source configuration.' } + 'mismatched' { 'Align the workload set with the branch SDK, Android, Apple, and runtime pins and the target MAUI Preview train before shipping.' } + default { + if (-not $Result.VersionConfirmed) { + 'Supply the confirmed workload-set CLI version and any required authenticated package source, then rerun locally.' + } else { + 'Authenticate the required package sources and rerun; do not treat an inaccessible source as proof that a package is missing.' + } + } + } + + return [PSCustomObject]@{ + Area = 'Consumer installability' + Status = $status + Details = $Result.Summary + NextAction = $nextAction + } +} + +function Get-PreviewConsumerInstallability { + param( + [Parameter(Mandatory)][int]$Major, + [Parameter(Mandatory)][int]$Preview, + [AllowNull()]$Pins, + [string]$WorkloadSetCliVersion, + [string]$ExpectedMauiManifestVersion, + [string[]]$AdditionalPackageSource = @(), + [bool]$PublicSafe = $true, + [scriptblock]$Fetcher, + [scriptblock]$PackageReader + ) + + $sdkVersion = [string](Get-InstallabilityProperty (Get-InstallabilityProperty $Pins 'Vmr') 'Version') + if ([string]::IsNullOrWhiteSpace($sdkVersion)) { + $result = [PSCustomObject]@{ + Status = 'unknown'; Summary = 'The branch SDK pin could not be resolved.' + SdkVersion = $null; SdkFeatureBand = $null; PackageId = $null + CliVersion = $null; NuGetVersion = $null; VersionConfirmed = $false + PinComparisons = @(); ManifestPackages = @(); PackProbes = @() + RequiredSources = @(); PlatformRequirements = $null + NuGetConfig = $null; InstallCommand = $null + } + return Complete-PreviewInstallabilityResult -Result $result -PublicSafe $PublicSafe + } + + $sources = ConvertFrom-PreviewPackageSourceSpec -Major $Major -AdditionalPackageSource $AdditionalPackageSource + $resolvedSources = @($sources | ForEach-Object { + Resolve-PreviewPackageSource -Source $_ -Fetcher $Fetcher + }) + + $featureBand = Get-PreviewSdkFeatureBand -SdkVersion $sdkVersion + $packageId = "Microsoft.NET.Workloads.$featureBand-preview.$Preview" + $requestedNuGetVersion = $null + $versionMismatch = $null + if ($WorkloadSetCliVersion) { + try { + $requestedFeatureBand = Get-PreviewSdkFeatureBand -SdkVersion $WorkloadSetCliVersion + $requestedNuGetVersion = ConvertTo-WorkloadSetNuGetVersion -CliVersion $WorkloadSetCliVersion + if ($requestedFeatureBand -ne $featureBand) { + $versionMismatch = "The confirmed workload-set feature band $requestedFeatureBand does not match branch SDK band $featureBand." + } elseif ($WorkloadSetCliVersion -notmatch "-preview\.$Preview(?:\.|$)") { + $versionMismatch = "The confirmed workload-set version does not target preview $Preview." + } + } catch { + $versionMismatch = 'The confirmed workload-set CLI version is not valid.' + } + } + + if ($versionMismatch) { + $result = [PSCustomObject]@{ + Status = 'mismatched' + Summary = $versionMismatch + SdkVersion = $sdkVersion; SdkFeatureBand = $featureBand; PackageId = $packageId + CliVersion = $WorkloadSetCliVersion; NuGetVersion = $requestedNuGetVersion; VersionConfirmed = $true + PinComparisons = @(); ManifestPackages = @(); PackProbes = @() + RequiredSources = @($sources); PlatformRequirements = $null + NuGetConfig = $null; InstallCommand = $null + } + return Complete-PreviewInstallabilityResult -Result $result -PublicSafe $PublicSafe + } + + $package = $null + if ($requestedNuGetVersion) { + $location = Find-PreviewPackageLocation -ResolvedSources $resolvedSources ` + -PackageId $packageId -Version $requestedNuGetVersion -Fetcher $Fetcher + if ($location.Status -ne 'found') { + $result = [PSCustomObject]@{ + Status = $location.Status + Summary = if ($location.Status -eq 'missing') { + "The confirmed workload-set version $WorkloadSetCliVersion is not available from the supplied sources." + } else { + "Availability of the confirmed workload-set version $WorkloadSetCliVersion could not be established." + } + SdkVersion = $sdkVersion; SdkFeatureBand = $featureBand; PackageId = $packageId + CliVersion = $WorkloadSetCliVersion; NuGetVersion = $requestedNuGetVersion; VersionConfirmed = $true + PinComparisons = @(); ManifestPackages = @(); PackProbes = @() + RequiredSources = @($sources); PlatformRequirements = $null + NuGetConfig = $null; InstallCommand = $null + } + return Complete-PreviewInstallabilityResult -Result $result -PublicSafe $PublicSafe + } + + $package = [PSCustomObject]@{ + Id = $packageId + Source = $location.ResolvedSource + Versions = @($requestedNuGetVersion) + } + } else { + $package = Find-PreviewWorkloadSetPackage -ResolvedSources $resolvedSources ` + -SdkFeatureBand $featureBand -Preview $Preview -Fetcher $Fetcher + if (-not $package) { + $result = [PSCustomObject]@{ + Status = 'unknown' + Summary = "No workload-set candidate was discovered for SDK band $featureBand preview $Preview, and no release-owner-confirmed version was supplied." + SdkVersion = $sdkVersion; SdkFeatureBand = $featureBand; PackageId = $packageId + CliVersion = $null; NuGetVersion = $null; VersionConfirmed = $false + PinComparisons = @(); ManifestPackages = @(); PackProbes = @() + RequiredSources = @($sources); PlatformRequirements = $null + NuGetConfig = $null; InstallCommand = $null + } + return Complete-PreviewInstallabilityResult -Result $result -PublicSafe $PublicSafe + } + } + + $versions = @(Sort-PreviewNuGetVersions -Version $package.Versions) + $candidateVersions = if ($requestedNuGetVersion) { @($requestedNuGetVersion) } else { @($versions | Select-Object -First 20) } + $selectedVersion = $null + $selectedManifest = $null + $selectedComparisons = @() + $lastComparisons = @() + foreach ($version in $candidateVersions) { + try { + $entries = if ($PackageReader) { + & $PackageReader $package.Source $package.Id $version @('data/microsoft.net.workloads.workloadset.json') + } else { + Read-PreviewNuGetPackageEntries -ResolvedSource $package.Source -PackageId $package.Id -Version $version ` + -EntryName @('data/microsoft.net.workloads.workloadset.json') + } + $manifest = Get-InstallabilityProperty $entries 'data/microsoft.net.workloads.workloadset.json' + if (-not $manifest) { continue } + $comparisons = @(Compare-PreviewWorkloadSetPins -Manifest $manifest -Pins $Pins -Major $Major -Preview $Preview ` + -ExpectedMauiManifestVersion $ExpectedMauiManifestVersion) + $lastComparisons = $comparisons + if (@($comparisons | Where-Object { $_.Status -in @('mismatch', 'missing') }).Count -eq 0) { + $selectedVersion = $version + $selectedManifest = $manifest + $selectedComparisons = $comparisons + break + } + } catch { + continue + } + } + + if (-not $selectedManifest) { + $status = if ($lastComparisons.Count -gt 0) { 'mismatched' } else { 'unknown' } + $result = [PSCustomObject]@{ + Status = $status + Summary = if ($status -eq 'mismatched') { + 'Available workload-set candidates do not match the branch component pins.' + } else { + 'The workload-set package was found, but its manifest could not be read.' + } + SdkVersion = $sdkVersion; SdkFeatureBand = $featureBand; PackageId = $package.Id + CliVersion = $WorkloadSetCliVersion; NuGetVersion = $requestedNuGetVersion; VersionConfirmed = [bool]$WorkloadSetCliVersion + PinComparisons = $lastComparisons; ManifestPackages = @(); PackProbes = @() + RequiredSources = @($package.Source.Source); PlatformRequirements = $null + NuGetConfig = $null; InstallCommand = $null + } + return Complete-PreviewInstallabilityResult -Result $result -PublicSafe $PublicSafe + } + + $selectedCliVersion = ConvertTo-WorkloadSetCliVersion -NuGetVersion $selectedVersion -SdkFeatureBand $featureBand + $manifestRequests = @(Get-PreviewManifestPackageRequests -Manifest $selectedManifest) + $manifestLocations = [System.Collections.Generic.List[object]]::new() + foreach ($request in $manifestRequests) { + $location = Find-PreviewPackageLocation -ResolvedSources $resolvedSources -PackageId $request.PackageId ` + -Version $request.Version -Fetcher $Fetcher + [void]$manifestLocations.Add([PSCustomObject]@{ + WorkloadId = $request.WorkloadId + PackageId = $request.PackageId + Version = $request.Version + Status = $location.Status + ContentStatus = if ($location.Status -eq 'found') { 'pending' } else { $null } + ResolvedSource = $location.ResolvedSource + UnknownSources = $location.UnknownSources + }) + } + + $manifestEvidence = [System.Collections.Generic.List[object]]::new() + foreach ($location in @($manifestLocations | Where-Object { $_.Status -eq 'found' })) { + try { + $entries = if ($PackageReader) { + & $PackageReader $location.ResolvedSource $location.PackageId $location.Version ` + @('data/WorkloadDependencies.json', 'data/WorkloadManifest.json') + } else { + Read-PreviewNuGetPackageEntries -ResolvedSource $location.ResolvedSource -PackageId $location.PackageId ` + -Version $location.Version -EntryName @('data/WorkloadDependencies.json', 'data/WorkloadManifest.json') + } + $manifestContent = Get-InstallabilityProperty $entries 'data/WorkloadManifest.json' + $location.ContentStatus = if ($manifestContent) { 'read' } else { 'unknown' } + [void]$manifestEvidence.Add([PSCustomObject]@{ + WorkloadId = $location.WorkloadId + Dependencies = Get-InstallabilityProperty $entries 'data/WorkloadDependencies.json' + Manifest = $manifestContent + }) + } catch { + $location.ContentStatus = 'unknown' + [void]$manifestEvidence.Add([PSCustomObject]@{ + WorkloadId = $location.WorkloadId + Dependencies = $null + Manifest = $null + }) + } + } + + $packRequests = @(Get-PreviewRepresentativePackRequests -ManifestEvidence $manifestEvidence -Major $Major) + $packLocations = [System.Collections.Generic.List[object]]::new() + foreach ($request in $packRequests) { + if ([string]::IsNullOrWhiteSpace($request.PackageId) -or [string]::IsNullOrWhiteSpace($request.Version)) { + [void]$packLocations.Add([PSCustomObject]@{ + Category = $request.Category + PackageId = $null + Version = $null + Status = 'unknown' + Reason = "The $($request.Category) representative pack could not be derived from the component manifests." + ResolvedSource = $null + UnknownSources = @() + }) + continue + } + + $location = Find-PreviewPackageLocation -ResolvedSources $resolvedSources -PackageId $request.PackageId ` + -Version $request.Version -Fetcher $Fetcher + [void]$packLocations.Add([PSCustomObject]@{ + Category = $request.Category + PackageId = $request.PackageId + Version = $request.Version + Status = $location.Status + Reason = $null + ResolvedSource = $location.ResolvedSource + UnknownSources = $location.UnknownSources + }) + } + + $allLocations = @($manifestLocations) + @($packLocations) + $missing = @($allLocations | Where-Object { $_.Status -eq 'missing' }) + $unknown = @($allLocations | Where-Object { $_.Status -eq 'unknown' }) + $unreadableManifests = @($manifestLocations | Where-Object { + $_.Status -eq 'found' -and $_.ContentStatus -ne 'read' + }) + $status = if ($missing.Count -gt 0) { + 'missing' + } elseif ($unknown.Count -gt 0 -or $unreadableManifests.Count -gt 0) { + 'unknown' + } elseif (-not $WorkloadSetCliVersion) { + 'unknown' + } else { + 'installable' + } + + $requiredSources = [System.Collections.Generic.List[object]]::new() + [void]$requiredSources.Add($package.Source.Source) + foreach ($location in @($allLocations | Where-Object { $_.Status -eq 'found' })) { + [void]$requiredSources.Add($location.ResolvedSource.Source) + } + foreach ($baseline in @($sources | Where-Object { $_.Role -eq 'shared' })) { + [void]$requiredSources.Add($baseline) + } + if ($status -ne 'installable') { + foreach ($additional in @($sources | Where-Object { $_.IsAdditional })) { + [void]$requiredSources.Add($additional) + } + } + $requiredSources = @($requiredSources | Sort-Object -Property Name -Unique) + + $config = ConvertTo-IsolatedNuGetConfig -Sources $requiredSources + $command = "dotnet workload install maui --version $selectedCliVersion --configfile ./preview-nuget.config" + $summary = switch ($status) { + 'installable' { 'The confirmed workload set matches branch pins and its required manifest and representative pack assets are resolvable.' } + 'missing' { "$($missing.Count) required manifest or representative pack asset(s) were not found in the supplied sources." } + 'unknown' { + if (-not $WorkloadSetCliVersion) { + 'A coherent public workload-set candidate was found, but its official CLI version was not supplied and full source availability could not be confirmed.' + } else { + "$($unknown.Count + $unreadableManifests.Count) required asset location or manifest content check(s) could not be confirmed." + } + } + } + + $result = [PSCustomObject]@{ + Status = $status + Summary = $summary + SdkVersion = $sdkVersion + SdkFeatureBand = $featureBand + PackageId = $package.Id + CliVersion = $selectedCliVersion + NuGetVersion = $selectedVersion + VersionConfirmed = [bool]$WorkloadSetCliVersion + PinComparisons = $selectedComparisons + ManifestPackages = @($manifestLocations) + PackProbes = @($packLocations) + RequiredSources = $requiredSources + PlatformRequirements = Get-PreviewPlatformRequirements -ManifestEvidence $manifestEvidence + NuGetConfig = $config + InstallCommand = $command + } + + return Complete-PreviewInstallabilityResult -Result $result -PublicSafe $PublicSafe +} + +function Format-PreviewInstallabilityMarkdown { + param( + [Parameter(Mandatory)]$Result, + [bool]$PublicSafe = $true + ) + + $builder = [Text.StringBuilder]::new() + [void]$builder.AppendLine('## Consumer installability') + [void]$builder.AppendLine('') + [void]$builder.AppendLine("**Status:** **$($Result.Status)** - $($Result.Summary)") + [void]$builder.AppendLine('') + if ($Result.PackageId) { + [void]$builder.AppendLine("| Evidence | Value |") + [void]$builder.AppendLine("|----------|-------|") + [void]$builder.AppendLine("| SDK | ``$($Result.SdkVersion)`` |") + [void]$builder.AppendLine("| Workload-set package | ``$($Result.PackageId)`` |") + [void]$builder.AppendLine("| CLI version | ``$($Result.CliVersion)`` |") + [void]$builder.AppendLine("| NuGet package version | ``$($Result.NuGetVersion)`` |") + [void]$builder.AppendLine("| Version source | $(if ($Result.VersionConfirmed) { 'release evidence supplied to the script' } else { 'latest coherent public candidate; official confirmation still required' }) |") + [void]$builder.AppendLine('') + } + + if (@($Result.PinComparisons).Count -gt 0) { + [void]$builder.AppendLine("| Component | Expected | Workload set | Result |") + [void]$builder.AppendLine("|-----------|----------|--------------|--------|") + foreach ($comparison in $Result.PinComparisons) { + [void]$builder.AppendLine("| ``$($comparison.WorkloadId)`` | ``$($comparison.Expected)`` | ``$($comparison.Actual)`` | **$($comparison.Status)** |") + } + [void]$builder.AppendLine('') + } + + if (@($Result.ManifestPackages).Count -gt 0) { + [void]$builder.AppendLine("| Manifest package | Version | Availability | Content | Source |") + [void]$builder.AppendLine("|------------------|---------|--------------|---------|--------|") + foreach ($manifestPackage in $Result.ManifestPackages) { + $sourceName = Get-InstallabilityProperty $manifestPackage 'SourceName' + if ([string]::IsNullOrWhiteSpace([string]$sourceName)) { + $resolved = Get-InstallabilityProperty $manifestPackage 'ResolvedSource' + $sourceName = Get-InstallabilityProperty (Get-InstallabilityProperty $resolved 'Source') 'Name' + } + if ([string]::IsNullOrWhiteSpace([string]$sourceName)) { $sourceName = 'β€”' } + $contentStatus = Get-InstallabilityProperty $manifestPackage 'ContentStatus' + if ([string]::IsNullOrWhiteSpace([string]$contentStatus)) { $contentStatus = 'β€”' } + [void]$builder.AppendLine("| ``$($manifestPackage.PackageId)`` | ``$($manifestPackage.Version)`` | **$($manifestPackage.Status)** | **$contentStatus** | ``$sourceName`` |") + } + [void]$builder.AppendLine('') + } + + if (@($Result.PackProbes).Count -gt 0) { + [void]$builder.AppendLine("| Representative | Package | Version | Availability | Source |") + [void]$builder.AppendLine("|----------------|---------|---------|--------------|--------|") + foreach ($packProbe in $Result.PackProbes) { + $sourceName = Get-InstallabilityProperty $packProbe 'SourceName' + if ([string]::IsNullOrWhiteSpace([string]$sourceName)) { + $resolved = Get-InstallabilityProperty $packProbe 'ResolvedSource' + $sourceName = Get-InstallabilityProperty (Get-InstallabilityProperty $resolved 'Source') 'Name' + } + if ([string]::IsNullOrWhiteSpace([string]$sourceName)) { $sourceName = 'β€”' } + $packageId = if ([string]::IsNullOrWhiteSpace([string]$packProbe.PackageId)) { 'not derived' } else { "``$($packProbe.PackageId)``" } + $version = if ([string]::IsNullOrWhiteSpace([string]$packProbe.Version)) { 'β€”' } else { "``$($packProbe.Version)``" } + [void]$builder.AppendLine("| $($packProbe.Category) | $packageId | $version | **$($packProbe.Status)** | ``$sourceName`` |") + } + [void]$builder.AppendLine('') + } + + if (@($Result.RequiredSources).Count -gt 0) { + $sourceNames = @($Result.RequiredSources | ForEach-Object { "``$($_.Name)``" }) -join ', ' + [void]$builder.AppendLine("**Required package sources:** $sourceNames") + [void]$builder.AppendLine('') + } + + $requirements = $Result.PlatformRequirements + if ($requirements) { + $items = [System.Collections.Generic.List[string]]::new() + if ($requirements.Jdk) { + [void]$items.Add("JDK ``$($requirements.Jdk.Version)`` (recommended ``$($requirements.Jdk.RecommendedVersion)``)") + } + if (@($requirements.AndroidSdk).Count -gt 0) { + $androidPackages = @($requirements.AndroidSdk | ForEach-Object { "``$_``" }) -join ', ' + [void]$items.Add("Android SDK: $androidPackages") + } + if ($requirements.Xcode) { + [void]$items.Add("Xcode ``$($requirements.Xcode.Version)`` (recommended ``$($requirements.Xcode.RecommendedVersion)``)") + } + if ($requirements.AppleSdk) { + [void]$items.Add("Apple SDK ``$($requirements.AppleSdk)``") + } + if ($requirements.WindowsAppSdk) { + [void]$items.Add("Windows App SDK ``$($requirements.WindowsAppSdk)``") + } + if ($items.Count -gt 0) { + [void]$builder.AppendLine("**Platform prerequisites:** $($items -join '; ')") + [void]$builder.AppendLine('') + } + } + + if (-not $PublicSafe -and $Result.NuGetConfig -and $Result.InstallCommand) { + [void]$builder.AppendLine('Create a local-only NuGet configuration:') + [void]$builder.AppendLine('') + [void]$builder.AppendLine('```xml') + [void]$builder.Append($Result.NuGetConfig.TrimEnd()) + [void]$builder.AppendLine('') + [void]$builder.AppendLine('```') + [void]$builder.AppendLine('') + [void]$builder.AppendLine('Then install with the isolated configuration:') + [void]$builder.AppendLine('') + [void]$builder.AppendLine('```bash') + [void]$builder.AppendLine($Result.InstallCommand) + [void]$builder.AppendLine('```') + [void]$builder.AppendLine('') + } elseif ($PublicSafe) { + [void]$builder.AppendLine('_Exact feed URLs and credential setup are intentionally local-only. Re-run with ``-PublicSafe $false`` and the confirmed workload-set CLI version to generate an isolated ```` configuration. If an authenticated source is required, use a short-lived Packaging Read PAT through NuGet credentials; never place it in the tracker or repository._') + [void]$builder.AppendLine('') + } + + return $builder.ToString() +} diff --git a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 index 80128c219a23..19388818ca89 100644 --- a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 @@ -4135,6 +4135,327 @@ Write-Host "`n[Unit] Test-IsP0Pr β€” p/0 PR blocker classification" -ForegroundC $prevScript = Join-Path $PSScriptRoot '..' 'scripts' 'Get-PreviewReadiness.ps1' . $prevScript -Branch 'release/11.0.1xx-preview6' +# ========================================================================= +# Preview consumer installability β€” workload set, feeds, pins, redaction +# ========================================================================= +Write-Host "`n[Unit] Preview consumer installability" -ForegroundColor Cyan + +$installabilityScript = Join-Path $PSScriptRoot '..' 'scripts' 'PreviewInstallability.ps1' +. $installabilityScript + +Assert-Eq -Label "installability: SDK feature band is derived from SDK patch" ` + -Expected '11.0.100' -Actual (Get-PreviewSdkFeatureBand '11.0.103-preview.6.1') +Assert-Eq -Label "installability: CLI version converts to workload-set NuGet version" ` + -Expected '11.100.0-preview.6.26363.2' -Actual (ConvertTo-WorkloadSetNuGetVersion '11.0.100-preview.6.26363.2') +Assert-Eq -Label "installability: NuGet version converts back to CLI version" ` + -Expected '11.0.100-preview.6.26363.2' -Actual (ConvertTo-WorkloadSetCliVersion '11.100.0-preview.6.26363.2' '11.0.100') + +$iiPins = [PSCustomObject]@{ + Vmr = [PSCustomObject]@{ Version = '11.0.100-preview.6.26359.118' } + Android = [PSCustomObject]@{ Version = '37.0.0-preview.6.59' } + Macios = [PSCustomObject]@{ Version = '26.5.11720-net11-p6' } +} +$iiWorkloadSetManifest = [ordered]@{ + 'Microsoft.NET.Sdk.Android' = '37.0.0-preview.6.59/11.0.100-preview.6' + 'Microsoft.NET.Sdk.iOS' = '26.5.11720-net11-p6/11.0.100-preview.6' + 'Microsoft.NET.Sdk.MacCatalyst' = '26.5.11720-net11-p6/11.0.100-preview.6' + 'Microsoft.NET.Sdk.macOS' = '26.5.11720-net11-p6/11.0.100-preview.6' + 'Microsoft.NET.Sdk.tvOS' = '26.5.11720-net11-p6/11.0.100-preview.6' + 'Microsoft.NET.Workload.Mono.ToolChain.Current'= '11.0.100-preview.6.26359.118/11.0.100-preview.6' + 'Microsoft.NET.Workload.Emscripten.Current' = '11.0.100-preview.6.26359.118/11.0.100-preview.6' + 'Microsoft.NET.Sdk.Maui' = '11.0.0-preview.6.26360.8/11.0.100-preview.6' +} +$iiDependencies = [ordered]@{ + 'microsoft.net.sdk.android' = @{ + jdk = @{ version = '[21.0,22.0)'; recommendedVersion = '21.0.8' } + androidsdk = @{ + packages = @( + @{ sdkPackage = @{ id = 'build-tools;36.0.0' } } + @{ sdkPackage = @{ id = 'platforms;android-36' } } + ) + } + } + 'microsoft.net.sdk.ios' = @{ + xcode = @{ version = '[26.6,)'; recommendedVersion = '26.6' } + sdk = @{ version = '26.5' } + } + 'microsoft.net.sdk.maui' = @{ + windowsAppSdk = @{ recommendedVersion = '1.8.251106002' } + } +} +$iiComponentManifest = [ordered]@{ + packs = [ordered]@{ + 'Microsoft.Android.Sdk.net11' = @{ version = '37.0.0-preview.6.59' } + 'Microsoft.iOS.Sdk.net11.0_26.5' = @{ version = '26.5.11720-net11-p6' } + 'Microsoft.MacCatalyst.Sdk.net11.0_26.5' = @{ version = '26.5.11720-net11-p6' } + 'Microsoft.NETCore.App.Runtime.Mono.net11.android-arm64' = @{ version = '11.0.0-preview.6.26359.118' } + 'Microsoft.NETCore.App.Runtime.Mono.net11.ios-arm64' = @{ version = '11.0.0-preview.6.26359.118' } + 'Microsoft.NETCore.App.Runtime.Mono.net11.maccatalyst-arm64' = @{ version = '11.0.0-preview.6.26359.118' } + 'Microsoft.Maui.Controls' = @{ version = '11.0.0-preview.6.26360.8' } + } +} +$iiPackageReader = { + param($ResolvedSource, $PackageId, $Version, $EntryNames) + if ($PackageId -like 'Microsoft.NET.Workloads.*') { + return @{ 'data/microsoft.net.workloads.workloadset.json' = $iiWorkloadSetManifest } + } + return @{ + 'data/WorkloadDependencies.json' = $iiDependencies + 'data/WorkloadManifest.json' = $iiComponentManifest + } +}.GetNewClosure() + +$iiSourcePackages = @{ + 'dotnet-workloads' = @( + 'microsoft.net.workloads.11.0.100-preview.6' + ) + 'dotnet11-workloads' = @( + 'microsoft.net.sdk.android.manifest-11.0.100-preview.6', + 'microsoft.android.sdk.net11' + ) + 'dotnet11' = @( + 'microsoft.net.sdk.ios.manifest-11.0.100-preview.6', + 'microsoft.net.sdk.maccatalyst.manifest-11.0.100-preview.6', + 'microsoft.net.sdk.macos.manifest-11.0.100-preview.6', + 'microsoft.net.sdk.maui.manifest-11.0.100-preview.6', + 'microsoft.net.workload.mono.toolchain.current.manifest-11.0.100-preview.6', + 'microsoft.ios.sdk.net11.0_26.5', + 'microsoft.maccatalyst.sdk.net11.0_26.5', + 'microsoft.maui.controls' + ) + 'dotnet11-transport' = @( + 'microsoft.netcore.app.runtime.mono.net11.android-arm64', + 'microsoft.netcore.app.runtime.mono.net11.ios-arm64', + 'microsoft.netcore.app.runtime.mono.net11.maccatalyst-arm64' + ) +} +$iiFetcher = { + param($Url, $Source) + + if ($Url -eq $Source.Uri) { + return @{ + resources = @( + @{ '@id' = "https://fake/$($Source.Name)/query2/"; '@type' = 'SearchQueryService/3.5.0' } + @{ '@id' = "https://fake/$($Source.Name)/flat2"; '@type' = 'PackageBaseAddress/3.0.0' } + ) + } + } + if ($Url -match '/query2/') { + if ($Source.Name -ne 'dotnet-workloads') { return @{ data = @() } } + return @{ + data = @( + @{ + id = 'Microsoft.NET.Workloads.11.0.100-preview.6' + version = '11.100.0-preview.6.26363.2' + versions = @( + @{ version = '11.100.0-preview.6.26363.2' } + @{ version = '11.100.0-preview.6.26364.2' } + ) + } + @{ + id = 'Microsoft.NET.Workloads.11.0.100-preview.6.Msi.x64' + version = '11.100.0-preview.6.26363.2' + versions = @(@{ version = '11.100.0-preview.6.26363.2' }) + } + ) + } + } + if ($Url -match '/flat2/(?[^/]+)/index\.json$') { + $id = $Matches.id.ToLowerInvariant() + $available = @($iiSourcePackages[$Source.Name]) -contains $id + return @{ versions = if ($available) { @( + '11.100.0-preview.6.26363.2', + '37.0.0-preview.6.59', + '26.5.11720-net11-p6', + '11.0.100-preview.6.26359.118', + '11.0.0-preview.6.26359.118', + '11.0.0-preview.6.26360.8' + ) } else { @() } } + } + throw "Unexpected installability fixture URL: $Url" +}.GetNewClosure() + +$iiResult = Get-PreviewConsumerInstallability -Major 11 -Preview 6 -Pins $iiPins ` + -WorkloadSetCliVersion '11.0.100-preview.6.26363.2' -PublicSafe $false ` + -Fetcher $iiFetcher -PackageReader $iiPackageReader +Assert-Eq -Label "installability: coherent workload set is installable" -Expected 'installable' -Actual $iiResult.Status +Assert-Eq -Label "installability: workload-set search excludes MSI variants" ` + -Expected 'Microsoft.NET.Workloads.11.0.100-preview.6' -Actual $iiResult.PackageId +Assert-Eq -Label "installability: confirmed workload CLI version is preserved" ` + -Expected '11.0.100-preview.6.26363.2' -Actual $iiResult.CliVersion +Assert-Eq -Label "installability: Android branch pin matches workload set" ` + -Expected 'match' -Actual (@($iiResult.PinComparisons | Where-Object WorkloadId -eq 'Microsoft.NET.Sdk.Android')[0].Status) +Assert-Eq -Label "installability: transport feed is discovered from representative runtime pack" ` + -Expected $true -Actual (@($iiResult.RequiredSources.Name) -contains 'dotnet11-transport') +Assert-Eq -Label "installability: Apple SDK representative packs are probed" ` + -Expected $true -Actual ( + @($iiResult.PackProbes.Category) -contains 'ios-sdk' -and + @($iiResult.PackProbes.Category) -contains 'maccatalyst-sdk' + ) +Assert-Eq -Label "installability: generated NuGet config clears inherited sources" ` + -Expected $true -Actual ($iiResult.NuGetConfig -match '') +Assert-Eq -Label "installability: JDK requirement comes from component manifest" ` + -Expected '21.0.8' -Actual $iiResult.PlatformRequirements.Jdk.RecommendedVersion +Assert-Eq -Label "installability: Xcode requirement comes from component manifest" ` + -Expected '26.6' -Actual $iiResult.PlatformRequirements.Xcode.RecommendedVersion +Assert-Eq -Label "installability: Windows App SDK requirement comes from MAUI manifest" ` + -Expected '1.8.251106002' -Actual $iiResult.PlatformRequirements.WindowsAppSdk + +$iiInternalExactFetcher = { + param($Url, $Source) + if ($Url -eq $Source.Uri) { + return @{ + resources = @( + @{ '@id' = "https://fake/$($Source.Name)/query2/"; '@type' = 'SearchQueryService/3.5.0' } + @{ '@id' = "https://fake/$($Source.Name)/flat2"; '@type' = 'PackageBaseAddress/3.0.0' } + ) + } + } + if ($Url -match '/flat2/microsoft\.net\.workloads\.11\.0\.100-preview\.6/index\.json$') { + return @{ versions = if ($Source.Name -eq 'internal_preview6') { + @('11.100.0-preview.6.26363.2') + } else { @() } } + } + return & $iiFetcher $Url $Source +}.GetNewClosure() +$iiInternalExact = Get-PreviewConsumerInstallability -Major 11 -Preview 6 -Pins $iiPins ` + -WorkloadSetCliVersion '11.0.100-preview.6.26363.2' ` + -AdditionalPackageSource 'internal_preview6=https://pkgs.dev.azure.com/dnceng/internal/_packaging/example-shipping/nuget/v3/index.json' ` + -PublicSafe $false -Fetcher $iiInternalExactFetcher -PackageReader $iiPackageReader +Assert-Eq -Label "installability: confirmed version is resolved from an additional source before discovery preference" ` + -Expected 'installable' -Actual $iiInternalExact.Status +Assert-Eq -Label "installability: additional source carrying the confirmed workload set is retained" ` + -Expected $true -Actual (@($iiInternalExact.RequiredSources.Name) -contains 'internal_preview6') + +$iiUnreadablePackageReader = { + param($ResolvedSource, $PackageId, $Version, $EntryNames) + if ($PackageId -like 'Microsoft.NET.Workloads.*') { + return @{ 'data/microsoft.net.workloads.workloadset.json' = $iiWorkloadSetManifest } + } + return @{ + 'data/WorkloadDependencies.json' = $iiDependencies + 'data/WorkloadManifest.json' = $null + } +}.GetNewClosure() +$iiUnreadable = Get-PreviewConsumerInstallability -Major 11 -Preview 6 -Pins $iiPins ` + -WorkloadSetCliVersion '11.0.100-preview.6.26363.2' -PublicSafe $false ` + -Fetcher $iiFetcher -PackageReader $iiUnreadablePackageReader +Assert-Eq -Label "installability: unreadable component manifests cannot produce installable" ` + -Expected 'unknown' -Actual $iiUnreadable.Status +Assert-Eq -Label "installability: unreadable component content is represented in evidence" ` + -Expected $true -Actual (@($iiUnreadable.ManifestPackages.ContentStatus) -contains 'unknown') + +$iiWrongBand = Get-PreviewConsumerInstallability -Major 11 -Preview 6 -Pins $iiPins ` + -WorkloadSetCliVersion '11.0.200-preview.6.26363.2' -PublicSafe $false ` + -Fetcher $iiFetcher -PackageReader $iiPackageReader +Assert-Eq -Label "installability: workload-set feature band must match branch SDK" ` + -Expected 'mismatched' -Actual $iiWrongBand.Status + +$iiNoCandidateFetcher = { + param($Url, $Source) + if ($Url -eq $Source.Uri) { + return @{ + resources = @( + @{ '@id' = "https://fake/$($Source.Name)/query2/"; '@type' = 'SearchQueryService/3.5.0' } + @{ '@id' = "https://fake/$($Source.Name)/flat2"; '@type' = 'PackageBaseAddress/3.0.0' } + ) + } + } + if ($Url -match '/query2/') { return @{ data = @() } } + return @{ versions = @() } +} +$iiNoCandidate = Get-PreviewConsumerInstallability -Major 11 -Preview 6 -Pins $iiPins ` + -PublicSafe $false -Fetcher $iiNoCandidateFetcher -PackageReader $iiPackageReader +Assert-Eq -Label "installability: no unconfirmed candidate remains unknown rather than blocked" ` + -Expected 'unknown' -Actual $iiNoCandidate.Status + +$iiResolvedRoles = @( + [PSCustomObject]@{ + Source = [PSCustomObject]@{ Name = 'workloads'; Role = 'workload-set' } + Available = $true + }, + [PSCustomObject]@{ + Source = [PSCustomObject]@{ Name = 'platform'; Role = 'platform' } + Available = $true + } +) +Assert-Eq -Label "installability: unrelated workload-set feed is not probed for platform packs" ` + -Expected @('platform') -Actual @( + (Get-PreviewSourceOrder -ResolvedSources $iiResolvedRoles -PackageId 'Microsoft.Android.Sdk.net11').Source.Name + ) + +$iiMismatchedManifest = [ordered]@{} +foreach ($entry in $iiWorkloadSetManifest.GetEnumerator()) { $iiMismatchedManifest[$entry.Key] = $entry.Value } +$iiMismatchedManifest['Microsoft.NET.Sdk.Android'] = '37.0.0-preview.6.999/11.0.100-preview.6' +$iiMismatch = Compare-PreviewWorkloadSetPins -Manifest $iiMismatchedManifest -Pins $iiPins -Major 11 -Preview 6 +Assert-Eq -Label "installability: component pin mismatch is detected" ` + -Expected 'mismatch' -Actual (@($iiMismatch | Where-Object WorkloadId -eq 'Microsoft.NET.Sdk.Android')[0].Status) +Assert-Eq -Label "installability: MAUI preview regex accepts the target preview" ` + -Expected 'match' -Actual (@($iiMismatch | Where-Object WorkloadId -eq 'Microsoft.NET.Sdk.Maui')[0].Status) + +$iiAdditionalSource = [PSCustomObject]@{ + Name = 'internal_preview6'; Uri = 'https://pkgs.dev.azure.com/dnceng/internal/_packaging/example-shipping/nuget/v3/index.json' + Role = 'additional'; IsAdditional = $true; IsInternal = $true +} +$iiUnavailableSource = [PSCustomObject]@{ + Source = $iiAdditionalSource; Available = $false; AuthenticationLost = $true + SearchUrl = $null; FlatUrl = $null; Reason = 'HTTP 401' +} +$iiAuthUnknown = Find-PreviewPackageLocation -ResolvedSources @($iiUnavailableSource) ` + -PackageId 'Example.Package' -Version '1.0.0' +Assert-Eq -Label "installability: inaccessible authenticated source is unknown, not missing" ` + -Expected 'unknown' -Actual $iiAuthUnknown.Status + +$iiMissingSource = [PSCustomObject]@{ + Source = [PSCustomObject]@{ + Name = 'public'; Uri = 'https://api.nuget.org/v3/index.json' + Role = 'shared'; IsAdditional = $false; IsInternal = $false + } + Available = $true; AuthenticationLost = $false + SearchUrl = 'https://fake/public/query2/'; FlatUrl = 'https://fake/public/flat2'; Reason = $null +} +$iiMissingFetcher = { param($Url, $Source) @{ versions = @() } } +$iiMissing = Find-PreviewPackageLocation -ResolvedSources @($iiMissingSource) ` + -PackageId 'Example.Package' -Version '1.0.0' -Fetcher $iiMissingFetcher +Assert-Eq -Label "installability: confirmed absence on accessible sources is missing" ` + -Expected 'missing' -Actual $iiMissing.Status + +$iiPrivateResult = [PSCustomObject]@{ + Status = 'unknown'; Summary = 'Authentication is required.'; SdkVersion = '11.0.100-preview.6.1' + SdkFeatureBand = '11.0.100'; PackageId = 'Example.Package'; CliVersion = '11.0.100-preview.6.2' + NuGetVersion = '11.100.0-preview.6.2'; VersionConfirmed = $true; PinComparisons = @() + ManifestPackages = @([PSCustomObject]@{ + WorkloadId = 'Example.Workload'; PackageId = 'Example.Manifest'; Version = '1.0.0'; Status = 'unknown' + ResolvedSource = $null; UnknownSources = @('internal_preview6') + }) + PackProbes = @(); RequiredSources = @($iiAdditionalSource); PlatformRequirements = $null + NuGetConfig = 'private'; InstallCommand = 'dotnet workload install' +} +$iiPublicResult = ConvertTo-PublicInstallabilityResult -Result $iiPrivateResult +$iiPublicJson = $iiPublicResult | ConvertTo-Json -Depth 10 +$iiPublicMarkdown = Format-PreviewInstallabilityMarkdown -Result $iiPublicResult -PublicSafe $true +Assert-Eq -Label "installability: public JSON removes additional source URL" ` + -Expected $false -Actual ($iiPublicJson.Contains($iiAdditionalSource.Uri)) +Assert-Eq -Label "installability: public JSON removes additional source name" ` + -Expected $false -Actual ($iiPublicJson.Contains($iiAdditionalSource.Name)) +Assert-Eq -Label "installability: public JSON removes local NuGet config" ` + -Expected $null -Actual $iiPublicResult.NuGetConfig +Assert-Eq -Label "installability: public Markdown removes additional source URL" ` + -Expected $false -Actual ($iiPublicMarkdown.Contains($iiAdditionalSource.Uri)) +Assert-Eq -Label "installability: public Markdown explains local-only credential setup" ` + -Expected $true -Actual ($iiPublicMarkdown -match 'Packaging Read PAT') +Assert-Eq -Label "installability: public Markdown renders package availability evidence" ` + -Expected $true -Actual ($iiPublicMarkdown -match 'Manifest package.+Availability') + +Assert-Eq -Label "installability check: installable maps to READY" ` + -Expected 'READY' -Actual (ConvertTo-PreviewInstallabilityCheck $iiResult).Status +Assert-Eq -Label "installability check: missing maps to BLOCKED" ` + -Expected 'BLOCKED' -Actual (ConvertTo-PreviewInstallabilityCheck ([PSCustomObject]@{ + Status = 'missing'; Summary = 'missing'; VersionConfirmed = $true + })).Status +Assert-Eq -Label "installability check: unknown maps to UNKNOWN" ` + -Expected 'UNKNOWN' -Actual (ConvertTo-PreviewInstallabilityCheck $iiPrivateResult).Status + $p0Pr = [PSCustomObject]@{ number = 34758; labels = @([PSCustomObject]@{ name = 'p/0' }, [PSCustomObject]@{ name = 'area-xaml' }) } $nonP0Pr = [PSCustomObject]@{ number = 99999; labels = @([PSCustomObject]@{ name = 'area-xaml' }, [PSCustomObject]@{ name = 'p/1' }) } $missingLbls = [PSCustomObject]@{ number = 12345 } # no labels property at all From 835ca7093ab9d6c08dea773f6301b1c2b5ca4231 Mon Sep 17 00:00:00 2001 From: Rui Marinho Date: Tue, 14 Jul 2026 23:20:06 +0100 Subject: [PATCH 2/8] Restrict authenticated Preview package sources Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3b2e86e0-ce82-4e1b-94ec-d554098fcf02 --- .../release-readiness/references/methodology.md | 12 +++++++----- .../scripts/PreviewInstallability.ps1 | 6 +++--- .../tests/Test-ReleaseReadiness.ps1 | 10 ++++++++++ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/.github/skills/release-readiness/references/methodology.md b/.github/skills/release-readiness/references/methodology.md index bfb22e08fae2..731eb1519f5d 100644 --- a/.github/skills/release-readiness/references/methodology.md +++ b/.github/skills/release-readiness/references/methodology.md @@ -198,11 +198,13 @@ may find several coherent candidates, but "newest coherent" does not mean "approved for release." An unconfirmed version therefore cannot produce `READY`. -Additional sources are accepted only for NuGet.org or dnceng Azure Artifacts -HTTPS endpoints. User information embedded in a URL is rejected. Credentials -come from NuGet's `NuGetPackageSourceCredentials_` environment variable, -where `` exactly matches the source name. The value must contain non-empty -`Username` and `Password` fields and `ValidAuthenticationTypes=Basic`. +Additional sources are accepted only for dnceng Azure Artifacts HTTPS +endpoints. NuGet.org is already part of the fixed public source set and cannot +be supplied under an arbitrary credential-bearing name. User information +embedded in a URL is rejected. Credentials come from NuGet's +`NuGetPackageSourceCredentials_` environment variable, where `` +exactly matches the source name. The value must contain non-empty `Username` +and `Password` fields and `ValidAuthenticationTypes=Basic`. ### Workload-Set Version Normalization diff --git a/.github/skills/release-readiness/scripts/PreviewInstallability.ps1 b/.github/skills/release-readiness/scripts/PreviewInstallability.ps1 index ae9d8ab99bfe..81c45f67194c 100644 --- a/.github/skills/release-readiness/scripts/PreviewInstallability.ps1 +++ b/.github/skills/release-readiness/scripts/PreviewInstallability.ps1 @@ -91,11 +91,11 @@ function ConvertFrom-PreviewPackageSourceSpec { $uriText = $Matches.uri $uri = [Uri]$uriText if ($uri.Scheme -ne 'https' -or - $uri.Host -notin @('pkgs.dev.azure.com', 'api.nuget.org') -or + $uri.Host -ne 'pkgs.dev.azure.com' -or -not [string]::IsNullOrEmpty($uri.UserInfo)) { - throw "Additional package source '$name' must be an HTTPS dnceng Azure Artifacts or NuGet.org service index without embedded credentials." + throw "Additional package source '$name' must be an HTTPS dnceng Azure Artifacts service index without embedded credentials." } - if ($uri.Host -eq 'pkgs.dev.azure.com' -and -not $uri.AbsolutePath.StartsWith('/dnceng/', [StringComparison]::OrdinalIgnoreCase)) { + if (-not $uri.AbsolutePath.StartsWith('/dnceng/', [StringComparison]::OrdinalIgnoreCase)) { throw "Additional Azure Artifacts source '$name' must belong to the dnceng organization." } if (@($sources | Where-Object { $_.Name -ieq $name }).Count -gt 0) { diff --git a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 index 19388818ca89..378c4289978f 100644 --- a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 @@ -4150,6 +4150,16 @@ Assert-Eq -Label "installability: CLI version converts to workload-set NuGet ver Assert-Eq -Label "installability: NuGet version converts back to CLI version" ` -Expected '11.0.100-preview.6.26363.2' -Actual (ConvertTo-WorkloadSetCliVersion '11.100.0-preview.6.26363.2' '11.0.100') +$iiExternalCredentialSourceRejected = $false +try { + $null = ConvertFrom-PreviewPackageSourceSpec -Major 11 ` + -AdditionalPackageSource 'credential_alias=https://api.nuget.org/v3/index.json' +} catch { + $iiExternalCredentialSourceRejected = $true +} +Assert-Eq -Label "installability: credential-bearing additional sources cannot target NuGet.org" ` + -Expected $true -Actual $iiExternalCredentialSourceRejected + $iiPins = [PSCustomObject]@{ Vmr = [PSCustomObject]@{ Version = '11.0.100-preview.6.26359.118' } Android = [PSCustomObject]@{ Version = '37.0.0-preview.6.59' } From 57c70254b076d808c43105244efb33bf1367bb21 Mon Sep 17 00:00:00 2001 From: PureWeen <223556219+Copilot@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:07:05 -0500 Subject: [PATCH 3/8] Fix adversarial-review findings in PreviewInstallability.ps1 Addresses high-confidence findings from a multi-model adversarial code review of PR #36569 (posted as GitHub review #4812833050): - Sensitive-source-name leak: ConvertTo-PublicInstallabilityResult now accepts a -Sources parameter and treats every additional/internal source configured for the run as sensitive, not just the ones that ended up in $Result.RequiredSources. A source that failed for one package but was never "required" (because a later source satisfied it) could otherwise leak its real name through that location's UnknownSources in public-safe output. All 6 call sites of Complete-PreviewInstallabilityResult now pass -Sources through. - Embargoed CLI/NuGet version leak: a confirmed workload-set build's version was still exposed via InstallCommand and each pin comparison's Actual value even though CliVersion/NuGetVersion were withheld. InstallCommand now uses a withheld placeholder and each PinComparisons Actual becomes 'withheld' when VersionConfirmed is true, while WorkloadId/Expected/Status stay intact so the pin- coherence signal is preserved. Unconfirmed (discovered) candidates are left unredacted, matching existing intent. - Unconfirmed-candidate status mapping: an unconfirmed run (no release-owner-supplied CLI version) whose only discoverable candidate failed pin coherence, or had missing/unresolvable assets, was mapped to mismatched/missing -> BLOCKED. Only a genuinely confirmed candidate's asset/pin problems are real BLOCKED signals; an unconfirmed candidate now stays unknown in both cases. - Unverified pin status treated as coherent: a pin comparison whose expected value could not be determined (Compare- PreviewWorkloadSetPins reports unverified) was not excluded from the coherence check, so a workload set could be reported installable even though one of its components was never actually verified against branch pins. Unverified is now treated the same as mismatch/missing for candidate selection. - Markdown injection via feed-derived source names: source names interpolated into the rendered markdown table were not escaped, so a pipe could break table structure and a less-than sign could be read as an HTML tag by downstream renderers. Added a self-contained Format-InstallabilityMarkdownCell helper (mirroring Format- MarkdownCell's intent in Get-PreviewReadiness.ps1, duplicated so this file stays independently dot-sourceable in unit tests) and applied it to all source-name interpolations in Format-PreviewInstallabilityMarkdown. Adds 20 focused regression tests in Test-ReleaseReadiness.ps1 covering each fix directly (redaction with/without -Sources, embargoed-version redaction for CliVersion/NuGetVersion/InstallCommand/PinComparisons, confirmed-vs-unconfirmed status mapping for both mismatch and missing- asset cases, unverified pin handling, and markdown-cell escaping). Test-ReleaseReadiness.ps1 -SkipE2E: 1961/1961 passed (was 1941/1941). Full suite (no -SkipE2E): 2094/2094 passed (was 2074/2074). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 68573d91-fd15-4374-adf5-c959169a31b8 --- .../scripts/PreviewInstallability.ps1 | 117 ++++++++++-- .../tests/Test-ReleaseReadiness.ps1 | 176 ++++++++++++++++++ 2 files changed, 273 insertions(+), 20 deletions(-) diff --git a/.github/skills/release-readiness/scripts/PreviewInstallability.ps1 b/.github/skills/release-readiness/scripts/PreviewInstallability.ps1 index 81c45f67194c..a54728aea4d9 100644 --- a/.github/skills/release-readiness/scripts/PreviewInstallability.ps1 +++ b/.github/skills/release-readiness/scripts/PreviewInstallability.ps1 @@ -3,6 +3,26 @@ Set-StrictMode -Version Latest +function Format-InstallabilityMarkdownCell { + <# + Escapes markdown-significant characters in values that can originate from + outside this script's control (NuGet package source names supplied via + -AdditionalPackageSource, or feed-hosted package/version identifiers) before + they are interpolated into a rendered markdown table. Without this, a source + name containing a literal `|` breaks the table's column alignment, and a `<` + can be interpreted as the start of an HTML tag/comment by downstream markdown + renderers. This mirrors Format-MarkdownCell's escaping intent in + Get-PreviewReadiness.ps1, duplicated locally so this file stays independently + loadable/testable (it is dot-sourced standalone in unit tests). + #> + param([AllowNull()][string]$Value) + if ([string]::IsNullOrEmpty($Value)) { return '' } + $escaped = $Value -replace "`r`n", ' ' -replace "`n", ' ' -replace "`r", ' ' + $escaped = $escaped -replace '\|', '\|' + $escaped = $escaped -replace '<', '<' -replace '>', '>' + return $escaped +} + function Get-InstallabilityProperty { param( [AllowNull()]$Object, @@ -665,10 +685,21 @@ function ConvertTo-IsolatedNuGetConfig { } function ConvertTo-PublicInstallabilityResult { - param([Parameter(Mandatory)]$Result) + param( + [Parameter(Mandatory)]$Result, + [AllowNull()][array]$Sources = @() + ) + # Build the sensitive-name set from every additional/internal source that was + # configured for this run, not just the ones that ended up in $Result.RequiredSources. + # RequiredSources only lists sources actually needed by the final outcome (and, on + # the 'installable' path, omits additional sources entirely - see the requiredSources + # assembly below). A source that failed for one package but was never "required" + # because a later source satisfied the request can still appear in an individual + # location's UnknownSources; sourcing sensitivity from $Sources instead of + # $Result.RequiredSources ensures its real name is redacted there too. $sensitiveSourceNames = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) - foreach ($source in @($Result.RequiredSources)) { + foreach ($source in (@($Sources) + @($Result.RequiredSources))) { if ([bool](Get-InstallabilityProperty $source 'IsAdditional') -or [bool](Get-InstallabilityProperty $source 'IsInternal')) { [void]$sensitiveSourceNames.Add([string](Get-InstallabilityProperty $source 'Name')) @@ -711,9 +742,33 @@ function ConvertTo-PublicInstallabilityResult { } $copy.NuGetConfig = $null - $copy.InstallCommand = if ($Result.CliVersion) { - "dotnet workload install maui --version $($Result.CliVersion) --configfile " - } else { $null } + # A release-owner-confirmed workload-set build is embargoed (withheld from the public + # issue), consistent with this skill's existing "blessed build" handling elsewhere + # (see SKILL.md's Preview tracker gotchas). Withhold the exact CLI/NuGet version, the + # install command that names it, and each component's resolved build number here so a + # public-safe report never leaks the confirmed build ahead of its official + # announcement; the match/mismatch/missing Status alone still conveys pin coherence + # without revealing the build itself. An unconfirmed discovered candidate is not + # embargoed (it's just the newest public coherent package), so it is left intact. + if ($Result.VersionConfirmed) { + $copy.CliVersion = 'withheld' + $copy.NuGetVersion = 'withheld' + $copy.InstallCommand = if ($Result.CliVersion) { + 'dotnet workload install maui --version --configfile ' + } else { $null } + $copy.PinComparisons = @($Result.PinComparisons | ForEach-Object { + [PSCustomObject]@{ + WorkloadId = $_.WorkloadId + Expected = $_.Expected + Actual = if ([string]::IsNullOrWhiteSpace([string]$_.Actual)) { $_.Actual } else { 'withheld' } + Status = $_.Status + } + }) + } else { + $copy.InstallCommand = if ($Result.CliVersion) { + "dotnet workload install maui --version $($Result.CliVersion) --configfile " + } else { $null } + } $copy.ManifestPackages = @($Result.ManifestPackages | ForEach-Object { & $sanitizeLocation $_ }) $copy.PackProbes = @($Result.PackProbes | ForEach-Object { & $sanitizeLocation $_ }) $copy.RequiredSources = @( @@ -741,11 +796,12 @@ function ConvertTo-PublicInstallabilityResult { function Complete-PreviewInstallabilityResult { param( [Parameter(Mandatory)]$Result, - [bool]$PublicSafe + [bool]$PublicSafe, + [AllowNull()][array]$Sources = @() ) if ($PublicSafe) { - return ConvertTo-PublicInstallabilityResult -Result $Result + return ConvertTo-PublicInstallabilityResult -Result $Result -Sources $Sources } return $Result } @@ -839,7 +895,7 @@ function Get-PreviewConsumerInstallability { RequiredSources = @($sources); PlatformRequirements = $null NuGetConfig = $null; InstallCommand = $null } - return Complete-PreviewInstallabilityResult -Result $result -PublicSafe $PublicSafe + return Complete-PreviewInstallabilityResult -Result $result -PublicSafe $PublicSafe -Sources $sources } $package = $null @@ -860,7 +916,7 @@ function Get-PreviewConsumerInstallability { RequiredSources = @($sources); PlatformRequirements = $null NuGetConfig = $null; InstallCommand = $null } - return Complete-PreviewInstallabilityResult -Result $result -PublicSafe $PublicSafe + return Complete-PreviewInstallabilityResult -Result $result -PublicSafe $PublicSafe -Sources $sources } $package = [PSCustomObject]@{ @@ -881,7 +937,7 @@ function Get-PreviewConsumerInstallability { RequiredSources = @($sources); PlatformRequirements = $null NuGetConfig = $null; InstallCommand = $null } - return Complete-PreviewInstallabilityResult -Result $result -PublicSafe $PublicSafe + return Complete-PreviewInstallabilityResult -Result $result -PublicSafe $PublicSafe -Sources $sources } } @@ -904,7 +960,11 @@ function Get-PreviewConsumerInstallability { $comparisons = @(Compare-PreviewWorkloadSetPins -Manifest $manifest -Pins $Pins -Major $Major -Preview $Preview ` -ExpectedMauiManifestVersion $ExpectedMauiManifestVersion) $lastComparisons = $comparisons - if (@($comparisons | Where-Object { $_.Status -in @('mismatch', 'missing') }).Count -eq 0) { + # 'unverified' means the expected pin value itself was unavailable (see + # Compare-PreviewWorkloadSetPins) β€” i.e. this component's pin was never + # actually checked, not that it matched. Treating it as coherent would let + # incomplete branch-pin data silently pass a full pin-coherence claim. + if (@($comparisons | Where-Object { $_.Status -in @('mismatch', 'missing', 'unverified') }).Count -eq 0) { $selectedVersion = $version $selectedManifest = $manifest $selectedComparisons = $comparisons @@ -916,11 +976,24 @@ function Get-PreviewConsumerInstallability { } if (-not $selectedManifest) { - $status = if ($lastComparisons.Count -gt 0) { 'mismatched' } else { 'unknown' } + # An unconfirmed run (no release-owner-supplied CLI version) that finds no + # fully-coherent candidate among the discovered versions must remain 'unknown', + # not 'mismatched' -> BLOCKED: the newest public candidate not yet matching + # branch pins is not evidence of a real installability problem when nothing has + # been confirmed. Only a confirmed candidate's genuine pin mismatch is BLOCKED. + $status = if (-not $WorkloadSetCliVersion) { + 'unknown' + } elseif ($lastComparisons.Count -gt 0) { + 'mismatched' + } else { + 'unknown' + } $result = [PSCustomObject]@{ Status = $status Summary = if ($status -eq 'mismatched') { 'Available workload-set candidates do not match the branch component pins.' + } elseif (-not $WorkloadSetCliVersion -and $lastComparisons.Count -gt 0) { + 'No discovered workload-set candidate has branch-pin-coherent contents, and no release-owner-confirmed version was supplied to evaluate directly.' } else { 'The workload-set package was found, but its manifest could not be read.' } @@ -930,7 +1003,7 @@ function Get-PreviewConsumerInstallability { RequiredSources = @($package.Source.Source); PlatformRequirements = $null NuGetConfig = $null; InstallCommand = $null } - return Complete-PreviewInstallabilityResult -Result $result -PublicSafe $PublicSafe + return Complete-PreviewInstallabilityResult -Result $result -PublicSafe $PublicSafe -Sources $sources } $selectedCliVersion = ConvertTo-WorkloadSetCliVersion -NuGetVersion $selectedVersion -SdkFeatureBand $featureBand @@ -1012,12 +1085,16 @@ function Get-PreviewConsumerInstallability { $unreadableManifests = @($manifestLocations | Where-Object { $_.Status -eq 'found' -and $_.ContentStatus -ne 'read' }) - $status = if ($missing.Count -gt 0) { + $status = if (-not $WorkloadSetCliVersion) { + # Unconfirmed run: no release-owner-blessed version was supplied, so even a + # missing/unresolvable asset for the auto-discovered candidate is not proof this + # preview is uninstallable - it's proof this particular unconfirmed candidate is + # not fully resolvable. Only a confirmed candidate's asset gaps are BLOCKED. + 'unknown' + } elseif ($missing.Count -gt 0) { 'missing' } elseif ($unknown.Count -gt 0 -or $unreadableManifests.Count -gt 0) { 'unknown' - } elseif (-not $WorkloadSetCliVersion) { - 'unknown' } else { 'installable' } @@ -1069,7 +1146,7 @@ function Get-PreviewConsumerInstallability { InstallCommand = $command } - return Complete-PreviewInstallabilityResult -Result $result -PublicSafe $PublicSafe + return Complete-PreviewInstallabilityResult -Result $result -PublicSafe $PublicSafe -Sources $sources } function Format-PreviewInstallabilityMarkdown { @@ -1115,7 +1192,7 @@ function Format-PreviewInstallabilityMarkdown { if ([string]::IsNullOrWhiteSpace([string]$sourceName)) { $sourceName = 'β€”' } $contentStatus = Get-InstallabilityProperty $manifestPackage 'ContentStatus' if ([string]::IsNullOrWhiteSpace([string]$contentStatus)) { $contentStatus = 'β€”' } - [void]$builder.AppendLine("| ``$($manifestPackage.PackageId)`` | ``$($manifestPackage.Version)`` | **$($manifestPackage.Status)** | **$contentStatus** | ``$sourceName`` |") + [void]$builder.AppendLine("| ``$($manifestPackage.PackageId)`` | ``$($manifestPackage.Version)`` | **$($manifestPackage.Status)** | **$contentStatus** | ``$(Format-InstallabilityMarkdownCell $sourceName)`` |") } [void]$builder.AppendLine('') } @@ -1132,13 +1209,13 @@ function Format-PreviewInstallabilityMarkdown { if ([string]::IsNullOrWhiteSpace([string]$sourceName)) { $sourceName = 'β€”' } $packageId = if ([string]::IsNullOrWhiteSpace([string]$packProbe.PackageId)) { 'not derived' } else { "``$($packProbe.PackageId)``" } $version = if ([string]::IsNullOrWhiteSpace([string]$packProbe.Version)) { 'β€”' } else { "``$($packProbe.Version)``" } - [void]$builder.AppendLine("| $($packProbe.Category) | $packageId | $version | **$($packProbe.Status)** | ``$sourceName`` |") + [void]$builder.AppendLine("| $($packProbe.Category) | $packageId | $version | **$($packProbe.Status)** | ``$(Format-InstallabilityMarkdownCell $sourceName)`` |") } [void]$builder.AppendLine('') } if (@($Result.RequiredSources).Count -gt 0) { - $sourceNames = @($Result.RequiredSources | ForEach-Object { "``$($_.Name)``" }) -join ', ' + $sourceNames = @($Result.RequiredSources | ForEach-Object { "``$(Format-InstallabilityMarkdownCell $_.Name)``" }) -join ', ' [void]$builder.AppendLine("**Required package sources:** $sourceNames") [void]$builder.AppendLine('') } diff --git a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 index f0403565ad5c..e76b2db240b2 100644 --- a/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 +++ b/.github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1 @@ -9169,6 +9169,182 @@ Assert-Eq -Label "installability check: missing maps to BLOCKED" ` Assert-Eq -Label "installability check: unknown maps to UNKNOWN" ` -Expected 'UNKNOWN' -Actual (ConvertTo-PreviewInstallabilityCheck $iiPrivateResult).Status +# ------------------------------------------------------------------------- +# Regression tests: redaction/consensus fixes found during adversarial review +# ------------------------------------------------------------------------- + +# Fix: an additional/internal source that fails for ONE package but is never +# selected as that package's resolving source (because a later source in the +# probe order succeeds) never lands in $Result.RequiredSources β€” the +# 'installable' path only carries additional sources when they were actually +# used. Its real name can still leak through an individual location's +# UnknownSources unless the public sanitizer is told about every source that +# was *configured* for the run (-Sources), not just the ones RequiredSources +# ended up keeping. +$iiFailedButUnusedSource = [PSCustomObject]@{ + Name = 'internal_preview6_unused' + Uri = 'https://pkgs.dev.azure.com/dnceng/internal/_packaging/unused/nuget/v3/index.json' + Role = 'additional'; IsAdditional = $true; IsInternal = $true +} +$iiInstallableWithHiddenFailure = [PSCustomObject]@{ + Status = 'installable'; Summary = 'ok'; SdkVersion = '11.0.100-preview.6.1' + SdkFeatureBand = '11.0.100'; PackageId = 'Example.Package'; CliVersion = '11.0.100-preview.6.2' + NuGetVersion = '11.100.0-preview.6.2'; VersionConfirmed = $false; PinComparisons = @() + ManifestPackages = @([PSCustomObject]@{ + WorkloadId = 'Example.Workload'; PackageId = 'Example.Manifest'; Version = '1.0.0'; Status = 'found' + ResolvedSource = [PSCustomObject]@{ Source = [PSCustomObject]@{ Name = 'public'; Role = 'shared'; IsAdditional = $false; IsInternal = $false } } + # The failed additional source shows up here even though the package was + # ultimately found via 'public' β€” this is the leak vector. + UnknownSources = @('internal_preview6_unused') + }) + PackProbes = @(); RequiredSources = @([PSCustomObject]@{ Name = 'public'; Role = 'shared'; Uri = 'https://api.nuget.org/v3/index.json'; IsAdditional = $false; IsInternal = $false }) + NuGetConfig = $null; InstallCommand = 'dotnet workload install maui --version 11.0.100-preview.6.2 --configfile ./preview-nuget.config' +} +$iiRedactedWithSources = ConvertTo-PublicInstallabilityResult -Result $iiInstallableWithHiddenFailure ` + -Sources @($iiFailedButUnusedSource) +Assert-Eq -Label "installability: source that failed but was never required is still redacted when -Sources is supplied" ` + -Expected $false -Actual (($iiRedactedWithSources | ConvertTo-Json -Depth 10).Contains('internal_preview6_unused')) +Assert-Eq -Label "installability: redacted UnknownSources uses the generic authenticated-source placeholder" ` + -Expected 'authenticated-source' -Actual $iiRedactedWithSources.ManifestPackages[0].UnknownSources[0] + +$iiRedactedWithoutSources = ConvertTo-PublicInstallabilityResult -Result $iiInstallableWithHiddenFailure +Assert-Eq -Label "installability: without -Sources, a source outside RequiredSources is NOT recognized as sensitive (documents why -Sources must be passed at every call site)" ` + -Expected 'internal_preview6_unused' -Actual $iiRedactedWithoutSources.ManifestPackages[0].UnknownSources[0] + +# Fix: a release-owner-confirmed workload set's exact CLI/NuGet version and +# per-component "Actual" build numbers must be embargoed in public-safe output +# even though they are only referenced by the install command string, not just +# the top-level CliVersion/NuGetVersion fields. +$iiConfirmedLeakResult = [PSCustomObject]@{ + Status = 'installable'; Summary = 'ok'; SdkVersion = '11.0.100-preview.6.1' + SdkFeatureBand = '11.0.100'; PackageId = 'Microsoft.NET.Workloads.11.0.100-preview.6' + CliVersion = '11.0.100-preview.6.SECRETBUILD'; NuGetVersion = '11.100.0-preview.6.SECRETBUILD' + VersionConfirmed = $true + PinComparisons = @([PSCustomObject]@{ + WorkloadId = 'Microsoft.NET.Sdk.Android'; Expected = '37.0.0-preview.6.59' + Actual = '37.0.0-preview.6.SECRETBUILD'; Status = 'match' + }) + ManifestPackages = @(); PackProbes = @(); RequiredSources = @() + NuGetConfig = $null + InstallCommand = 'dotnet workload install maui --version 11.0.100-preview.6.SECRETBUILD --configfile ./preview-nuget.config' +} +$iiConfirmedLeakPublic = ConvertTo-PublicInstallabilityResult -Result $iiConfirmedLeakResult +Assert-Eq -Label "installability: confirmed CliVersion is withheld in public-safe output" ` + -Expected 'withheld' -Actual $iiConfirmedLeakPublic.CliVersion +Assert-Eq -Label "installability: confirmed NuGetVersion is withheld in public-safe output" ` + -Expected 'withheld' -Actual $iiConfirmedLeakPublic.NuGetVersion +Assert-Eq -Label "installability: confirmed InstallCommand does not leak the embargoed build number" ` + -Expected $false -Actual ($iiConfirmedLeakPublic.InstallCommand.Contains('SECRETBUILD')) +Assert-Eq -Label "installability: confirmed pin comparison Actual value is withheld" ` + -Expected 'withheld' -Actual $iiConfirmedLeakPublic.PinComparisons[0].Actual +Assert-Eq -Label "installability: confirmed pin comparison WorkloadId/Expected/Status survive redaction (coherence signal preserved)" ` + -Expected $true -Actual ( + $iiConfirmedLeakPublic.PinComparisons[0].WorkloadId -eq 'Microsoft.NET.Sdk.Android' -and + $iiConfirmedLeakPublic.PinComparisons[0].Expected -eq '37.0.0-preview.6.59' -and + $iiConfirmedLeakPublic.PinComparisons[0].Status -eq 'match' + ) + +$iiUnconfirmedResult = [PSCustomObject]@{ + Status = 'installable'; Summary = 'ok'; SdkVersion = '11.0.100-preview.6.1' + SdkFeatureBand = '11.0.100'; PackageId = 'Microsoft.NET.Workloads.11.0.100-preview.6' + CliVersion = '11.0.100-preview.6.26363.2'; NuGetVersion = '11.100.0-preview.6.26363.2' + VersionConfirmed = $false + PinComparisons = @([PSCustomObject]@{ + WorkloadId = 'Microsoft.NET.Sdk.Android'; Expected = '37.0.0-preview.6.59' + Actual = '37.0.0-preview.6.59'; Status = 'match' + }) + ManifestPackages = @(); PackProbes = @(); RequiredSources = @() + NuGetConfig = $null + InstallCommand = 'dotnet workload install maui --version 11.0.100-preview.6.26363.2 --configfile ./preview-nuget.config' +} +$iiUnconfirmedPublic = ConvertTo-PublicInstallabilityResult -Result $iiUnconfirmedResult +Assert-Eq -Label "installability: unconfirmed (discovered) version is not embargoed - it's just the newest public candidate" ` + -Expected '11.0.100-preview.6.26363.2' -Actual $iiUnconfirmedPublic.CliVersion +Assert-Eq -Label "installability: unconfirmed InstallCommand keeps the discovered version" ` + -Expected $true -Actual ($iiUnconfirmedPublic.InstallCommand.Contains('11.0.100-preview.6.26363.2')) + +# Fix: an unconfirmed run (no release-owner-supplied CLI version) whose only +# discoverable workload-set candidate fails branch-pin coherence must remain +# 'unknown', not be promoted to 'mismatched' (which maps to BLOCKED). Only a +# genuinely confirmed candidate's mismatch is evidence of a real problem. +$iiAlwaysMismatchedManifest = [ordered]@{} +foreach ($entry in $iiWorkloadSetManifest.GetEnumerator()) { $iiAlwaysMismatchedManifest[$entry.Key] = $entry.Value } +$iiAlwaysMismatchedManifest['Microsoft.NET.Sdk.Android'] = '37.0.0-preview.6.999/11.0.100-preview.6' +$iiMismatchPackageReader = { + param($ResolvedSource, $PackageId, $Version, $EntryNames) + if ($PackageId -like 'Microsoft.NET.Workloads.*') { + return @{ 'data/microsoft.net.workloads.workloadset.json' = $iiAlwaysMismatchedManifest } + } + return @{ + 'data/WorkloadDependencies.json' = $iiDependencies + 'data/WorkloadManifest.json' = $iiComponentManifest + } +}.GetNewClosure() +$iiUnconfirmedMismatch = Get-PreviewConsumerInstallability -Major 11 -Preview 6 -Pins $iiPins ` + -PublicSafe $false -Fetcher $iiFetcher -PackageReader $iiMismatchPackageReader +Assert-Eq -Label "installability: unconfirmed candidate that fails pin coherence stays unknown (not promoted to BLOCKED)" ` + -Expected 'unknown' -Actual $iiUnconfirmedMismatch.Status + +$iiConfirmedMismatch = Get-PreviewConsumerInstallability -Major 11 -Preview 6 -Pins $iiPins ` + -WorkloadSetCliVersion '11.0.100-preview.6.26363.2' -PublicSafe $false ` + -Fetcher $iiFetcher -PackageReader $iiMismatchPackageReader +Assert-Eq -Label "installability: confirmed candidate that fails pin coherence is still mismatched (real BLOCKED signal preserved)" ` + -Expected 'mismatched' -Actual $iiConfirmedMismatch.Status + +# Fix: a pin whose expected value could not be determined at all (e.g. the +# branch pins document is missing that component) compares as 'unverified' - +# distinct from 'match'. An 'unverified' component must not silently count as +# coherent, because that would let a confirmed workload set with genuinely +# unknown pin data for one component still be reported 'installable'. +$iiPinsMissingAndroid = [PSCustomObject]@{ + Vmr = $iiPins.Vmr + Macios = $iiPins.Macios + # Android intentionally omitted -> Compare-PreviewWorkloadSetPins cannot + # verify that component's pin and must report 'unverified', not 'match'. +} +$iiUnverifiedComparisons = Compare-PreviewWorkloadSetPins -Manifest $iiWorkloadSetManifest ` + -Pins $iiPinsMissingAndroid -Major 11 -Preview 6 +Assert-Eq -Label "installability: pin with no expected value to verify against is 'unverified', not 'match'" ` + -Expected 'unverified' -Actual (@($iiUnverifiedComparisons | Where-Object WorkloadId -eq 'Microsoft.NET.Sdk.Android')[0]).Status + +$iiConfirmedUnverifiedRun = Get-PreviewConsumerInstallability -Major 11 -Preview 6 -Pins $iiPinsMissingAndroid ` + -WorkloadSetCliVersion '11.0.100-preview.6.26363.2' -PublicSafe $false ` + -Fetcher $iiFetcher -PackageReader $iiPackageReader +Assert-Eq -Label "installability: 'unverified' pin blocks the coherent-candidate selection (status is not 'installable')" ` + -Expected $false -Actual ($iiConfirmedUnverifiedRun.Status -eq 'installable') + +# Fix: markdown table cells built from feed/source-supplied strings (NuGet +# source names) must not be able to break table structure (a literal '|') +# or be misread as an HTML tag/comment start (a literal '<') by downstream +# markdown renderers. +Assert-Eq -Label "installability markdown: pipe in source name does not break table structure" ` + -Expected 'a \| b' -Actual (Format-InstallabilityMarkdownCell 'a | b') +Assert-Eq -Label "installability markdown: angle brackets are escaped" ` + -Expected '<script>' -Actual (Format-InstallabilityMarkdownCell '