From a430239fdee46796bf2b31d1042162c3b408ef46 Mon Sep 17 00:00:00 2001 From: romanett Date: Tue, 29 Sep 2026 07:45:30 +0200 Subject: [PATCH] Allow release.yml to promote release-line previews to nuget.org (#4574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description Adds a way to publish a numbered release-line preview (e.g. `2.0.0-preview.6`) to nuget.org. Since #4513, `nuget-publish.yml` pushes previews only to GitHub Packages, and `release.yml` (the only nuget.org path) refused every non-stable candidate. That left no way to continue the `2.0.0-preview.N` series on nuget.org (last published there: `2.0.0-preview.5`). ## Changes - **`release.yml`** gains a `channel` input (`stable` | `preview`, default `stable`). - `stable` behaves exactly as before. - `preview` accepts only a public release-line preview, `M.m.p-preview.N` with no commit id, from the matching canonical `release/M.m` branch. `master` / PR builds (`-preview.N.g`) are rejected. - The candidate manifest's recorded `channel` must equal the dispatched one, so a stable promotion can never publish a preview by accident, or the reverse. - **`package-version-policy.ps1`**: new `Test-PromotablePreviewPackageVersion`, and an `-AllowPreview` switch on `Test-CanonicalReleaseBranchForPackageVersion`. Callers without the switch (the stable gate in `nuget-publish.yml`) behave exactly as before. - **`docs/ReleaseProcess.md`**: new "Preview release" procedure, including how to pick the preview number with `versionHeightOffset`. - **Tests**: predicate cases, the `-AllowPreview` switch, and a pin that `release.yml` defaults to `stable` and relaxes the gate only for `channel=preview`. ## GitHub Packages preview feed is unchanged - `nuget-publish.yml` is not touched. `master` keeps auto-publishing `2.0.0-preview..g` to GitHub Packages, and release-branch builds keep pushing their own `-preview.N` there. - The commit-id suffix means `master` versions can never collide with a bare `2.0.0-preview.N`. - For a preview, `release.yml`'s GitHub Packages push is a verified no-op. The candidate's own `nuget-publish.yml` run already pushed those bytes, and the existing identity preflight and post-push check prove they match. ## Getting `2.0.0-preview.6` after merge 1. Cut `release/2.0` from `master` after this merges. `release.yml` executes the policy script from the candidate commit, so that commit must contain this change. 2. On `release/2.0`, commit a `versionHeightOffset` so the build lands on `.6`. Verified locally with nbgv 3.10.94: at the current `master` tip (height 24), one commit setting `-19` yields `2.0.0-preview.6` and the next yields `.7`. Recompute for the actual cut point with `nbgv get-version -v NuGetPackageVersion`. 3. Dispatch `release.yml --ref release/2.0 -f release_run_id= -f channel=preview`, with a dry run first. `2.0.0-preview.6` is currently free: nuget.org has `.2`–`.5`, and the retired `release/2.0.0` pushed up to `.5` to GitHub Packages. ## Related Issues - Follow-up to #4513. Supersedes the `master` → `release/2.0.0` merge attempts #4566 / #4570; `release/2.0.0` is retired and cannot publish. ## Checklist - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [ ] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. Locally: `PackageVersionPolicyTests` + `NuGetPackageSetValidationTests` on net10.0, 96/96 passed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .azurepipelines/package-version-policy.ps1 | 36 +++++++- .github/workflows/release.yml | 80 ++++++++++++---- docs/ReleaseProcess.md | 87 +++++++++++++++++- .../PackageVersionPolicyTests.cs | 92 +++++++++++++++++++ 4 files changed, 269 insertions(+), 26 deletions(-) diff --git a/.azurepipelines/package-version-policy.ps1 b/.azurepipelines/package-version-policy.ps1 index 8c5e26d92b..600a19e3ae 100644 --- a/.azurepipelines/package-version-policy.ps1 +++ b/.azurepipelines/package-version-policy.ps1 @@ -257,6 +257,27 @@ function Test-StablePackageVersion { return $Version -match '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$' } +function Test-PromotablePreviewPackageVersion { + <# + .SYNOPSIS + Returns $true when a package version is a public release-line + preview, exactly "major.minor.patch-preview.N", for example + "2.0.0-preview.6". This is the only preview shape release.yml may + promote to nuget.org. + .DESCRIPTION + A canonical release/M.m branch is a public release for + Nerdbank.GitVersioning, so its previews carry no commit id. Every + other shape is rejected: "-preview.N.gabc123" and "+gabc123" are + non-public builds (master, PR and feature branches), and anything + else ("-rc.1", "-preview", a four-component version) is not a + numbered preview this repository ever produces. The label is matched + case-sensitively (-cmatch): "-PREVIEW.6" is not the shape NBGV emits. + #> + param([Parameter(Mandatory)][string]$Version) + + return $Version -cmatch '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-preview\.(0|[1-9]\d*)$' +} + function Test-CanonicalReleaseBranchRef { <# .SYNOPSIS @@ -304,13 +325,22 @@ function Test-CanonicalReleaseBranchForPackageVersion { comparison below can stay textual: "release/02.0" is not an alternate spelling of the 2.0 line, it is simply not a release line. See Test-CanonicalReleaseBranchRef for why case and leading zeroes matter. + + -AllowPreview additionally accepts a public release-line preview + (Test-PromotablePreviewPackageVersion, e.g. "2.0.0-preview.6") under + the same major/minor rule. Only release.yml's explicit preview + promotion passes it; the stable gates in nuget-publish.yml and + release.yml never do. #> param( [Parameter(Mandatory)][string]$Ref, - [Parameter(Mandatory)][string]$Version + [Parameter(Mandatory)][string]$Version, + [switch]$AllowPreview ) - if (-not (Test-StablePackageVersion -Version $Version)) { + $isStable = Test-StablePackageVersion -Version $Version + $isPreview = $AllowPreview -and (Test-PromotablePreviewPackageVersion -Version $Version) + if (-not ($isStable -or $isPreview)) { return $false } @@ -321,7 +351,7 @@ function Test-CanonicalReleaseBranchForPackageVersion { } $versionMatch = [regex]::Match( - $Version, '^(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?:0|[1-9]\d*)$') + $Version, '^(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-preview\.(?:0|[1-9]\d*))?$') return $versionMatch.Success -and $branchMatch.Groups['major'].Value -eq $versionMatch.Groups['major'].Value -and $branchMatch.Groups['minor'].Value -eq $versionMatch.Groups['minor'].Value diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a3e20e847..6962a2dfb2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,13 +4,15 @@ name: Release to nuget.org # nuget-publish.yml run on a canonical "release/." branch (e.g. # release/2.0). The release environment provides the approval boundary (its # deployment_branch_policy restricts execution to release/* branches) and the -# NuGet trusted publishing identity. This workflow additionally requires the -# candidate's base package version to be an exact stable release (no -# prerelease label or build metadata, e.g. "2.0.0") - a preview candidate is -# already published automatically by nuget-publish.yml and must never be -# promoted here. See docs/ReleaseProcess.md ("Approved promotion") for the -# full procedure, including how to pick a release_run_id and how to recover -# from a partial or failed promotion. +# NuGet trusted publishing identity. The "channel" input states what is being +# promoted and must match the candidate: "stable" (the default) requires an +# exact stable release (no prerelease label or build metadata, e.g. "2.0.0"); +# "preview" requires a public release-line preview (e.g. "2.0.0-preview.6"). +# nuget-publish.yml only ever pushes previews to GitHub Packages, so this is +# the one path that puts a preview on nuget.org. See docs/ReleaseProcess.md +# ("Approved promotion" and "Preview release") for the full procedures, +# including how to pick a release_run_id and how to recover from a partial or +# failed promotion. # # One-time nuget.org trusted publishing policy: # Repository Owner: OPCFoundation @@ -25,6 +27,14 @@ on: description: Successful nuget-publish.yml run ID from a release/. branch required: true type: string + channel: + description: What the candidate is - an exact stable release, or a release-line preview (e.g. 2.0.0-preview.6) + required: false + type: choice + options: + - stable + - preview + default: stable dry_run: description: Validate the selected package set without publishing required: false @@ -157,6 +167,7 @@ jobs: RELEASE_RUN_ID: ${{ inputs.release_run_id }} RELEASE_BRANCH: ${{ steps.source.outputs.branch }} RELEASE_COMMIT: ${{ steps.source.outputs.commit }} + RELEASE_CHANNEL: ${{ inputs.channel }} run: | . ./.azurepipelines/package-version-policy.ps1 $manifestPath = 'promotion/release-manifest.json' @@ -182,24 +193,53 @@ jobs: $manifest.packageVersion } - # This workflow exists solely to promote a stable release (e.g. - # "2.0.0", "2.0.1", "2.1.0") to nuget.org. In-development preview - # candidates are already published automatically by - # nuget-publish.yml and must never be promoted here. See - # docs/ReleaseProcess.md ("Approved promotion"). - if (-not (Test-StablePackageVersion -Version $expectedBaseVersion)) { + # The dispatcher states which kind of release this is, and the + # candidate must be exactly that: a stable promotion can never + # publish a preview by accident, nor the reverse. A preview must be + # a public release-line build ("M.m.p-preview.N", no commit id), so + # master or feature-branch previews can never reach nuget.org. See + # docs/ReleaseProcess.md ("Approved promotion", "Preview release"). + $channel = $env:RELEASE_CHANNEL + if ($channel -cne 'stable' -and $channel -cne 'preview') { + throw "channel must be 'stable' or 'preview', not '$channel'." + } + if ($channel -ceq 'stable' -and + -not (Test-StablePackageVersion -Version $expectedBaseVersion)) { throw ( "Run $($env:RELEASE_RUN_ID) produced base version " + "'$expectedBaseVersion', which is not an exact stable release. Only a " + 'stable candidate (no prerelease label or build metadata) may be ' + - 'promoted through this workflow.') + "promoted with channel 'stable'; dispatch with channel 'preview' to " + + 'publish a release-line preview.') + } + if ($channel -ceq 'preview' -and + -not (Test-PromotablePreviewPackageVersion -Version $expectedBaseVersion)) { + throw ( + "Run $($env:RELEASE_RUN_ID) produced base version " + + "'$expectedBaseVersion', which is not a release-line preview. Only an " + + "'M.m.p-preview.N' candidate without a commit id may be promoted with " + + "channel 'preview'.") + } + # schemaVersion 1 manifests predate the recorded channel. They stay + # promotable as stable, but a preview must prove its channel. + if ($channel -ceq 'preview' -and $manifest.schemaVersion -ne 2) { + throw ( + "The promotion manifest is schemaVersion $($manifest.schemaVersion), which " + + "records no channel. Only a schemaVersion 2 candidate may be promoted " + + "with channel 'preview'.") + } + if ($manifest.schemaVersion -eq 2 -and $manifest.channel -cne $channel) { + throw ( + "The promotion manifest records channel '$($manifest.channel)', but this " + + "promotion was dispatched with channel '$channel'.") } if (-not (Test-CanonicalReleaseBranchForPackageVersion ` -Ref $expectedRef ` - -Version $expectedBaseVersion)) { + -Version $expectedBaseVersion ` + -AllowPreview:($channel -ceq 'preview'))) { throw ( "Candidate version '$expectedBaseVersion' does not match release branch " + - "'$expectedRef'. A stable M.m.p candidate must be promoted only from " + + "'$expectedRef'. An M.m.p candidate must be promoted only from " + 'the matching release/M.m branch.') } @@ -309,7 +349,7 @@ jobs: } | Sort-Object Name) if ($packages.Count -eq 0) { - throw 'The stable candidate contains no non-Debug packages to push to nuget.org.' + throw 'The candidate contains no non-Debug packages to push to nuget.org.' } Write-Host ( "Publishing $($packages.Count) non-Debug package(s) to nuget.org. " + @@ -342,7 +382,9 @@ jobs: # any feed automatically (see its "Skip feed publication" step); this # is the one place a stable release also gets mirrored to GitHub # Packages, gated behind the same approval and dry-run switch as the - # nuget.org push above. + # nuget.org push above. A preview candidate was already pushed here by + # its own nuget-publish.yml run, so this loop is a verified no-op for + # it: the preflight and post-push checks prove those are its bytes. if: ${{ !inputs.dry_run }} shell: pwsh env: @@ -378,6 +420,7 @@ jobs: env: RELEASE_RUN_ID: ${{ inputs.release_run_id }} RELEASE_BRANCH: ${{ steps.source.outputs.branch }} + RELEASE_CHANNEL: ${{ inputs.channel }} RELEASE_DRY_RUN: ${{ inputs.dry_run }} run: | $manifestPath = 'promotion/release-manifest.json' @@ -400,6 +443,7 @@ jobs: - Source run: ``$($env:RELEASE_RUN_ID)`` - Source branch: ``$($env:RELEASE_BRANCH)`` + - Channel: ``$($env:RELEASE_CHANNEL)`` - Version: ``$version`` - Packages: ``$count`` - Dry run: ``$($env:RELEASE_DRY_RUN)`` diff --git a/docs/ReleaseProcess.md b/docs/ReleaseProcess.md index abf2c9bd37..5a6301e9e9 100644 --- a/docs/ReleaseProcess.md +++ b/docs/ReleaseProcess.md @@ -145,6 +145,7 @@ Every procedure below starts here. | A production-only bug needs a fix that did not start on `master` | [Emergency hotfix](#emergency-hotfix) | | You have a `nuget-publish.yml` run and want to inspect it before/without publishing | [Candidate and dry run](#candidate-and-dry-run) | | A maintainer has approved a stable candidate for publication | [Approved promotion](#approved-promotion) | +| Publishing a numbered preview (e.g. `2.0.0-preview.6`) to nuget.org | [Preview release](#preview-release) | | A release or promotion attempt errored, or you are unsure what published | [Failed or partial release](#failed-or-partial-release) | | You just promoted a release and want to confirm everything is consistent | [Post-release verification](#post-release-verification) | @@ -159,7 +160,10 @@ Every procedure below starts here. (`release/2.0`, `release/2.1`, ...). While stabilizing, its root version is also `-preview.{height}` (e.g. `2.0.1-preview.{height}` between the `2.0.0` and `2.0.1` releases) and publishes automatically to GitHub Packages, same - as `master`. At the exact commit approved for release, its root version is + as `master`. Such a release-line preview (`M.m.p-preview.N`, no commit id) + reaches nuget.org only through the explicitly dispatched + [Preview release](#preview-release) procedure; `master` previews carry a + commit id and never can. At the exact commit approved for release, its root version is set to the plain `..` (e.g. `2.0.0`, `2.0.1`, `2.1.0`) - `channel: stable` - and that specific package set is only ever published by the [Approved promotion](#approved-promotion) procedure, never @@ -175,8 +179,9 @@ Every procedure below starts here. `publicReleaseRefSpec` (so only that ref shape ever computes `NBGV_PublicRelease == 'True'`), the `Aggregate and validate packages` step in `nuget-publish.yml` (refuses to build a stable set from a non-canonical - ref), `release.yml` (refuses to promote from a non-canonical ref or a - non-stable candidate), and the `release` GitHub Environment's + ref), `release.yml` (refuses to promote from a non-canonical ref, and + refuses any candidate whose version does not match the dispatched `channel` + input, which defaults to `stable`), and the `release` GitHub Environment's `deployment_branch_policy` (restricts which branch may even execute the `release.yml` job). - The branch and stable package version must agree: `release/2.0` may @@ -454,8 +459,9 @@ maintainer can subsequently approve [Approved promotion](#approved-promotion). exact stable `major.minor.patch`, or the two disagree; print `$manifest.ref` and `$manifest.basePackageVersion` to see which. For a **preview** candidate this predicate returns `False` by design, because a preview - version is not a stable release version - check the shape alone with - `Test-CanonicalReleaseBranchRef -Ref $manifest.ref` in that case. + version is not a stable release version. For a preview you intend to + publish with [Preview release](#preview-release), pass `-AllowPreview`, + exactly as `release.yml` does for a `channel: preview` dispatch. For a stable candidate, also prove that the preview-only families in the set still sort strictly above everything already published, using the same @@ -592,6 +598,77 @@ fails the promotion instead of being silently skipped. [Patch release](#patch-release), or [Minor release](#minor-release)) to complete its final "advance to the next preview version" step. +## Preview release + +Publishes a numbered preview of a release line (e.g. `2.0.0-preview.6`) to +nuget.org. `nuget-publish.yml` only ever pushes previews to GitHub Packages, +so this is the only way a preview reaches nuget.org. It reuses the +[Approved promotion](#approved-promotion) machinery, dispatched with +`channel=preview`. + +**Owner**: a repository maintainer with `release` GitHub Environment +reviewer access. + +**Preconditions**: the [common preflight](#common-preflight) is complete; the +canonical `release/.` branch exists; nothing on nuget.org or +GitHub Packages already holds the preview number you intend to publish. + +What stays the same for the GitHub Packages preview feed: + +- `master` keeps publishing `-preview..g` to GitHub + Packages automatically. Those versions carry a commit id, so they can never + collide with a bare release-line `-preview.N`, and `release.yml` + rejects them for `channel=preview`. +- Every build of the release branch already pushed its own + `-preview.N` to GitHub Packages. `release.yml`'s GitHub Packages step + is therefore a verified no-op for a preview: the identity preflight and the + post-push check prove the feed holds this candidate's exact bytes. + +1. **Choose the preview number.** A canonical release branch is a public + release for Nerdbank.GitVersioning, so its previews are + `-preview.`, and a branch freshly cut + from `master` inherits `master`'s height. To make the next build carry a + specific number (for example to continue after `2.0.0-preview.5`), commit + a `versionHeightOffset` change on the release branch through a normal PR, + and check the result on that commit before merging: + ```powershell + nbgv get-version -v NuGetPackageVersion + ``` + *Completion evidence*: it prints exactly the intended version, e.g. + `2.0.0-preview.6`. The offset is negative when `master`'s height is already + past the target. Each later commit on the branch increases the number by + one, so publish the build of the exact commit you verified. + +2. Find that commit's successful `nuget-publish.yml` run and inspect it as + in [Candidate and dry run](#candidate-and-dry-run) steps 1-2. + *Completion evidence*: `channel` is `preview`, `basePackageVersion` is the + intended version with no `.g` suffix, and `ref` is the release + branch. + +3. Dry-run, then publish, from the same release branch: + ```powershell + gh workflow run release.yml --ref release/. ` + -f release_run_id= -f channel=preview -f dry_run=true + gh workflow run release.yml --ref release/. ` + -f release_run_id= -f channel=preview -f dry_run=false + ``` + Approve the `release` environment deployment when prompted. + *Completion evidence*: the run passes "Validate promotion manifest and + package bytes", reports its nuget.org publication byte-identical to the + candidate, and the summary shows `Channel: preview`. A candidate whose + version does not match the dispatched channel fails in the validation + step, before anything is published. + +4. Confirm the preview is live on nuget.org: + ```powershell + Invoke-RestMethod 'https://api.nuget.org/v3-flatcontainer/opcfoundation.netstandard.opc.ua.core/index.json' | + Select-Object -ExpandProperty versions | Select-Object -Last 3 + ``` + *Completion evidence*: the intended preview version appears. Recover a + partial preview publication exactly as in + [Failed or partial release](#failed-or-partial-release), keeping + `channel=preview` on the re-run. + ## Failed or partial release Recovers from an error anywhere in [Candidate and dry run](#candidate-and-dry-run) diff --git a/tests/Opc.Ua.Tools.Tests/PackageVersionPolicyTests.cs b/tests/Opc.Ua.Tools.Tests/PackageVersionPolicyTests.cs index c61c315055..75cf58210c 100644 --- a/tests/Opc.Ua.Tools.Tests/PackageVersionPolicyTests.cs +++ b/tests/Opc.Ua.Tools.Tests/PackageVersionPolicyTests.cs @@ -280,6 +280,98 @@ namespace Opc.Ua.Tools.Tests Assert.That(result.GetProperty("actual").GetBoolean(), Is.EqualTo(expectedMatch)); } + [TestCase("2.0.0-preview.6", true, Description = "Public release-line preview")] + [TestCase("2.0.1-preview.0", true, Description = "Zero preview number")] + [TestCase("2.1.0-preview.12", true, Description = "Multi-digit preview number")] + [TestCase("2.0.0", false, Description = "Stable is not a preview")] + [TestCase("2.0.0-preview.1.gabc123def0", false, Description = "Non-public build carries a commit id")] + [TestCase("2.0.0-preview.6+gabc123def0", false, Description = "Build metadata")] + [TestCase("2.0.0-PREVIEW.6", false, Description = "Upper-case label")] + [TestCase("2.0.0-Preview.6", false, Description = "Mixed-case label")] + [TestCase("2.0.0-preview", false, Description = "Unnumbered preview")] + [TestCase("2.0.0-preview.06", false, Description = "Leading zero in the preview number")] + [TestCase("2.0.0-rc.1", false, Description = "Other prerelease label")] + [TestCase("2.0.0.0-preview.1", false, Description = "Four-component version")] + [TestCase("2.0-preview.6", false, Description = "Two-component version")] + public async Task TestPromotablePreviewPackageVersionAsync(string version, bool expectedPreview) + { + JsonElement result = await RunPolicyScriptAsync( + $$""" + . '{{PolicyScriptPath}}' + @{ actual = (Test-PromotablePreviewPackageVersion -Version '{{version}}') } | ConvertTo-Json + """).ConfigureAwait(false); + + Assert.That(result.GetProperty("actual").GetBoolean(), Is.EqualTo(expectedPreview)); + } + + [TestCase("refs/heads/release/2.0", "2.0.0-preview.6", true)] + [TestCase("refs/heads/release/2.0", "2.0.1-preview.3", true)] + [TestCase("refs/heads/release/2.0", "2.0.0", true, Description = "Stable still accepted")] + [TestCase("refs/heads/release/2.0", "2.1.0-preview.1", false, Description = "Other minor line")] + [TestCase("refs/heads/release/2.0", "2.0.0-PREVIEW.6", false, Description = "Upper-case label")] + [TestCase("refs/heads/release/2.0", "2.0.0-preview.6.gabc123def0", false, Description = "Non-public build")] + [TestCase("refs/heads/release/2.0", "2.0.0-preview.6+gabc123def0", false, Description = "Build metadata")] + [TestCase("refs/heads/release/2.0.0", "2.0.0-preview.6", false, Description = "Retired three-component branch")] + [TestCase("refs/heads/master", "2.0.0-preview.6", false, Description = "master never promotes")] + [TestCase("refs/heads/Release/2.0", "2.0.0-preview.6", false, Description = "Case-sensitive like nbgv")] + public async Task TestCanonicalReleaseBranchForPreviewPackageVersionAsync( + string ruleRef, + string version, + bool expectedMatch) + { + JsonElement result = await RunPolicyScriptAsync( + $$""" + . '{{PolicyScriptPath}}' + @{ + allowed = (Test-CanonicalReleaseBranchForPackageVersion -Ref '{{ruleRef}}' -Version '{{version}}' -AllowPreview) + stableOnly = (Test-CanonicalReleaseBranchForPackageVersion -Ref '{{ruleRef}}' -Version '{{version}}') + } | ConvertTo-Json + """).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(result.GetProperty("allowed").GetBoolean(), Is.EqualTo(expectedMatch)); + Assert.That( + result.GetProperty("stableOnly").GetBoolean(), + Is.EqualTo(expectedMatch && !version.Contains('-', StringComparison.Ordinal)), + "Without -AllowPreview the stable gate must keep rejecting every preview."); + }); + } + + [Test] + public void ReleaseWorkflowPromotesAPreviewOnlyWhenDispatchedAsOne() + { + // nuget-publish.yml never pushes to nuget.org, so release.yml is + // the only path there for a preview too. It must default to the + // stable channel, accept a preview only when the dispatcher asked + // for one, and require the candidate's recorded channel to agree. + string workflow = File.ReadAllText(ReleaseWorkflowPath); + + Assert.Multiple(() => + { + Assert.That( + workflow, + Does.Match(@"channel:\s*\n(?:\s+.*\n)*?\s+default: stable\s*\n"), + "The channel input must default to 'stable'."); + Assert.That( + workflow, + Does.Contain("-AllowPreview:($channel -ceq 'preview')"), + "Only a preview dispatch may relax the release-branch version gate."); + Assert.That( + workflow, + Does.Contain("Test-PromotablePreviewPackageVersion -Version $expectedBaseVersion"), + "A preview dispatch must require a public release-line preview version."); + Assert.That( + workflow, + Does.Contain("$manifest.channel -cne $channel"), + "The candidate's recorded channel must match the dispatched channel."); + Assert.That( + workflow, + Does.Contain("$channel -ceq 'preview' -and $manifest.schemaVersion -ne 2"), + "A preview must come from a manifest that records its channel."); + }); + } + [Test] public async Task GetPreviewPackageBuildNumberMatchesCommittedPropsFileAsync() {