From 6bbf3d260b88a39a0a628736af75cfb98b10edf8 Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:43:48 +0100 Subject: [PATCH 01/17] fix(release): classify releases via cargo-semver-checks vs crates.io baseline The release cascade decided each crate's change type from a [package.metadata.cargo_check_external_types] allowlist heuristic to guess whether a dependent exposed a changed dependency. That allowlist drifts from the real public API, so an exposed dependency's breaking change could be mis-cascaded as a patch (AB#7577159). Replace the heuristic with real API diffing: run cargo-semver-checks against each crate's last published crates.io version (the tool's default baseline). Every release in the plan -- user-source roots and cascade-pulled dependents -- is classified from its own working-tree API diff. Dependents are floored at patch (they must re-release to pick up the new dependency) then raised to their own semver verdict. No fallback: cargo-semver-checks is a required tool and a crate that cannot be analysed is a hard error; a crate not yet on crates.io imposes no change-type floor. Also rework the CI semver job: baseline against crates.io (drop --baseline-rev), run only when the PR bumps a crate's [package] version (the publishing set), and keep it non-failing -- it posts/clears an informational sticky PR comment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/main.yml | 68 ++++++- docs/releasing.md | 64 ++++-- scripts/lib/release-flow.ps1 | 191 +++++++++++++----- scripts/lib/releasing.ps1 | 134 +++++++++--- scripts/release-packages.ps1 | 31 +-- .../tests/Pester/_common/Invoke-Scenario.ps1 | 12 ++ ...table-version-minor-distinct.scenario.psd1 | 4 + ...3-pin-with-cascade-satisfied.scenario.psd1 | 1 + ...14-pin-with-cascade-conflict.scenario.psd1 | 1 + ...-auto-upgrade-of-user-source.scenario.psd1 | 1 + ...S16-stable-cascade-elevation.scenario.psd1 | 11 +- ...-with-cascade-conflict-force.scenario.psd1 | 1 + .../Pester/scenarios/Scenarios.Tests.ps1 | 11 + .../ResolveReleaseSet.Tests.ps1 | 156 ++++++++------ .../unit/releasing/PureFunctions.Tests.ps1 | 66 ++++-- 15 files changed, 545 insertions(+), 207 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5a10505bb..232a3644d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -196,6 +196,13 @@ jobs: - name: Check Spelling run: just spellcheck + # Runs cargo-semver-checks against each crate's LAST PUBLISHED version on + # crates.io (the tool's default baseline — no --baseline-rev), scoped to the + # crates whose `version =` is bumped in this PR (the "publishing set"). The + # job is informational only: it never fails the build (continue-on-error) and + # posts / clears a sticky PR comment. It is skipped entirely on PRs that do + # not bump any crate version, since only version-bumping PRs trigger a + # publish and thus need a compatibility check against the published baseline. semver: needs: [delta] if: github.event_name == 'pull_request' && needs.delta.outputs.skip != 'true' @@ -215,37 +222,84 @@ jobs: rust-toolchain: RUST_LATEST cargo-tools: CARGO_SEMVER_CHECKS_VERSION - # execute + # Determine which crates have a bumped `[package] version = "..."` between + # the PR base and head. Only these are on a path to publish, so only these + # are checked against their crates.io baseline. Emits `--package NAME ...` + # (or empty) plus `publishing=true|false`. + - name: Compute Publishing Set + id: publish + shell: bash + env: + BASE_REF: ${{ github.base_ref }} + run: | + set -euo pipefail + git fetch --no-tags origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" + + pkg_args="" + # For every crates//Cargo.toml changed in this PR, compare the + # [package] version line at base vs head. A crate is "publishing" when + # its version line changed (added, or value differs). + while IFS= read -r manifest; do + [[ -z "$manifest" ]] && continue + [[ "$manifest" == crates/*/Cargo.toml ]] || continue + + base_ver=$(git show "origin/${BASE_REF}:${manifest}" 2>/dev/null \ + | awk '/^\[package\]/{p=1} p&&/^version[[:space:]]*=/{print;exit}' || true) + head_ver=$(awk '/^\[package\]/{p=1} p&&/^version[[:space:]]*=/{print;exit}' "$manifest" 2>/dev/null || true) + + if [[ -n "$head_ver" && "$base_ver" != "$head_ver" ]]; then + name=$(awk '/^\[package\]/{p=1} p&&/^name[[:space:]]*=/{gsub(/.*=[[:space:]]*"?|"?[[:space:]]*$/,"");print;exit}' "$manifest") + [[ -n "$name" ]] && pkg_args+=" --package $name" + fi + done < <(git diff --name-only "origin/${BASE_REF}...HEAD" -- 'crates/*/Cargo.toml') + + pkg_args="$(echo "$pkg_args" | sed 's/^ *//')" + if [[ -n "$pkg_args" ]]; then + echo "publishing=true" >> "$GITHUB_OUTPUT" + else + echo "publishing=false" >> "$GITHUB_OUTPUT" + fi + echo "packages=$pkg_args" >> "$GITHUB_OUTPUT" + echo "Publishing set: ${pkg_args:-}" + + # execute — only when the PR bumps at least one crate version. Baseline is + # crates.io (default: no --baseline-rev), so this validates the on-disk + # bump is sufficient for the API changes since the last published release. - name: Semver Compatibility id: semver + if: steps.publish.outputs.publishing == 'true' continue-on-error: true run: | set -o pipefail - cargo semver-checks --all-features --workspace ${{ needs.delta.outputs.exclude_not_affected }} --color never --baseline-rev origin/${{ github.base_ref }} 2>&1 | tee semver-output.txt + cargo semver-checks --all-features ${{ steps.publish.outputs.packages }} --color never 2>&1 | tee semver-output.txt # report - name: Prepare Semver Comment - if: steps.semver.outcome == 'failure' + if: steps.publish.outputs.publishing == 'true' && steps.semver.outcome == 'failure' run: | - echo "## ⚠️ Breaking Changes Detected" > semver-comment.txt + echo "## ⚠️ Breaking Changes Detected (vs last published version)" > semver-comment.txt + echo "" >> semver-comment.txt + echo "\`cargo semver-checks\` compared the crate(s) this PR is publishing against their latest **crates.io** release and found API changes that require a stronger version bump than the one on disk." >> semver-comment.txt echo "" >> semver-comment.txt echo '```' >> semver-comment.txt grep -v "^ *Cloning " semver-output.txt | grep -v "^ *Building " | grep -v "^ *Built \[" | grep -v "^ *Parsing " | grep -v "^ *Parsed \[" | grep -v "^ *Checking" | grep -v "^ *Checked \[" | grep -v "^ *Finished \[" | grep -v "^ *Summary " >> semver-comment.txt echo '```' >> semver-comment.txt echo "" >> semver-comment.txt - echo "If the breaking changes are intentional then everything is fine - this message is merely informative." >> semver-comment.txt + echo "If the breaking changes are intentional then everything is fine - this message is merely informative and does not block the merge." >> semver-comment.txt echo "" >> semver-comment.txt echo "Remember to apply a version number bump with the correct severity when publishing a version with breaking changes (\`1.x.x -> 2.x.x\` or \`0.1.x -> 0.2.x\`)." >> semver-comment.txt - name: Post Semver Failure Comment - if: steps.semver.outcome == 'failure' && github.event.pull_request.head.repo.full_name == github.repository + if: steps.publish.outputs.publishing == 'true' && steps.semver.outcome == 'failure' && github.event.pull_request.head.repo.full_name == github.repository uses: marocchino/sticky-pull-request-comment@v3.0.4 with: header: semver-check path: semver-comment.txt + # Clear a stale comment when the check now passes OR when the PR no longer + # publishes anything (publishing=false skips the check entirely). - name: Remove Semver Comment on Success - if: steps.semver.outcome == 'success' && github.event.pull_request.head.repo.full_name == github.repository + if: (steps.publish.outputs.publishing != 'true' || steps.semver.outcome == 'success') && github.event.pull_request.head.repo.full_name == github.repository uses: marocchino/sticky-pull-request-comment@v3.0.4 with: header: semver-check diff --git a/docs/releasing.md b/docs/releasing.md index 7187a4974..3ff081d79 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -203,31 +203,59 @@ Examples: After parsing the tokens, the planner walks the workspace dependency graph forward from every user-source release and adds each transitive -published dependent as a cascade-source release. The cascade's change -type for each dependent is derived from whether the user-source release -exposes (in its public API) the cascaded-from package — exposing -cascades propagate the source's change type, non-exposing cascades drop -to `patch`. +published dependent as a cascade-source release. The change type for +every release in the plan — both the directly-requested (user-source) +crates and the cascade-pulled dependents — is derived by running +[`cargo semver-checks`](https://crates.io/crates/cargo-semver-checks) +against each crate's **last published version on crates.io** (the tool's +default baseline). The current working-tree API is analysed, so a +coordinated release's in-progress edits — including a dependency whose +public types a dependent re-exports — are reflected in the dependent's +own API diff. This replaces the former +`[package.metadata.cargo_check_external_types]` allowlist heuristic, +which could misfire when the allowlist drifted from the real public API +(see the bug that motivated this change: a breaking change in an exposed +dependency was cascaded as `patch` instead of `breaking`). + +Mapping from `cargo semver-checks` verdicts to change types: + +- major API change required → `breaking` +- minor API change required → `non-breaking` +- compatible / no update required → `patch` +- crate not yet on crates.io (never published) → no constraint + +Cascade dependents are floored at `patch` (they must re-release to pick +up the new dependency version even when their own public API is +unchanged), then raised to whatever their own `cargo semver-checks` +verdict requires. + +> **cargo-semver-checks is a hard dependency of the release scripts.** +> Install the version pinned in `constants.env` +> (`CARGO_SEMVER_CHECKS_VERSION`) with +> `cargo install cargo-semver-checks --version --locked`. There +> is no heuristic fallback — the script errors out if the tool is +> missing or the crate cannot be analysed. The planner enforces **topological consistency**: if a user-supplied -change type for a package is *weaker* than the cascade would compute, -the planner auto-upgrades it and notes the upgrade in the review output. -The caller's `-Packages` token is therefore a *lower bound*, not a -guarantee — the caller can always elevate further on the next iteration -of the review, but cannot suppress a cascade-imposed change type. +change type for a package is *weaker* than `cargo semver-checks` +requires (for that package or via a cascade), the planner auto-upgrades +it and notes the upgrade in the review output. The caller's `-Packages` +token is therefore a *lower bound*, not a guarantee — the caller can +always elevate further on the next iteration of the review, but cannot +suppress a semver-imposed change type. ### Errors the planner rejects - An explicit semver that is not strictly greater than the package's current on-disk version. (Always fatal — `-Force` does not relax this.) -- A user-supplied change type that pins the package *below* what the - cascade computes for it. (The planner can auto-upgrade ordinary - change-type tokens, but treats an explicit semver token as a hard - pin — if the explicit version is below what the cascade requires the - planner errors instead of silently overriding the caller. Pass - `-Force` to override: the pin is honored verbatim, the package's - effective change-type tag is still upgraded so further cascade - decisions are correct, and a warning is printed flagging that +- A user-supplied change type that pins the package *below* what + `cargo semver-checks` (or the cascade) computes for it. (The planner + can auto-upgrade ordinary change-type tokens, but treats an explicit + semver token as a hard pin — if the explicit version is below what the + analysis requires the planner errors instead of silently overriding + the caller. Pass `-Force` to override: the pin is honored verbatim, the + package's effective change-type tag is still upgraded so further + cascade decisions are correct, and a warning is printed flagging that consumers may break.) --- diff --git a/scripts/lib/release-flow.ps1 b/scripts/lib/release-flow.ps1 index 62f077e91..c59d51fc4 100644 --- a/scripts/lib/release-flow.ps1 +++ b/scripts/lib/release-flow.ps1 @@ -33,7 +33,10 @@ # Test-IsBreakingChange) and package-version readers (Get-CurrentVersion, # Get-PackageVersionFromRef). # - Workspace metadata (Get-WorkspaceMetadata, Get-WorkspacePackages, -# Invalidate-WorkspaceMetadataCache, Test-PackageExposesTarget, Get-AllTransitiveDependents). +# Invalidate-WorkspaceMetadataCache, Get-AllTransitiveDependents) and +# cargo-semver-checks classification (Invoke-CrateSemverCheck, +# ConvertFrom-SemverChecksOutput, Get-CrateRequiredChangeType, +# Get-StrongerChangeType). # - Modified-but-unreleased dependency analysis (Get-PackagesWithUnreleasedChanges, # Get-PackagesWithVersionChanges, Get-UnreleasedModifiedDependencies). . "$PSScriptRoot/releasing.ps1" @@ -246,6 +249,61 @@ function Get-TransitivePublishedDependentsFromBaseline { return @($dependents) } +# Raises a resolved release-set entry's EffectiveChangeType to at least +# $RequiredChangeType, honouring the same explicit-pin rules the cascade uses: +# * Change-type-only entry: bump EffectiveChangeType + EffectiveTargetVersion, +# flag AutoUpgraded for user-source entries. +# * Pinned entry whose pin still satisfies the requirement: bump the tag, keep +# the pin. +# * Pinned entry whose pin undershoots: throw, unless -Force (then honour the +# pin verbatim, bump the tag, set PinHonoredAgainstCascade, and warn). +# No-op when the entry is already at or above the required change type. +# $RequirementLabel / $RequirementDetail are woven into the throw/warn messages +# so the user can see whether the requirement came from a cascade or from +# cargo-semver-checks analysing the crate's own API. +function Update-EntryForRequiredChangeType { + param( + [Parameter(Mandatory = $true)][pscustomobject]$Entry, + [Parameter(Mandatory = $true)][string]$RequiredChangeType, + [Parameter(Mandatory = $true)][string]$RequirementLabel, + [Parameter(Mandatory = $true)][string]$RequirementDetail, + [switch]$Force + ) + + $existingRank = $script:ChangeTypeRank[$Entry.EffectiveChangeType] + $newRank = $script:ChangeTypeRank[$RequiredChangeType] + if ($null -eq $existingRank) { $existingRank = 0 } + if ($null -eq $newRank) { $newRank = 0 } + if ($newRank -le $existingRank) { return } + + $requiredVersion = Get-NextVersion -currentVersion $Entry.CurrentVersion -ChangeType $RequiredChangeType + + if (-not [string]::IsNullOrEmpty($Entry.RequestedTargetVersion)) { + # User pinned an explicit version. Verify it numerically satisfies the + # requirement; if not, reject unless -Force honours it verbatim. + $cmpPin = Compare-SemanticVersions -version1 $Entry.RequestedTargetVersion -version2 $requiredVersion + if ($cmpPin -lt 0) { + if ($Force) { + Write-Warning "-Force: honoring explicit pin v$($Entry.RequestedTargetVersion) on '$($Entry.Folder)' even though $RequirementLabel requires at least v$requiredVersion ($RequirementDetail). The package's EffectiveChangeType tag is upgraded to '$RequiredChangeType' but the version on disk will be v$($Entry.RequestedTargetVersion). Consumers may break." + $Entry.EffectiveChangeType = $RequiredChangeType + $Entry.PinHonoredAgainstCascade = $true + } else { + throw "Cannot release '$($Entry.Folder)' as v$($Entry.RequestedTargetVersion): $RequirementLabel requires at least v$requiredVersion because of $RequirementDetail. Specify a higher version pin, use a change-type keyword, or pass -Force to honor the pin verbatim (consumers may break)." + } + } else { + # Pin still satisfies. Bump the tag so downstream cascade decisions + # are correct, but keep the pinned version. + $Entry.EffectiveChangeType = $RequiredChangeType + } + } else { + $Entry.EffectiveChangeType = $RequiredChangeType + $Entry.EffectiveTargetVersion = $requiredVersion + if ($Entry.Source -eq 'user') { + $Entry.AutoUpgraded = $true + } + } +} + # Turns the parsed token entries from Parse-ReleaseTokens into a *resolved # release set* — every package that will receive a release in this invocation, # whether the user asked for it directly or it was pulled in by cascade. @@ -307,14 +365,22 @@ function Get-TransitivePublishedDependentsFromBaseline { # overwrites the prior reason in place). # # Note: cascade is one-level. The set of dependents reachable from a user -# target is the transitive published dependents BFS, but the cascade-applied -# change type for each dependent is derived from exposure of the USER TARGET -# (not of any intermediate). Tightening the analysis is out of scope for the -# redesign. +# target is the transitive published dependents BFS; each dependent's +# cascade-applied change type is derived from cargo-semver-checks analysing the +# dependent's OWN current working-tree public API vs its last published version +# (floored at 'patch' — it must re-release to pick up the new dependency +# version even when its own API is unchanged). This replaces the former +# allowed_external_types exposure heuristic. function Resolve-ReleaseSet { param( [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$ParsedTokens, [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$WorkspaceBaseline, + # Classifier scriptblock: (folder, cargoName) -> 'breaking'|'non-breaking'| + # 'patch'|'none'. Decides each crate's minimum change type from its real + # API diff (cargo-semver-checks) vs its last published version. Production + # passes New-SemverChangeTypeClassifier; the default is a no-op ('none') + # so callers that only exercise version/pin/BFS math need not supply one. + [Parameter(Mandatory = $false)][scriptblock]$GetRequiredChangeType = { param($folder, $cargoName) 'none' }, [Parameter(Mandatory = $false)][switch]$Force ) @@ -329,8 +395,6 @@ function Resolve-ReleaseSet { $baselineByCargo[$pkg.Name.Replace('-', '_')] = $pkg } - $rank = @{ 'patch' = 1; 'non-breaking' = 2; 'breaking' = 3 } - $resolved = [ordered]@{} foreach ($req in $ParsedTokens) { @@ -390,16 +454,20 @@ function Resolve-ReleaseSet { $targetEntry = $resolved[$targetFolder] $targetPkg = $baselineByFolder[$targetFolder] - $targetIsBreaking = Test-IsBreakingChange -oldVersion $targetEntry.CurrentVersion -ChangeType $targetEntry.EffectiveChangeType - $exposingCascadeChangeType = if ($targetIsBreaking) { 'breaking' } else { $targetEntry.EffectiveChangeType } - $targetCargoNorm = $targetPkg.Name.Replace('-', '_') $reachable = Get-TransitivePublishedDependentsFromBaseline -Baseline $WorkspaceBaseline -TargetCargoName $targetCargoNorm foreach ($depFolder in $reachable) { $depPkg = $baselineByFolder[$depFolder] - $exposes = Test-PackageExposesTarget -dependent $depPkg -targetPackageName $targetPkg.Name - $dependentChangeType = if ($exposes) { $exposingCascadeChangeType } else { 'patch' } + + # The dependent's change type is decided by cargo-semver-checks + # analysing ITS OWN current working-tree API (which already reflects + # the target's in-progress changes, including re-exported types) vs + # its last published version — floored at 'patch' because it must be + # re-released to pick up the new dependency version even when its own + # public API is unchanged. This replaces the old + # allowed_external_types exposure heuristic. + $dependentChangeType = Get-StrongerChangeType 'patch' (& $GetRequiredChangeType $depPkg.Folder $depPkg.Name) $depBreakingForReason = Test-IsBreakingChange -oldVersion $depPkg.Version -ChangeType $dependentChangeType $cascadeReason = [pscustomobject]@{ @@ -426,45 +494,9 @@ function Resolve-ReleaseSet { $existing.CascadeReasons.Add($cascadeReason) } - $existingRank = $rank[$existing.EffectiveChangeType] - $newRank = $rank[$dependentChangeType] - if ($newRank -gt $existingRank) { - $cascadeRequiredVersion = Get-NextVersion -currentVersion $existing.CurrentVersion -ChangeType $dependentChangeType - - if (-not [string]::IsNullOrEmpty($existing.RequestedTargetVersion)) { - # User pinned an explicit version. Verify it numerically - # satisfies the cascade requirement; if not, the user - # has to revise their request — unless -Force was set, - # in which case we honor the pin verbatim, still bump - # the EffectiveChangeType tag, and flag the entry so - # the user sees a clear warning at plan-display time. - $cmpPin = Compare-SemanticVersions -version1 $existing.RequestedTargetVersion -version2 $cascadeRequiredVersion - if ($cmpPin -lt 0) { - if ($Force) { - $reasonsNames = ($existing.CascadeReasons | ForEach-Object { $_.Target } | Sort-Object -Unique) -join ', ' - Write-Warning "-Force: honoring explicit pin v$($existing.RequestedTargetVersion) on '$($existing.Folder)' even though cascade requires at least v$cascadeRequiredVersion (cascade sources: $reasonsNames). The package's EffectiveChangeType tag is upgraded to '$dependentChangeType' but the version on disk will be v$($existing.RequestedTargetVersion). Consumers may break." - $existing.EffectiveChangeType = $dependentChangeType - $existing.PinHonoredAgainstCascade = $true - } else { - $reasonsNames = ($existing.CascadeReasons | ForEach-Object { $_.Target } | Sort-Object -Unique) -join ', ' - throw "Cannot release '$($existing.Folder)' as v$($existing.RequestedTargetVersion): cascade requires at least v$cascadeRequiredVersion because of changes in: $reasonsNames. Specify a higher version pin, use a change-type keyword, or pass -Force to honor the pin verbatim (consumers may break)." - } - } else { - # Pin still satisfies. Bump the EffectiveChangeType tag - # (so cascade re-exposure decisions for this entry's - # own dependents — if we iterated them, which we don't - # at present — would be correct) but keep the pin as - # the version. - $existing.EffectiveChangeType = $dependentChangeType - } - } else { - $existing.EffectiveChangeType = $dependentChangeType - $existing.EffectiveTargetVersion = $cascadeRequiredVersion - if ($existing.Source -eq 'user') { - $existing.AutoUpgraded = $true - } - } - } + $reasonsNames = ($existing.CascadeReasons | ForEach-Object { $_.Target } | Sort-Object -Unique) -join ', ' + Update-EntryForRequiredChangeType -Entry $existing -RequiredChangeType $dependentChangeType ` + -RequirementLabel 'cascade' -RequirementDetail "changes in: $reasonsNames" -Force:$Force } else { $newEntry = [pscustomobject]@{ Folder = $depPkg.Folder @@ -486,9 +518,38 @@ function Resolve-ReleaseSet { } } + # Self-floor: raise each USER-source entry's change type to at least what + # cargo-semver-checks requires for its OWN public API vs its last published + # version. The cascade above already floored dependents; this catches + # user-source ROOTS that nothing cascades into (e.g. releasing a single leaf + # crate whose own API broke). Author intent is never downgraded — only raised + # when the real API diff demands a stronger change type. + foreach ($folder in $userFolders) { + $entry = $resolved[$folder] + $required = & $GetRequiredChangeType $entry.Folder $entry.Name + if ([string]::IsNullOrEmpty($required) -or $required -eq 'none') { continue } + Update-EntryForRequiredChangeType -Entry $entry -RequiredChangeType $required ` + -RequirementLabel 'cargo-semver-checks' -RequirementDetail "the crate's own public API changes" -Force:$Force + } + return @($resolved.Values) } +# Repo root for the default classifier, set by Invoke-ReleasePackagesMain before +# planning. A module-scope variable (rather than a captured closure) so the +# classifier scriptblock below resolves it in the module session state. +$script:ReleaseRepoRoot = $null + +# The production classifier handed to Resolve-ReleaseSet / Invoke-PlanReview. +# Defined at module scope so, when invoked via `& $GetRequiredChangeType` deep +# inside the planner, it resolves Get-CrateRequiredChangeType and +# $script:ReleaseRepoRoot in this module's session state — and Pester can Mock +# Get-CrateRequiredChangeType for tests. +$script:DefaultSemverClassifier = { + param([string]$folder, [string]$cargoName) + Get-CrateRequiredChangeType -Folder $folder -CargoName $cargoName -RepoRoot $script:ReleaseRepoRoot +} + function Format-ConventionalCommits { param( [string[]]$rawCommitMessages, @@ -1446,6 +1507,10 @@ function Invoke-PlanReview { [Parameter(Mandatory = $true)][string]$RepoRoot, [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$ParsedTokens, [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$WorkspaceBaseline, + # Classifier forwarded to Resolve-ReleaseSet. Defaults to the module-scope + # cargo-semver-checks classifier; tests that mock Resolve-ReleaseSet can + # omit it (the default is never invoked because the mock ignores it). + [Parameter(Mandatory = $false)][scriptblock]$GetRequiredChangeType = $script:DefaultSemverClassifier, [Parameter(Mandatory = $false)][hashtable]$ModifiedSnapshot, [Parameter(Mandatory = $false)][ValidateSet('targeted', 'all-changed')][string]$Mode = 'targeted', [Parameter(Mandatory = $false)][switch]$Force @@ -1510,7 +1575,7 @@ function Invoke-PlanReview { if ($Mode -eq 'all-changed' -and $userTokens.Count -eq 0) { $resolvedHash = @{} } else { - $resolvedArr = @(Resolve-ReleaseSet -ParsedTokens $userTokens.ToArray() -WorkspaceBaseline $WorkspaceBaseline -Force:$Force) + $resolvedArr = @(Resolve-ReleaseSet -ParsedTokens $userTokens.ToArray() -WorkspaceBaseline $WorkspaceBaseline -GetRequiredChangeType $GetRequiredChangeType -Force:$Force) $resolvedHash = @{} foreach ($e in $resolvedArr) { $resolvedHash[$e.Folder] = $e } } @@ -1593,7 +1658,7 @@ function Invoke-PlanReview { if ($Mode -eq 'all-changed' -and $userTokens.Count -eq 0) { $resolvedHash = @{} } else { - $resolvedArr = @(Resolve-ReleaseSet -ParsedTokens $userTokens.ToArray() -WorkspaceBaseline $WorkspaceBaseline -Force:$Force) + $resolvedArr = @(Resolve-ReleaseSet -ParsedTokens $userTokens.ToArray() -WorkspaceBaseline $WorkspaceBaseline -GetRequiredChangeType $GetRequiredChangeType -Force:$Force) $resolvedHash = @{} foreach ($e in $resolvedArr) { $resolvedHash[$e.Folder] = $e } } @@ -1977,6 +2042,14 @@ function Invoke-ReleasePackagesMain { Exit 1 } + # cargo-semver-checks decides every change type in the plan (against each + # crate's last published version). It is a hard dependency — there is no + # heuristic fallback — so fail fast with an actionable message if missing. + if (-not (Test-CommandExists -command 'cargo-semver-checks')) { + Write-Error "cargo-semver-checks is not installed or not found in your PATH. Install the version pinned in constants.env (CARGO_SEMVER_CHECKS_VERSION) with 'cargo install cargo-semver-checks --version --locked'. It is required to classify releases against their last published versions." + Exit 1 + } + $repoRoot = Get-Location if (-not (Test-Path (Join-Path $repoRoot '.git'))) { Write-Error 'This script must be run from the root of a Git repository.' @@ -2066,9 +2139,21 @@ function Invoke-ReleasePackagesMain { # the loop and feed Resolve-ReleaseSet on the next iteration just like # the targeted flow. $planReviewMode = if ($Mode -eq 'targeted') { 'targeted' } else { 'all-changed' } + + # Classifier passed to the planner: decides every change type in the plan + # (user-source and cascade) from each crate's real API diff vs its last + # published version — no allowed_external_types heuristic, no fallback. + # $script:DefaultSemverClassifier is a module-scope scriptblock (defined + # below Resolve-ReleaseSet) so it resolves Get-CrateRequiredChangeType and + # $script:ReleaseRepoRoot in the module session state; that also lets the + # test suites Mock Get-CrateRequiredChangeType. Get-CrateRequiredChangeType + # memoises per crate so the interactive review loop re-resolves cheaply. + $script:ReleaseRepoRoot = $repoRoot.Path + try { $resolvedHash = Invoke-PlanReview -RepoRoot $repoRoot.Path ` -ParsedTokens $parsedTokens -WorkspaceBaseline $workspaceBaseline ` + -GetRequiredChangeType $script:DefaultSemverClassifier ` -ModifiedSnapshot $modifiedSnapshot -Mode $planReviewMode -Force:$Force } catch { Write-Error $_.Exception.Message diff --git a/scripts/lib/releasing.ps1 b/scripts/lib/releasing.ps1 index d340a1b58..a34bebf6a 100644 --- a/scripts/lib/releasing.ps1 +++ b/scripts/lib/releasing.ps1 @@ -295,6 +295,24 @@ function Test-IsBreakingChange { return $true } +# Ordinal rank of a change type, used to compute the stronger of two change +# types. 'none' means "no constraint" (e.g. cargo-semver-checks found nothing to +# compare against) and ranks below every real change type. +$script:ChangeTypeRank = @{ 'none' = 0; 'patch' = 1; 'non-breaking' = 2; 'breaking' = 3 } + +# Returns whichever of two change types is the stronger (higher-ranked). Unknown +# or empty inputs are treated as 'none' (rank 0). Ties return $A. +function Get-StrongerChangeType { + param( + [AllowNull()][AllowEmptyString()][string]$A, + [AllowNull()][AllowEmptyString()][string]$B + ) + $ra = $script:ChangeTypeRank[$A]; if ($null -eq $ra) { $ra = 0 } + $rb = $script:ChangeTypeRank[$B]; if ($null -eq $rb) { $rb = 0 } + if ($rb -gt $ra) { return $B } + return $A +} + # Reads the [package] table's `version = "..."` from a Cargo.toml on disk. function Get-CurrentVersion { param([string]$cargoTomlPath) @@ -410,6 +428,33 @@ function Reset-ReleaseScriptCaches { $script:PackageLastReleaseBaselineCache = $null $script:PackageCommittedChangesCache = $null $script:PackageVersionAtRefCache = $null + $script:CrateSemverVerdictCache = $null +} + +# Memoised, mockable classifier: returns the minimum change type a crate's +# current working-tree public API requires versus its last published version +# ('breaking' / 'non-breaking' / 'patch' / 'none' when never published), by +# running cargo-semver-checks once per crate. Resolve-ReleaseSet is invoked many +# times during the interactive review loop, so results are cached per cargo name +# for the run. Test suites Mock this function to supply deterministic verdicts +# without invoking the real tool (see the scenario harness). +function Get-CrateRequiredChangeType { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$Folder, + [Parameter(Mandatory = $true)][string]$CargoName, + [Parameter(Mandatory = $true)][string]$RepoRoot + ) + + if ($null -eq $script:CrateSemverVerdictCache) { $script:CrateSemverVerdictCache = @{} } + if ($script:CrateSemverVerdictCache.ContainsKey($CargoName)) { + return $script:CrateSemverVerdictCache[$CargoName] + } + + Write-Host "🔎 cargo semver-checks: analysing '$CargoName' against its last published version..." -ForegroundColor Cyan + $result = Invoke-CrateSemverCheck -PackageName $CargoName -RepoRoot $RepoRoot + $script:CrateSemverVerdictCache[$CargoName] = $result + return $result } # Returns information about all workspace packages as an array of objects with: @@ -417,8 +462,6 @@ function Reset-ReleaseScriptCaches { # Folder - folder name under crates/ (used as the script's PackageName argument) # Published - $true if the package is published to crates.io # Deps - array of normalized dependency names (kind 'normal' or 'build', not 'dev') -# AllowedExternalTypes - array of strings from [package.metadata.cargo_check_external_types], -# or $null if the package does not declare them. function Get-WorkspacePackages { param([string]$repoRoot) @@ -439,53 +482,84 @@ function Get-WorkspacePackages { } } - $allowedTypes = $null - $pkgMeta = $package.PSObject.Properties['metadata'] - if ($pkgMeta -and $null -ne $pkgMeta.Value) { - $cet = $pkgMeta.Value.PSObject.Properties['cargo_check_external_types'] - if ($cet -and $null -ne $cet.Value) { - $aet = $cet.Value.PSObject.Properties['allowed_external_types'] - if ($aet -and $null -ne $aet.Value) { - $allowedTypes = @($aet.Value) - } - } - } - $packages += [pscustomobject]@{ Name = $package.name Folder = Split-Path $manifestDir -Leaf Version = $package.version Published = -not ($null -ne $package.publish -and $package.publish.Count -eq 0) Deps = $deps - AllowedExternalTypes = $allowedTypes } } return $packages } -# Returns $true if the dependent package exposes any type rooted at the target package -# in its public API, as declared by [package.metadata.cargo_check_external_types]. -# Conservative when metadata is missing. -function Test-PackageExposesTarget { +# Runs `cargo semver-checks` for a single crate against its last published version +# (the crates.io / registry baseline — cargo-semver-checks' default when no +# --baseline-* flag is passed) and returns the minimum change type the current +# working-tree API requires: 'breaking', 'non-breaking', 'patch', or 'none' when +# the crate has never been published (no baseline to compare against). +# +# The current API is analysed from the working tree, so a coordinated release's +# in-progress source edits (including a dependency whose public types this crate +# re-exports) are reflected — this is what lets an exposed-dependency breaking +# change cascade correctly, without the old allowed_external_types heuristic. +function Invoke-CrateSemverCheck { + [CmdletBinding()] param( - [pscustomobject]$dependent, - [string]$targetPackageName + [Parameter(Mandatory = $true)][string]$PackageName, + [Parameter(Mandatory = $true)][string]$RepoRoot ) - if ($null -eq $dependent.AllowedExternalTypes) { - return $true + Push-Location $RepoRoot + try { + # Manage the exit code manually; cargo-semver-checks exits non-zero when a + # bump is required, which is expected and not an error for our purposes. + $PSNativeCommandUseErrorActionPreference = $false + $output = & cargo semver-checks --package $PackageName --all-features --color never 2>&1 | Out-String + $exitCode = $LASTEXITCODE + } finally { + Pop-Location } - $normalizedTarget = $targetPackageName.Replace('-', '_') - foreach ($entry in $dependent.AllowedExternalTypes) { - $root = ($entry -split '::', 2)[0] - if ($root -eq $normalizedTarget) { - return $true - } + return ConvertFrom-SemverChecksOutput -Output $output -ExitCode $exitCode -PackageName $PackageName +} + +# Parses `cargo semver-checks` combined output into a change type. Pure (no I/O) +# so it can be unit-tested against captured tool output. Mapping: +# * "N major and M minor checks failed" -> major>0 breaking; minor>0 non-breaking +# * "no semver update required" -> patch (compatible) +# * baseline not found on the registry (never published) -> none +# * anything else (tool/network/build failure) -> throw (no silent fallback) +function ConvertFrom-SemverChecksOutput { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Output, + [int]$ExitCode = 0, + [string]$PackageName = '' + ) + + $m = [regex]::Match($Output, '(?i)(\d+)\s+major\s+and\s+(\d+)\s+minor\s+check') + if ($m.Success) { + if ([int]$m.Groups[1].Value -gt 0) { return 'breaking' } + if ([int]$m.Groups[2].Value -gt 0) { return 'non-breaking' } + return 'patch' + } + + if ($Output -match '(?i)no\s+semver\s+update\s+required') { + return 'patch' + } + + # No baseline: the crate (or a specific version) is not on the registry. This + # is the expected state for a brand-new, never-published crate — there is + # nothing to compare against, so it imposes no change-type floor. + if ($Output -match '(?i)not\s+found\s+in\s+(the\s+)?registry' -or + $Output -match '(?i)failed\s+to\s+retrieve\s+crate\s+data\s+from\s+registry' -or + $Output -match '(?i)no\s+(released|published)\s+versions?') { + return 'none' } - return $false + throw "cargo semver-checks did not produce a parseable result for '$PackageName' (exit $ExitCode). This usually means the tool is missing, the network/registry is unreachable, or the crate failed to build. Output:`n$Output" } # BFS over the reverse dependency graph. Returns the folder names of all published diff --git a/scripts/release-packages.ps1 b/scripts/release-packages.ps1 index 173155daa..f1a54760b 100644 --- a/scripts/release-packages.ps1 +++ b/scripts/release-packages.ps1 @@ -55,20 +55,23 @@ Cargo's 0.x.y SemVer rules are honored throughout: for `0.x.y` packages a Breaking change becomes `0.(x+1).0`, NonBreaking and Patch both map to - incrementing `y`. The dependent cascade also respects - `[package.metadata.cargo_check_external_types]`: a dependent that does - not expose any type rooted at the released package cascades as a `patch` - rather than mirroring the target's change type. Dev-only dependents are - skipped — they automatically pick up the new workspace version. - - User-provided change types may be automatically upgraded by cascade - analysis if dependency exposure rules require a stronger change type - (e.g. an exposing dependent of a breaking release is upgraded from - your requested `patch` to `breaking`). If an explicit version number - is specified for a package and cascade logic requires a higher - version number than the pin allows, the release plan is rejected - (or, with -Force, the pin is honored verbatim and a warning is - printed flagging that consumers may break). + incrementing `y`. Every release in the plan is classified by running + `cargo semver-checks` against each crate's last published crates.io + version: a dependent whose own public API is unaffected by the release + cascades as a `patch` (it still re-releases to pick up the new dependency + version), while a dependent whose API actually changes (e.g. because it + re-exports a changed type) cascades at the severity `cargo semver-checks` + reports. Dev-only dependents are skipped — they automatically pick up the + new workspace version. cargo-semver-checks is a hard dependency (install + the version pinned in constants.env); there is no heuristic fallback. + + User-provided change types may be automatically upgraded by this analysis + if the crate's real API diff requires a stronger change type (e.g. a + dependent that re-exports a breaking change is upgraded from your requested + `patch` to `breaking`). If an explicit version number is specified for a + package and the analysis requires a higher version number than the pin + allows, the release plan is rejected (or, with -Force, the pin is honored + verbatim and a warning is printed flagging that consumers may break). .PARAMETER Packages The list of workspace packages to release, in the form diff --git a/scripts/tests/Pester/_common/Invoke-Scenario.ps1 b/scripts/tests/Pester/_common/Invoke-Scenario.ps1 index 594f5870a..9ed88768f 100644 --- a/scripts/tests/Pester/_common/Invoke-Scenario.ps1 +++ b/scripts/tests/Pester/_common/Invoke-Scenario.ps1 @@ -82,6 +82,18 @@ function Invoke-Scenario { $script:ScenarioRepliesGiven = New-Object System.Collections.Generic.List[string] $script:ScenarioSkippedPromptFolders = New-Object System.Collections.Generic.List[string] + # Simulated cargo-semver-checks verdicts (folder -> 'breaking'|'non-breaking'| + # 'patch'|'none'), consumed by the Get-CrateRequiredChangeType mock the test + # installs. Absent entries default to 'none' (no constraint) — the cascade + # then floors dependents at 'patch'. Scenarios that need a stronger cascade + # (e.g. a dependent whose own API broke) declare Run.SemverVerdicts. + $script:ScenarioSemverVerdicts = @{} + if ($scenario.Run.SemverVerdicts) { + foreach ($k in $scenario.Run.SemverVerdicts.Keys) { + $script:ScenarioSemverVerdicts[$k] = $scenario.Run.SemverVerdicts[$k] + } + } + # --- 4. Invoke under mocks. The caller (test) has already mocked the # script-level cmdlets by the time this runs. We only invoke the entry # point and capture the release records + any thrown exception. diff --git a/scripts/tests/Pester/scenarios/S11-stable-version-minor-distinct.scenario.psd1 b/scripts/tests/Pester/scenarios/S11-stable-version-minor-distinct.scenario.psd1 index 2ccf31f9c..409815589 100644 --- a/scripts/tests/Pester/scenarios/S11-stable-version-minor-distinct.scenario.psd1 +++ b/scripts/tests/Pester/scenarios/S11-stable-version-minor-distinct.scenario.psd1 @@ -22,6 +22,10 @@ Run = @{ Packages = @('dependent@patch') + # dependent re-exports dependency's public types; when dependency lands a + # non-breaking change, dependent's own API gains those additions, so + # cargo-semver-checks classifies dependent as non-breaking (→ 1.1.0). + SemverVerdicts = @{ dependent = 'non-breaking' } Answers = @( # On a stable >=1.x.y package the menu offers [1-5]; '4' selects the # minor (non-breaking) path, distinct from the patch path of option 5. diff --git a/scripts/tests/Pester/scenarios/S13-pin-with-cascade-satisfied.scenario.psd1 b/scripts/tests/Pester/scenarios/S13-pin-with-cascade-satisfied.scenario.psd1 index da9467b6d..e381ee446 100644 --- a/scripts/tests/Pester/scenarios/S13-pin-with-cascade-satisfied.scenario.psd1 +++ b/scripts/tests/Pester/scenarios/S13-pin-with-cascade-satisfied.scenario.psd1 @@ -21,6 +21,7 @@ # because dependent exposes target. User pins dependent at 5.0.0 which # satisfies the cascade requirement, so the pin wins. Packages = @('target@breaking', 'dependent@5.0.0') + SemverVerdicts = @{ dependent = 'breaking' } Answers = @() } diff --git a/scripts/tests/Pester/scenarios/S14-pin-with-cascade-conflict.scenario.psd1 b/scripts/tests/Pester/scenarios/S14-pin-with-cascade-conflict.scenario.psd1 index 159d842b2..36cb70574 100644 --- a/scripts/tests/Pester/scenarios/S14-pin-with-cascade-conflict.scenario.psd1 +++ b/scripts/tests/Pester/scenarios/S14-pin-with-cascade-conflict.scenario.psd1 @@ -21,6 +21,7 @@ # User pinned dependent at 1.0.1 which is below the cascade requirement → # Resolve-ReleaseSet must throw. Packages = @('target@breaking', 'dependent@1.0.1') + SemverVerdicts = @{ dependent = 'breaking' } Answers = @() } diff --git a/scripts/tests/Pester/scenarios/S15-auto-upgrade-of-user-source.scenario.psd1 b/scripts/tests/Pester/scenarios/S15-auto-upgrade-of-user-source.scenario.psd1 index 55b258499..5d0cc9ffb 100644 --- a/scripts/tests/Pester/scenarios/S15-auto-upgrade-of-user-source.scenario.psd1 +++ b/scripts/tests/Pester/scenarios/S15-auto-upgrade-of-user-source.scenario.psd1 @@ -23,6 +23,7 @@ # dependent's EffectiveChangeType to breaking and EffectiveTargetVersion # to 2.0.0. Packages = @('target@breaking', 'dependent@patch') + SemverVerdicts = @{ dependent = 'breaking' } Answers = @() } diff --git a/scripts/tests/Pester/scenarios/S16-stable-cascade-elevation.scenario.psd1 b/scripts/tests/Pester/scenarios/S16-stable-cascade-elevation.scenario.psd1 index 605561ee0..5067f6781 100644 --- a/scripts/tests/Pester/scenarios/S16-stable-cascade-elevation.scenario.psd1 +++ b/scripts/tests/Pester/scenarios/S16-stable-cascade-elevation.scenario.psd1 @@ -24,6 +24,11 @@ This is the stable-version companion to S06 (the same flow on 0.x.y). Validates Run = @{ Packages = @('bottom@patch') + # Simulated cargo-semver-checks verdicts. 'top' re-exports 'middle' and + # its own public API gains 'middle's non-breaking additions, so the tool + # classifies top as non-breaking (→ 1.1.0). 'middle' itself is analysed + # as patch by the tool but the user elevates it via Invariant B review. + SemverVerdicts = @{ top = 'non-breaking' } Answers = @( # Invariant B: middle was cascade-pulled with a patch change # AND has pre-existing modifications. User elevates to @@ -36,9 +41,9 @@ This is the stable-version companion to S06 (the same flow on 0.x.y). Validates # bottom: 1.0.0 -> 1.0.1 (user-requested patch). # middle: cascade-released to 1.0.1, then escalated to 1.1.0 by the # post-release scan accepting it as non-breaking. - # top: cascade-released to 1.0.1 originally, then re-cascade-pulled - # to 1.1.0 because the exposing-cascade rule lifts dependents - # in lock-step with middle's non-breaking change. + # top: cascade-released to 1.1.0 because cargo-semver-checks + # classifies top's own public API change (from re-exporting + # middle's non-breaking additions) as non-breaking. Released = @( @{ Package = 'bottom'; To = '1.0.1' } @{ Package = 'middle'; To = '1.1.0' } diff --git a/scripts/tests/Pester/scenarios/S24-pin-with-cascade-conflict-force.scenario.psd1 b/scripts/tests/Pester/scenarios/S24-pin-with-cascade-conflict-force.scenario.psd1 index a352766a0..da16c3419 100644 --- a/scripts/tests/Pester/scenarios/S24-pin-with-cascade-conflict-force.scenario.psd1 +++ b/scripts/tests/Pester/scenarios/S24-pin-with-cascade-conflict-force.scenario.psd1 @@ -18,6 +18,7 @@ Run = @{ Packages = @('target@breaking', 'dependent@1.0.1') + SemverVerdicts = @{ dependent = 'breaking' } Force = $true Answers = @() } diff --git a/scripts/tests/Pester/scenarios/Scenarios.Tests.ps1 b/scripts/tests/Pester/scenarios/Scenarios.Tests.ps1 index 22bc5d69c..0483ec98e 100644 --- a/scripts/tests/Pester/scenarios/Scenarios.Tests.ps1 +++ b/scripts/tests/Pester/scenarios/Scenarios.Tests.ps1 @@ -47,6 +47,17 @@ Describe 'End-to-end release scenarios' { param([string]$Prompt) return Resolve-ScenarioPromptReply -Prompt $Prompt } -Verifiable:$false + + # Replace real cargo-semver-checks with the scenario's simulated verdict + # map (folder -> change type), so cascade/self-floor classification is + # deterministic and offline. Unmapped folders default to 'none'. + Mock -CommandName Get-CrateRequiredChangeType -MockWith { + param([string]$Folder, [string]$CargoName, [string]$RepoRoot) + if ($script:ScenarioSemverVerdicts -and $script:ScenarioSemverVerdicts.ContainsKey($Folder)) { + return $script:ScenarioSemverVerdicts[$Folder] + } + return 'none' + } -Verifiable:$false } It '' -ForEach $script:ScenarioFiles { diff --git a/scripts/tests/Pester/unit/release-packages/ResolveReleaseSet.Tests.ps1 b/scripts/tests/Pester/unit/release-packages/ResolveReleaseSet.Tests.ps1 index 53311fc79..e2c0aafb5 100644 --- a/scripts/tests/Pester/unit/release-packages/ResolveReleaseSet.Tests.ps1 +++ b/scripts/tests/Pester/unit/release-packages/ResolveReleaseSet.Tests.ps1 @@ -14,20 +14,32 @@ BeforeAll { [string] $Name = $null, [string] $Version = '0.1.0', [string[]] $Deps = @(), - [bool] $Published = $true, - $AllowedExternalTypes = $null + [bool] $Published = $true ) if ([string]::IsNullOrEmpty($Name)) { $Name = $Folder } return [pscustomobject]@{ - Folder = $Folder - Name = $Name - Version = $Version - Published = $Published - Deps = $Deps - AllowedExternalTypes = $AllowedExternalTypes + Folder = $Folder + Name = $Name + Version = $Version + Published = $Published + Deps = $Deps } } + # Builds a stub cargo-semver-checks classifier from a folder -> change-type + # map. Unmapped folders return 'none' (no constraint). Lets the cascade / + # self-floor logic be tested deterministically without invoking the real + # tool. The real classifier is New-SemverChangeTypeClassifier. + function New-StubClassifier { + param([hashtable]$Map = @{}) + return { + param([string]$Folder, [string]$CargoName) + $t = $Map[$Folder] + if ($t) { return $t } + return 'none' + }.GetNewClosure() + } + # Linear baseline: a → b → c → d (each depends on the previous). function New-LinearBaseline { return @( @@ -214,15 +226,19 @@ Describe 'Resolve-ReleaseSet' { $byFolder['d'].CascadeReasons[0].Target | Should -Be 'a' } - It 'cascades a breaking change as breaking to exposing dependents and as patch to non-exposing dependents' { - # a (breaking), b exposes a (so cascade -> breaking), c does NOT expose a (-> patch). + It 'classifies cascade dependents via cargo-semver-checks: API-broken dependent is breaking, unaffected dependent is patch' { + # a released breaking. b's own public API broke (semver-checks: + # breaking, e.g. it re-exports a changed type); c's did not + # (semver-checks: none) but c must still re-release to pick up new a + # => floored to patch. $baseline = @( (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) - (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a') -AllowedExternalTypes @('a')) - (New-BaselinePackage -Folder 'c' -Version '1.0.0' -Deps @('a') -AllowedExternalTypes @()) + (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) + (New-BaselinePackage -Folder 'c' -Version '1.0.0' -Deps @('a')) ) + $classifier = New-StubClassifier @{ b = 'breaking' } $parsed = Parse-ReleaseTokens -Tokens @('a@breaking') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline + $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier $byFolder = @{} foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } @@ -239,37 +255,36 @@ Describe 'Resolve-ReleaseSet' { $byFolder['c'].CascadeReasons[0].Breaking | Should -BeFalse } - It 'cascade BFS does NOT pass through cascade-source entries (one-level only)' { - # a -> b -> c. Releasing 'a' as patch makes b non-exposing→patch and - # c non-exposing→patch. Even if b *would* have been "breaking" if - # released directly, the cascade from a only ever asks for patch - # change types on transitive dependents (because b does not expose a's - # types and c does not expose a's types). + It 'derives each cascade dependent''s change type from its own semver-checks verdict, not the target''s' { + # a -> b -> c, releasing a as patch. b's own API is non-breaking; c's + # is unaffected. The dependent severities come from semver-checks on + # each dependent, independent of a's (patch) change type. $baseline = @( (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) - (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a') -AllowedExternalTypes @()) - (New-BaselinePackage -Folder 'c' -Version '1.0.0' -Deps @('b') -AllowedExternalTypes @()) + (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) + (New-BaselinePackage -Folder 'c' -Version '1.0.0' -Deps @('b')) ) + $classifier = New-StubClassifier @{ b = 'non-breaking' } $parsed = Parse-ReleaseTokens -Tokens @('a@patch') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline + $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier $byFolder = @{} foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } - $byFolder['b'].EffectiveChangeType | Should -Be 'patch' + $byFolder['b'].EffectiveChangeType | Should -Be 'non-breaking' $byFolder['c'].EffectiveChangeType | Should -Be 'patch' } } Context 'cascade auto-upgrade of user-source entries' { - It 'auto-upgrades a user-source patch to non-breaking when cascade requires it (and sets AutoUpgraded)' { - # a -> b. Release a as non-breaking, release b as patch. Cascade from - # a's non-breaking exposes b -> non-breaking, so b's patch request - # gets auto-upgraded. + It 'auto-upgrades a user-source patch to non-breaking when its own semver-checks verdict requires it (and sets AutoUpgraded)' { + # b requested as patch, but semver-checks says b's own API is + # non-breaking, so its change type is floored up and AutoUpgraded set. $baseline = @( (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) ) + $classifier = New-StubClassifier @{ b = 'non-breaking' } $parsed = Parse-ReleaseTokens -Tokens @('a@nonbreaking', 'b@patch') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline + $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier $byFolder = @{} foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } $byFolder['b'].Source | Should -Be 'user' @@ -279,25 +294,27 @@ Describe 'Resolve-ReleaseSet' { $byFolder['b'].EffectiveTargetVersion | Should -Be '1.1.0' } - It 'does NOT mark AutoUpgraded when the user requested the same change type the cascade asks for' { + It 'does NOT mark AutoUpgraded when the user requested the same change type semver-checks asks for' { $baseline = @( (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) ) + $classifier = New-StubClassifier @{ b = 'non-breaking' } $parsed = Parse-ReleaseTokens -Tokens @('a@nonbreaking', 'b@nonbreaking') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline + $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier $byFolder = @{} foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } $byFolder['b'].AutoUpgraded | Should -BeFalse } - It 'does NOT downgrade the user-supplied change type when cascade asks for a weaker change' { + It 'does NOT downgrade the user-supplied change type when semver-checks asks for a weaker change' { $baseline = @( (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) ) + $classifier = New-StubClassifier @{ b = 'patch' } $parsed = Parse-ReleaseTokens -Tokens @('a@patch', 'b@breaking') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline + $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier $byFolder = @{} foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } $byFolder['b'].EffectiveChangeType | Should -Be 'breaking' @@ -307,30 +324,33 @@ Describe 'Resolve-ReleaseSet' { } Context 'cascade interaction with explicit version pins' { - It 'keeps the pin when it numerically satisfies the cascade requirement' { - # a -> b. a non-breaking. b pinned to 1.5.0 (well above cascade 1.1.0 req). + It 'keeps the pin when it numerically satisfies the required version' { + # a non-breaking; b's own API non-breaking (required 1.1.0). b pinned + # to 1.5.0 (well above), so the pin is kept. $baseline = @( (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) ) + $classifier = New-StubClassifier @{ b = 'non-breaking' } $parsed = Parse-ReleaseTokens -Tokens @('a@nonbreaking', 'b@1.5.0') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline + $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier $byFolder = @{} foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } $byFolder['b'].EffectiveTargetVersion | Should -Be '1.5.0' $byFolder['b'].RequestedTargetVersion | Should -Be '1.5.0' } - It 'throws when the pin is numerically below the cascade requirement' { - # a breaking would require b 2.0.0 (cascade-required), but user pinned - # b at 1.1.0. Resolution must throw. + It 'throws when the pin is numerically below the required version' { + # b's own API broke (semver-checks: breaking) => requires 2.0.0, but + # user pinned b at 1.1.0. Resolution must throw. $baseline = @( (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) ) + $classifier = New-StubClassifier @{ b = 'breaking' } $parsed = Parse-ReleaseTokens -Tokens @('a@breaking', 'b@1.1.0') - { Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline } | - Should -Throw -ExpectedMessage "*Cannot release 'b' as v1.1.0*cascade requires*v2.0.0*" + { Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier } | + Should -Throw -ExpectedMessage "*Cannot release 'b' as v1.1.0*requires*v2.0.0*" } It 'mentions -Force in the rejection error message so the user knows about the override' { @@ -338,21 +358,23 @@ Describe 'Resolve-ReleaseSet' { (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) ) + $classifier = New-StubClassifier @{ b = 'breaking' } $parsed = Parse-ReleaseTokens -Tokens @('a@breaking', 'b@1.1.0') - { Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline } | + { Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier } | Should -Throw -ExpectedMessage '*-Force*' } - It '-Force honors the explicit pin verbatim when cascade requires a higher version' { - # a breaking would normally require b 2.0.0; user pinned b at 1.1.0. - # With -Force, b stays at 1.1.0 but the change-type tag is upgraded - # so any further cascade decisions for b would be correct. + It '-Force honors the explicit pin verbatim when a higher version is required' { + # b's own API broke => normally requires 2.0.0; user pinned b at + # 1.1.0. With -Force, b stays at 1.1.0 but the change-type tag is + # upgraded so any further cascade decisions for b would be correct. $baseline = @( (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) ) + $classifier = New-StubClassifier @{ b = 'breaking' } $parsed = Parse-ReleaseTokens -Tokens @('a@breaking', 'b@1.1.0') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -Force -WarningAction SilentlyContinue + $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier -Force -WarningAction SilentlyContinue $byFolder = @{} foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } $byFolder['b'].EffectiveTargetVersion | Should -Be '1.1.0' @@ -361,14 +383,15 @@ Describe 'Resolve-ReleaseSet' { $byFolder['b'].PinHonoredAgainstCascade | Should -BeTrue } - It '-Force emits a warning naming the package, the pin, the cascade-required minimum, and the cascade sources' { + It '-Force emits a warning naming the package, the pin, the required minimum, and the sources' { $baseline = @( (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) ) + $classifier = New-StubClassifier @{ b = 'breaking' } $parsed = Parse-ReleaseTokens -Tokens @('a@breaking', 'b@1.1.0') $warnings = @() - $null = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -Force -WarningVariable +warnings -WarningAction SilentlyContinue + $null = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier -Force -WarningVariable +warnings -WarningAction SilentlyContinue ($warnings -join "`n") | Should -Match '-Force' ($warnings -join "`n") | Should -Match "'b'" ($warnings -join "`n") | Should -Match 'v1\.1\.0' @@ -376,15 +399,16 @@ Describe 'Resolve-ReleaseSet' { ($warnings -join "`n") | Should -Match 'a' } - It '-Force does NOT set PinHonoredAgainstCascade when the pin already satisfies the cascade' { - # a non-breaking would require b 1.1.0 (cascade-required); user pinned - # b at 1.5.0 which already satisfies. -Force is a no-op here. + It '-Force does NOT set PinHonoredAgainstCascade when the pin already satisfies the requirement' { + # a non-breaking; b's own API non-breaking (required 1.1.0); user + # pinned b at 1.5.0 which already satisfies. -Force is a no-op here. $baseline = @( (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) ) + $classifier = New-StubClassifier @{ b = 'non-breaking' } $parsed = Parse-ReleaseTokens -Tokens @('a@nonbreaking', 'b@1.5.0') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -Force + $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier -Force $byFolder = @{} foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } $byFolder['b'].PinHonoredAgainstCascade | Should -BeFalse @@ -403,15 +427,16 @@ Describe 'Resolve-ReleaseSet' { Context 'diamond dependency with two user-source roots' { It 'accumulates one cascade reason per dependency into the diamond bottom and strengthens correctly' { - # diamond: a, x are roots; both depended on by mid; c depends on mid. - # Actually: c depends on a (patch) and c depends on b (breaking). + # a, x are roots; c depends on both. c's own API broke (semver-checks: + # breaking) because of x's breaking change. $baseline = @( (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) (New-BaselinePackage -Folder 'x' -Version '1.0.0' -Deps @()) (New-BaselinePackage -Folder 'c' -Version '1.0.0' -Deps @('a', 'x')) ) + $classifier = New-StubClassifier @{ c = 'breaking' } $parsed = Parse-ReleaseTokens -Tokens @('a@patch', 'x@breaking') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline + $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier $byFolder = @{} foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } @@ -419,24 +444,26 @@ Describe 'Resolve-ReleaseSet' { $reasonTargets = @($byFolder['c'].CascadeReasons | ForEach-Object { $_.Target } | Sort-Object) $reasonTargets | Should -Be @('a', 'x') - # x's cascade is breaking → c becomes breaking via cascade. + # c's own API broke => c becomes breaking. $byFolder['c'].EffectiveChangeType | Should -Be 'breaking' $byFolder['c'].EffectiveTargetVersion | Should -Be '2.0.0' } } Context 'transitive cascade reason aggregation' { - It 'records reasons for both the direct and indirect target when a later iteration auto-upgrades the middle crate' { - # Linear chain a -> b -> c (each exposes the previous). Tokens - # `b a` so b iterates first, then a's BFS reaches both b and c and - # bumps b to breaking. c stays patch under one-level cascade. + It 'records reasons for both the direct and indirect target when a middle crate is auto-upgraded' { + # Linear chain a -> b -> c. Tokens `b a` so b iterates first, then a's + # BFS reaches both b and c. b's own API broke (semver-checks: + # breaking) so b is bumped to breaking; c's is unaffected so c stays + # patch under the per-dependent classification. $baseline = @( (New-BaselinePackage -Folder 'a' -Version '1.0.0' -Deps @()) - (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a') -AllowedExternalTypes @('a')) - (New-BaselinePackage -Folder 'c' -Version '1.0.0' -Deps @('b') -AllowedExternalTypes @('b')) + (New-BaselinePackage -Folder 'b' -Version '1.0.0' -Deps @('a')) + (New-BaselinePackage -Folder 'c' -Version '1.0.0' -Deps @('b')) ) + $classifier = New-StubClassifier @{ b = 'breaking' } $parsed = Parse-ReleaseTokens -Tokens @('b@patch', 'a@breaking') - $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline + $resolved = Resolve-ReleaseSet -ParsedTokens $parsed -WorkspaceBaseline $baseline -GetRequiredChangeType $classifier $byFolder = @{} foreach ($e in $resolved) { $byFolder[$e.Folder] = $e } @@ -450,8 +477,7 @@ Describe 'Resolve-ReleaseSet' { $cReasonForB = @($byFolder['c'].CascadeReasons | Where-Object { $_.Target -eq 'b' }) $cReasonForB.Count | Should -Be 1 - # Breaking is intentionally NOT recomputed (one-level cascade - # semantics); this assertion locks that decision in. + # Breaking reflects c's own (patch) change, not b's. $cReasonForB[0].Breaking | Should -BeFalse $cReasonForA = @($byFolder['c'].CascadeReasons | Where-Object { $_.Target -eq 'a' }) diff --git a/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 b/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 index 444776ade..14fa86dc7 100644 --- a/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 +++ b/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 @@ -275,31 +275,63 @@ Describe 'Test-ValidPackageName' { } } -Describe 'Test-PackageExposesTarget' { - It 'returns true when no allowed_external_types declared (conservative default)' { - $dep = [pscustomobject]@{ AllowedExternalTypes = $null } - Test-PackageExposesTarget -dependent $dep -targetPackageName 'foo' | Should -BeTrue +Describe 'ConvertFrom-SemverChecksOutput' { + It 'maps a major-check failure to breaking' { + $out = " Summary semver requires new major version: 3 major and 0 minor checks failed" + ConvertFrom-SemverChecksOutput -Output $out | Should -Be 'breaking' } - It 'returns true when target appears as a root in allowed_external_types' { - $dep = [pscustomobject]@{ AllowedExternalTypes = @('foo::*', 'bar::Baz') } - Test-PackageExposesTarget -dependent $dep -targetPackageName 'foo' | Should -BeTrue - Test-PackageExposesTarget -dependent $dep -targetPackageName 'bar' | Should -BeTrue + It 'maps a minor-only failure to non-breaking' { + $out = " Summary semver requires new minor version: 0 major and 2 minor checks failed" + ConvertFrom-SemverChecksOutput -Output $out | Should -Be 'non-breaking' } - It 'returns false when target is not in allowed_external_types' { - $dep = [pscustomobject]@{ AllowedExternalTypes = @('std::*') } - Test-PackageExposesTarget -dependent $dep -targetPackageName 'foo' | Should -BeFalse + It 'maps a zero-major zero-minor summary to patch' { + $out = " Summary 0 major and 0 minor checks failed" + ConvertFrom-SemverChecksOutput -Output $out | Should -Be 'patch' } - It 'normalizes hyphens to underscores when matching' { - $dep = [pscustomobject]@{ AllowedExternalTypes = @('my_package::*') } - Test-PackageExposesTarget -dependent $dep -targetPackageName 'my-package' | Should -BeTrue + It 'maps "no semver update required" to patch' { + $out = " Checking foo v1.2.3 -> v1.2.3 (no change; assume minor)`n Summary no semver update required" + ConvertFrom-SemverChecksOutput -Output $out | Should -Be 'patch' } - It 'matches whole-root only, not prefix' { - $dep = [pscustomobject]@{ AllowedExternalTypes = @('foobar::*') } - Test-PackageExposesTarget -dependent $dep -targetPackageName 'foo' | Should -BeFalse + It 'maps a missing registry baseline to none (new/unpublished crate)' { + $out = "error: failed to retrieve crate data from registry`n`nCaused by:`n crate foo version 999.999.999 not found in registry" + ConvertFrom-SemverChecksOutput -Output $out | Should -Be 'none' + } + + It 'maps "no released versions" to none' { + ConvertFrom-SemverChecksOutput -Output 'error: foo has no released versions on the registry' | Should -Be 'none' + } + + It 'throws on unrecognized output (no silent fallback)' { + { ConvertFrom-SemverChecksOutput -Output 'some unexpected tooling error' -PackageName 'foo' } | + Should -Throw -ExpectedMessage "*did not produce a parseable result for 'foo'*" + } +} + +Describe 'Get-StrongerChangeType' { + It 'returns the higher-ranked change type' { + Get-StrongerChangeType 'patch' 'breaking' | Should -Be 'breaking' + Get-StrongerChangeType 'breaking' 'patch' | Should -Be 'breaking' + Get-StrongerChangeType 'patch' 'non-breaking' | Should -Be 'non-breaking' + Get-StrongerChangeType 'non-breaking' 'patch' | Should -Be 'non-breaking' + } + + It 'treats none as below patch' { + Get-StrongerChangeType 'patch' 'none' | Should -Be 'patch' + Get-StrongerChangeType 'none' 'patch' | Should -Be 'patch' + Get-StrongerChangeType 'none' 'none' | Should -Be 'none' + } + + It 'treats unknown/empty inputs as none (rank 0)' { + Get-StrongerChangeType 'breaking' '' | Should -Be 'breaking' + Get-StrongerChangeType $null 'patch' | Should -Be 'patch' + } + + It 'returns the first argument on a tie' { + Get-StrongerChangeType 'non-breaking' 'non-breaking' | Should -Be 'non-breaking' } } From 568dfdb074e3b5e399de48826c52ef5b138a2a57 Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:53:28 +0100 Subject: [PATCH 02/17] refactor(ci): compute publishing set via reusable pwsh script Replace the inline bash version-diff loop in the semver job with scripts/ci/compute-publishing-set.ps1, which reuses the release library's Get-PackagesWithVersionChanges (the same per-crate version-diff logic the release scripts use, already unit-tested) and maps changed crate folders to their cargo package names. More readable and testable than the awk/bash. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/main.yml | 37 +++------------ scripts/ci/compute-publishing-set.ps1 | 68 +++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 31 deletions(-) create mode 100644 scripts/ci/compute-publishing-set.ps1 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 232a3644d..77f37567d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -225,42 +225,17 @@ jobs: # Determine which crates have a bumped `[package] version = "..."` between # the PR base and head. Only these are on a path to publish, so only these # are checked against their crates.io baseline. Emits `--package NAME ...` - # (or empty) plus `publishing=true|false`. + # (or empty) plus `publishing=true|false`. Reuses the release library's + # per-crate version-diff logic (scripts/lib/releasing.ps1). - name: Compute Publishing Set id: publish - shell: bash + shell: pwsh env: BASE_REF: ${{ github.base_ref }} run: | - set -euo pipefail - git fetch --no-tags origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" - - pkg_args="" - # For every crates//Cargo.toml changed in this PR, compare the - # [package] version line at base vs head. A crate is "publishing" when - # its version line changed (added, or value differs). - while IFS= read -r manifest; do - [[ -z "$manifest" ]] && continue - [[ "$manifest" == crates/*/Cargo.toml ]] || continue - - base_ver=$(git show "origin/${BASE_REF}:${manifest}" 2>/dev/null \ - | awk '/^\[package\]/{p=1} p&&/^version[[:space:]]*=/{print;exit}' || true) - head_ver=$(awk '/^\[package\]/{p=1} p&&/^version[[:space:]]*=/{print;exit}' "$manifest" 2>/dev/null || true) - - if [[ -n "$head_ver" && "$base_ver" != "$head_ver" ]]; then - name=$(awk '/^\[package\]/{p=1} p&&/^name[[:space:]]*=/{gsub(/.*=[[:space:]]*"?|"?[[:space:]]*$/,"");print;exit}' "$manifest") - [[ -n "$name" ]] && pkg_args+=" --package $name" - fi - done < <(git diff --name-only "origin/${BASE_REF}...HEAD" -- 'crates/*/Cargo.toml') - - pkg_args="$(echo "$pkg_args" | sed 's/^ *//')" - if [[ -n "$pkg_args" ]]; then - echo "publishing=true" >> "$GITHUB_OUTPUT" - else - echo "publishing=false" >> "$GITHUB_OUTPUT" - fi - echo "packages=$pkg_args" >> "$GITHUB_OUTPUT" - echo "Publishing set: ${pkg_args:-}" + $ErrorActionPreference = 'Stop' + git fetch --no-tags origin "+refs/heads/$env:BASE_REF`:refs/remotes/origin/$env:BASE_REF" + ./scripts/ci/compute-publishing-set.ps1 -BaseRef "origin/$env:BASE_REF" # execute — only when the PR bumps at least one crate version. Baseline is # crates.io (default: no --baseline-rev), so this validates the on-disk diff --git a/scripts/ci/compute-publishing-set.ps1 b/scripts/ci/compute-publishing-set.ps1 new file mode 100644 index 000000000..d04a544c9 --- /dev/null +++ b/scripts/ci/compute-publishing-set.ps1 @@ -0,0 +1,68 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +#Requires -Version 7.0 + +<# +.SYNOPSIS + Emits the set of workspace crates a PR is about to publish. + +.DESCRIPTION + A crate is "publishing" when its `[package] version = "..."` differs between + the PR base ref and the working tree (a new crate not present at the base + counts as publishing its first version). Only these crates are on a path to + reach crates.io, so only these need a cargo-semver-checks compatibility check + against their published baseline. + + Reuses `Get-PackagesWithVersionChanges` from the release library (the same + per-crate version-diff logic the release scripts use), then maps the changed + crate folders to their Cargo package names. + + Writes two GitHub Actions step outputs to the file named by -GitHubOutput + (defaults to $env:GITHUB_OUTPUT): + + publishing = 'true' | 'false' + packages = '--package NAME --package NAME ...' (empty when none) + +.PARAMETER BaseRef + The git ref to compare against, e.g. 'origin/main'. Must be fetched before + calling this script. + +.PARAMETER RepoRoot + Repository root. Defaults to the current directory. + +.PARAMETER GitHubOutput + Path to the GitHub Actions step-output file. Defaults to $env:GITHUB_OUTPUT. + When neither is set, the outputs are printed to stdout instead. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$BaseRef, + [string]$RepoRoot = (Get-Location).Path, + [string]$GitHubOutput = $env:GITHUB_OUTPUT +) + +. "$PSScriptRoot/../lib/releasing.ps1" + +$changedFolders = Get-PackagesWithVersionChanges -RepoRoot $RepoRoot -BaseRef $BaseRef +$packages = Get-WorkspacePackages -repoRoot $RepoRoot +$nameByFolder = @{} +foreach ($p in $packages) { $nameByFolder[$p.Folder] = $p.Name } + +$cargoNames = @( + foreach ($folder in $changedFolders) { + if ($nameByFolder.ContainsKey($folder)) { $nameByFolder[$folder] } + } +) | Sort-Object -Unique + +$pkgArgs = ($cargoNames | ForEach-Object { "--package $_" }) -join ' ' +$publishing = if ($cargoNames.Count -gt 0) { 'true' } else { 'false' } + +Write-Host "Publishing set: $(if ($pkgArgs) { $pkgArgs } else { '' })" + +$lines = @("publishing=$publishing", "packages=$pkgArgs") +if ([string]::IsNullOrEmpty($GitHubOutput)) { + $lines | ForEach-Object { Write-Output $_ } +} else { + $lines | Add-Content -Path $GitHubOutput -Encoding utf8 +} From 61003f54764fda4364132b316643d9ea172174e8 Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:16:02 +0100 Subject: [PATCH 03/17] fix(release): make entry point throw instead of Exit so it is testable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invoke-ReleasePackagesMain called Exit 1 on validation / pre-flight / execution errors. Exit tears down the entire runspace, so when the Pester scenario suite ran an error case (e.g. a pin below the required version) it killed the whole run -- every subsequent scenario was reported as failed. This was a latent bug masked until the semver-checks change made more error scenarios actually reach the throw path. Surface those failures as terminating errors (throw) instead. The thin CLI shell (release-packages.ps1) now wraps the call and converts a throw into xit 1, preserving the command-line / CI exit-code contract, while test harnesses catch the exception in-process. Full Pester suite now passes 385/385 in a single process (previously the scenario file cascaded to failures). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/lib/release-flow.ps1 | 59 ++++++++----------- scripts/release-packages.ps1 | 12 +++- ...-all-force-release-unchanged.scenario.psd1 | 5 ++ 3 files changed, 40 insertions(+), 36 deletions(-) diff --git a/scripts/lib/release-flow.ps1 b/scripts/lib/release-flow.ps1 index c59d51fc4..6cca3c3ad 100644 --- a/scripts/lib/release-flow.ps1 +++ b/scripts/lib/release-flow.ps1 @@ -2018,8 +2018,11 @@ function Show-FinalMessageForBundle { # accepts every published package as eligible. # # Returns the array of release records (so Pester scenarios can assert on -# them). Errors during input validation / pre-flight checks call Exit 1 to -# match the existing script CLI contract. +# them). Input-validation / pre-flight / execution errors are surfaced as +# terminating errors (throw) so callers can catch them. The thin CLI shell +# (release-packages.ps1) converts a throw into `exit 1`; test harnesses catch +# the exception directly. This keeps the entry point testable in-process — a +# bare `Exit` would tear down the whole test runspace. function Invoke-ReleasePackagesMain { [CmdletBinding()] param( @@ -2038,27 +2041,23 @@ function Invoke-ReleasePackagesMain { # 1. PRE-FLIGHT if (-not (Test-CommandExists -command 'git')) { - Write-Error 'Git is not installed or not found in your PATH.' - Exit 1 + throw 'Git is not installed or not found in your PATH.' } # cargo-semver-checks decides every change type in the plan (against each # crate's last published version). It is a hard dependency — there is no # heuristic fallback — so fail fast with an actionable message if missing. if (-not (Test-CommandExists -command 'cargo-semver-checks')) { - Write-Error "cargo-semver-checks is not installed or not found in your PATH. Install the version pinned in constants.env (CARGO_SEMVER_CHECKS_VERSION) with 'cargo install cargo-semver-checks --version --locked'. It is required to classify releases against their last published versions." - Exit 1 + throw "cargo-semver-checks is not installed or not found in your PATH. Install the version pinned in constants.env (CARGO_SEMVER_CHECKS_VERSION) with 'cargo install cargo-semver-checks --version --locked'. It is required to classify releases against their last published versions." } $repoRoot = Get-Location if (-not (Test-Path (Join-Path $repoRoot '.git'))) { - Write-Error 'This script must be run from the root of a Git repository.' - Exit 1 + throw 'This script must be run from the root of a Git repository.' } $rootCargoToml = Join-Path $repoRoot 'Cargo.toml' if (-not (Test-Path $rootCargoToml)) { - Write-Error "Could not find root Cargo.toml at '$rootCargoToml'." - Exit 1 + throw "Could not find root Cargo.toml at '$rootCargoToml'." } # Every mode is interactive: the elevation review can prompt even in @@ -2066,31 +2065,22 @@ function Invoke-ReleasePackagesMain { # dependencies. Bail out early with a clear error if stdin is not a # terminal, rather than failing deep inside Read-Host. if (-not (Test-InteractiveSession)) { - Write-Error 'release-packages.ps1 must be run from an interactive terminal — every mode may prompt the user for elevation review of modified-but-unreleased dependencies.' - Exit 1 + throw 'release-packages.ps1 must be run from an interactive terminal — every mode may prompt the user for elevation review of modified-but-unreleased dependencies.' } # 2. MODE / INPUT VALIDATION + TOKEN PARSE $hasTokens = ($null -ne $Packages) -and ($Packages.Count -gt 0) if ($Mode -ne 'targeted' -and $Force) { - Write-Error "release-packages.ps1 -Force is only valid with -Packages (targeted mode). The -Changed and -All modes only accept change-type answers (breaking / non-breaking / patch) and never explicit version pins, so the pin-vs-cascade rejection that -Force overrides cannot fire." - Exit 1 + throw "release-packages.ps1 -Force is only valid with -Packages (targeted mode). The -Changed and -All modes only accept change-type answers (breaking / non-breaking / patch) and never explicit version pins, so the pin-vs-cascade rejection that -Force overrides cannot fire." } if ($Mode -eq 'targeted') { if (-not $hasTokens) { - Write-Error 'release-packages.ps1 -Packages requires at least one ''@'' token. Use -Changed or -All for a guided walk instead.' - Exit 1 - } - try { - $parsedTokens = Parse-ReleaseTokens -Tokens $Packages - } catch { - Write-Error $_.Exception.Message - Exit 1 + throw 'release-packages.ps1 -Packages requires at least one ''@'' token. Use -Changed or -All for a guided walk instead.' } + $parsedTokens = Parse-ReleaseTokens -Tokens $Packages } else { if ($hasTokens) { - Write-Error "release-packages.ps1 -$Mode does not accept -Packages tokens; the planner discovers targets for you." - Exit 1 + throw "release-packages.ps1 -$Mode does not accept -Packages tokens; the planner discovers targets for you." } $parsedTokens = @() } @@ -2150,15 +2140,15 @@ function Invoke-ReleasePackagesMain { # memoises per crate so the interactive review loop re-resolves cheaply. $script:ReleaseRepoRoot = $repoRoot.Path - try { - $resolvedHash = Invoke-PlanReview -RepoRoot $repoRoot.Path ` - -ParsedTokens $parsedTokens -WorkspaceBaseline $workspaceBaseline ` - -GetRequiredChangeType $script:DefaultSemverClassifier ` - -ModifiedSnapshot $modifiedSnapshot -Mode $planReviewMode -Force:$Force - } catch { - Write-Error $_.Exception.Message - Exit 1 - } + # The classifier and Invoke-PlanReview surface planning errors (e.g. a pin + # below the semver-required version) as terminating errors. Let them + # propagate to the caller (the CLI shell turns them into `exit 1`; tests + # catch them) rather than swallowing and re-emitting, which would tear down + # a test runspace via Exit. + $resolvedHash = Invoke-PlanReview -RepoRoot $repoRoot.Path ` + -ParsedTokens $parsedTokens -WorkspaceBaseline $workspaceBaseline ` + -GetRequiredChangeType $script:DefaultSemverClassifier ` + -ModifiedSnapshot $modifiedSnapshot -Mode $planReviewMode -Force:$Force # 6. EARLY EXIT IF GUIDED USER IGNORED EVERYTHING — skip Show-ReleasePlan # / Invoke-ResolvedRelease / Invoke-WorkspaceCheck. They handle empty @@ -2179,8 +2169,7 @@ function Invoke-ReleasePackagesMain { $releases = @(Invoke-ResolvedRelease -RepoRoot $repoRoot.Path -RootCargoToml $rootCargoToml ` -PrBaseUrl $prBaseUrl -ResolvedReleaseSet $resolvedHash -WorkspaceBaseline $workspaceBaseline) } catch { - Write-Error "Release execution failed: $_" - Exit 1 + throw "Release execution failed: $_" } Invoke-WorkspaceCheck -RepoRoot $repoRoot.Path diff --git a/scripts/release-packages.ps1 b/scripts/release-packages.ps1 index f1a54760b..563b5e062 100644 --- a/scripts/release-packages.ps1 +++ b/scripts/release-packages.ps1 @@ -200,4 +200,14 @@ $mode = switch ($PSCmdlet.ParameterSetName) { 'All' { 'all' } } -Invoke-ReleasePackagesMain -Mode $mode -Packages $Packages -Force:$Force | Out-Null +# Invoke-ReleasePackagesMain surfaces all validation / pre-flight / execution +# failures as terminating errors (throw) so it stays testable in-process. This +# thin CLI shell is the only layer that turns a failure into a process exit +# code, preserving the historical `exit 1`-on-error contract for command-line +# and CI callers. +try { + Invoke-ReleasePackagesMain -Mode $mode -Packages $Packages -Force:$Force | Out-Null +} catch { + Write-Error $_.Exception.Message + exit 1 +} diff --git a/scripts/tests/Pester/scenarios/S23-all-force-release-unchanged.scenario.psd1 b/scripts/tests/Pester/scenarios/S23-all-force-release-unchanged.scenario.psd1 index 128678520..21ad59572 100644 --- a/scripts/tests/Pester/scenarios/S23-all-force-release-unchanged.scenario.psd1 +++ b/scripts/tests/Pester/scenarios/S23-all-force-release-unchanged.scenario.psd1 @@ -11,6 +11,11 @@ Run = @{ Mode = 'all' + # a and b re-export c's public types, so when c lands a breaking change + # their own APIs break too — cargo-semver-checks classifies both as + # breaking. This is what makes the cascade mirror c's breaking onto + # them (and, being at the breaking ceiling, suppresses their prompts). + SemverVerdicts = @{ a = 'breaking'; b = 'breaking' } Answers = @( # Initial queue (no tokens, all roots): b, c, a (see S22 comment # for the BFS-root expansion that produces this order). From 25cbc39793fa3361b5ff6243c1ed6f16b04f8d98 Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:29:15 +0100 Subject: [PATCH 04/17] test(release): mock tool-presence check in scenario harness The scenario suite mocks the semver classifier (Get-CrateRequiredChangeType) so the real cargo-semver-checks binary is never invoked, but Invoke-ReleasePackagesMain's pre-flight asserts the binary is on PATH. The script-tests CI job does not install cargo-semver-checks (it would be a needless multi-minute compile for tests that mock it), so every scenario threw at pre-flight. Mock Test-CommandExists in the scenario BeforeEach so the presence check passes without the binary, matching how the classifier itself is mocked. Verified by running the scenario suite with cargo-semver-checks removed from PATH (cargo still present): 24/24 pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/tests/Pester/scenarios/Scenarios.Tests.ps1 | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/tests/Pester/scenarios/Scenarios.Tests.ps1 b/scripts/tests/Pester/scenarios/Scenarios.Tests.ps1 index 0483ec98e..cf15f5aab 100644 --- a/scripts/tests/Pester/scenarios/Scenarios.Tests.ps1 +++ b/scripts/tests/Pester/scenarios/Scenarios.Tests.ps1 @@ -39,6 +39,12 @@ Describe 'End-to-end release scenarios' { # tests run under CI / pwsh non-tty. Mock -CommandName Test-InteractiveSession -MockWith { $true } -Verifiable:$false + # The entry point's pre-flight asserts git and cargo-semver-checks are on + # PATH. Scenarios mock the classifier (Get-CrateRequiredChangeType) so the + # real cargo-semver-checks binary is never invoked and need not be + # installed on the runner — satisfy the presence check via mock. + Mock -CommandName Test-CommandExists -MockWith { $true } -Verifiable:$false + # Suppress real editor launches when scenarios exercise the View Diff path. Mock -CommandName Open-PathWithPreferredEditor -MockWith { } -Verifiable:$false From 21f522b206ffefdbc565d713837bc0b193507a5f Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:16:20 +0100 Subject: [PATCH 05/17] refactor(release): address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update-EntryForRequiredChangeType: encapsulate change-type ranking via Get-StrongerChangeType instead of reaching across files for \. Behaviourally identical (both files dot-source into one scope, so the rank table already resolved — verified by the full suite), but robust if the libraries are ever loaded in isolation. - ConvertFrom-SemverChecksOutput: only map crate-absent signals ("not found in registry" / "no released versions") to 'none'. Drop the generic "failed to retrieve crate data from registry" match, which also fires on transient network outages and would silently skip classification, violating the no-fallback contract. Added a test asserting a transient failure throws. - Fix stale references to the removed New-SemverChangeTypeClassifier in the Resolve-ReleaseSet param doc and the test helper comment (production uses \ / Get-CrateRequiredChangeType). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/lib/release-flow.ps1 | 16 +++++++++------- scripts/lib/releasing.ps1 | 13 +++++++++++-- .../release-packages/ResolveReleaseSet.Tests.ps1 | 3 ++- .../unit/releasing/PureFunctions.Tests.ps1 | 9 +++++++++ 4 files changed, 31 insertions(+), 10 deletions(-) diff --git a/scripts/lib/release-flow.ps1 b/scripts/lib/release-flow.ps1 index 6cca3c3ad..6410cb512 100644 --- a/scripts/lib/release-flow.ps1 +++ b/scripts/lib/release-flow.ps1 @@ -270,11 +270,12 @@ function Update-EntryForRequiredChangeType { [switch]$Force ) - $existingRank = $script:ChangeTypeRank[$Entry.EffectiveChangeType] - $newRank = $script:ChangeTypeRank[$RequiredChangeType] - if ($null -eq $existingRank) { $existingRank = 0 } - if ($null -eq $newRank) { $newRank = 0 } - if ($newRank -le $existingRank) { return } + # Only ever raise the change type — never lower it. Get-StrongerChangeType + # (defined alongside the rank table in releasing.ps1, so it encapsulates the + # ranking rather than reaching across files for $script:ChangeTypeRank) returns + # its first argument on a tie; when the requirement does not exceed the current + # effective type the stronger-of equals the current type and we no-op. + if ((Get-StrongerChangeType $Entry.EffectiveChangeType $RequiredChangeType) -eq $Entry.EffectiveChangeType) { return } $requiredVersion = Get-NextVersion -currentVersion $Entry.CurrentVersion -ChangeType $RequiredChangeType @@ -378,8 +379,9 @@ function Resolve-ReleaseSet { # Classifier scriptblock: (folder, cargoName) -> 'breaking'|'non-breaking'| # 'patch'|'none'. Decides each crate's minimum change type from its real # API diff (cargo-semver-checks) vs its last published version. Production - # passes New-SemverChangeTypeClassifier; the default is a no-op ('none') - # so callers that only exercise version/pin/BFS math need not supply one. + # passes $script:DefaultSemverClassifier (which calls + # Get-CrateRequiredChangeType); the default here is a no-op ('none') so + # callers that only exercise version/pin/BFS math need not supply one. [Parameter(Mandatory = $false)][scriptblock]$GetRequiredChangeType = { param($folder, $cargoName) 'none' }, [Parameter(Mandatory = $false)][switch]$Force ) diff --git a/scripts/lib/releasing.ps1 b/scripts/lib/releasing.ps1 index a34bebf6a..ef884691a 100644 --- a/scripts/lib/releasing.ps1 +++ b/scripts/lib/releasing.ps1 @@ -553,9 +553,18 @@ function ConvertFrom-SemverChecksOutput { # No baseline: the crate (or a specific version) is not on the registry. This # is the expected state for a brand-new, never-published crate — there is # nothing to compare against, so it imposes no change-type floor. + # + # Match ONLY messages that specifically indicate the crate/version is absent + # from the registry. The generic wrapper line "failed to retrieve crate data + # from registry" is deliberately NOT matched here: it also fires on transient + # network/registry outages, and treating those as 'none' would silently skip + # classification (violating the no-fallback contract). When the crate is + # genuinely unpublished the specific cause line ("... not found in registry" + # / "no released versions") is present in the output and matches below; a + # transient failure has no such line and falls through to the throw. if ($Output -match '(?i)not\s+found\s+in\s+(the\s+)?registry' -or - $Output -match '(?i)failed\s+to\s+retrieve\s+crate\s+data\s+from\s+registry' -or - $Output -match '(?i)no\s+(released|published)\s+versions?') { + $Output -match '(?i)no\s+(released|published)\s+versions?' -or + $Output -match '(?i)could\s+not\s+find\s+.*\bin\s+registry') { return 'none' } diff --git a/scripts/tests/Pester/unit/release-packages/ResolveReleaseSet.Tests.ps1 b/scripts/tests/Pester/unit/release-packages/ResolveReleaseSet.Tests.ps1 index e2c0aafb5..2d8b1b2ba 100644 --- a/scripts/tests/Pester/unit/release-packages/ResolveReleaseSet.Tests.ps1 +++ b/scripts/tests/Pester/unit/release-packages/ResolveReleaseSet.Tests.ps1 @@ -29,7 +29,8 @@ BeforeAll { # Builds a stub cargo-semver-checks classifier from a folder -> change-type # map. Unmapped folders return 'none' (no constraint). Lets the cascade / # self-floor logic be tested deterministically without invoking the real - # tool. The real classifier is New-SemverChangeTypeClassifier. + # tool. In production the classifier is $script:DefaultSemverClassifier, which + # calls Get-CrateRequiredChangeType (a cached cargo-semver-checks wrapper). function New-StubClassifier { param([hashtable]$Map = @{}) return { diff --git a/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 b/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 index 14fa86dc7..3b4673a1d 100644 --- a/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 +++ b/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 @@ -305,6 +305,15 @@ Describe 'ConvertFrom-SemverChecksOutput' { ConvertFrom-SemverChecksOutput -Output 'error: foo has no released versions on the registry' | Should -Be 'none' } + It 'throws (does NOT return none) on a transient registry-retrieval failure with no "not found" cause' { + # A network/registry outage emits the generic wrapper line but no + # crate-absent cause. Treating it as an unpublished crate ('none') would + # silently skip classification, violating the no-fallback contract. + $out = "error: failed to retrieve crate data from registry`n`nCaused by:`n error sending request for url (https://index.crates.io/...): operation timed out" + { ConvertFrom-SemverChecksOutput -Output $out -PackageName 'foo' } | + Should -Throw -ExpectedMessage "*did not produce a parseable result for 'foo'*" + } + It 'throws on unrecognized output (no silent fallback)' { { ConvertFrom-SemverChecksOutput -Output 'some unexpected tooling error' -PackageName 'foo' } | Should -Throw -ExpectedMessage "*did not produce a parseable result for 'foo'*" From b2d51679daf303ae7cb84cd59cc30c38fdde78f9 Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:36:02 +0100 Subject: [PATCH 06/17] =?UTF-8?q?feat(ci):=20richer=20semver=20PR=20report?= =?UTF-8?q?=20=E2=80=94=20table,=20stop=20sign,=20per-push=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single `⚠️` code-dump comment with a per-crate report rendered by scripts/ci/semver-report.ps1 (supersedes compute-publishing-set.ps1). For every crate the PR publishes it runs cargo semver-checks against the crates.io baseline and shows a table: Crate | crates.io | This PR | Minimum required | Status with a 🛑 header + collapsible cargo-semver-checks detail when any crate is under-bumped, a ✅ header when all bumps are sufficient, and a link to the Actions run. The minimum-required version is computed from the crate's parsed verdict via the release library's Get-NextVersion. The job posts a NEW comment on every push (via `gh pr comment`) instead of updating one sticky comment, so each run's result is visible in the timeline. Still non-failing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/main.yml | 77 ++++------ scripts/ci/compute-publishing-set.ps1 | 68 --------- scripts/ci/semver-report.ps1 | 198 ++++++++++++++++++++++++++ 3 files changed, 222 insertions(+), 121 deletions(-) delete mode 100644 scripts/ci/compute-publishing-set.ps1 create mode 100644 scripts/ci/semver-report.ps1 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 77f37567d..8e8b1b08e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -199,9 +199,10 @@ jobs: # Runs cargo-semver-checks against each crate's LAST PUBLISHED version on # crates.io (the tool's default baseline — no --baseline-rev), scoped to the # crates whose `version =` is bumped in this PR (the "publishing set"). The - # job is informational only: it never fails the build (continue-on-error) and - # posts / clears a sticky PR comment. It is skipped entirely on PRs that do - # not bump any crate version, since only version-bumping PRs trigger a + # job is informational only: it never fails the build and posts a fresh PR + # comment on every push with a per-crate table (crates.io vs this-PR vs the + # minimum required version) plus a link to the run. It is a no-op on PRs that + # do not bump any crate version, since only version-bumping PRs trigger a # publish and thus need a compatibility check against the published baseline. semver: needs: [delta] @@ -222,63 +223,33 @@ jobs: rust-toolchain: RUST_LATEST cargo-tools: CARGO_SEMVER_CHECKS_VERSION - # Determine which crates have a bumped `[package] version = "..."` between - # the PR base and head. Only these are on a path to publish, so only these - # are checked against their crates.io baseline. Emits `--package NAME ...` - # (or empty) plus `publishing=true|false`. Reuses the release library's - # per-crate version-diff logic (scripts/lib/releasing.ps1). - - name: Compute Publishing Set - id: publish + # Compute the publishing set (version-bumped published crates), run + # cargo-semver-checks per crate against its crates.io baseline, and render + # a Markdown report (scripts/ci/semver-report.ps1 — reuses the release + # library's version-diff / next-version helpers). Sets outputs: + # publishing = true|false (any crate version bumped) + # status = pass|fail (any crate under-bumped for its API changes) + # Kept non-failing so registry/network hiccups never block the PR. + - name: Semver Report + id: report + continue-on-error: true shell: pwsh env: BASE_REF: ${{ github.base_ref }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | $ErrorActionPreference = 'Stop' git fetch --no-tags origin "+refs/heads/$env:BASE_REF`:refs/remotes/origin/$env:BASE_REF" - ./scripts/ci/compute-publishing-set.ps1 -BaseRef "origin/$env:BASE_REF" - - # execute — only when the PR bumps at least one crate version. Baseline is - # crates.io (default: no --baseline-rev), so this validates the on-disk - # bump is sufficient for the API changes since the last published release. - - name: Semver Compatibility - id: semver - if: steps.publish.outputs.publishing == 'true' - continue-on-error: true - run: | - set -o pipefail - cargo semver-checks --all-features ${{ steps.publish.outputs.packages }} --color never 2>&1 | tee semver-output.txt + ./scripts/ci/semver-report.ps1 -BaseRef "origin/$env:BASE_REF" -ReportPath semver-comment.md -RunUrl "$env:RUN_URL" - # report - - name: Prepare Semver Comment - if: steps.publish.outputs.publishing == 'true' && steps.semver.outcome == 'failure' - run: | - echo "## ⚠️ Breaking Changes Detected (vs last published version)" > semver-comment.txt - echo "" >> semver-comment.txt - echo "\`cargo semver-checks\` compared the crate(s) this PR is publishing against their latest **crates.io** release and found API changes that require a stronger version bump than the one on disk." >> semver-comment.txt - echo "" >> semver-comment.txt - echo '```' >> semver-comment.txt - grep -v "^ *Cloning " semver-output.txt | grep -v "^ *Building " | grep -v "^ *Built \[" | grep -v "^ *Parsing " | grep -v "^ *Parsed \[" | grep -v "^ *Checking" | grep -v "^ *Checked \[" | grep -v "^ *Finished \[" | grep -v "^ *Summary " >> semver-comment.txt - echo '```' >> semver-comment.txt - echo "" >> semver-comment.txt - echo "If the breaking changes are intentional then everything is fine - this message is merely informative and does not block the merge." >> semver-comment.txt - echo "" >> semver-comment.txt - echo "Remember to apply a version number bump with the correct severity when publishing a version with breaking changes (\`1.x.x -> 2.x.x\` or \`0.1.x -> 0.2.x\`)." >> semver-comment.txt - - - name: Post Semver Failure Comment - if: steps.publish.outputs.publishing == 'true' && steps.semver.outcome == 'failure' && github.event.pull_request.head.repo.full_name == github.repository - uses: marocchino/sticky-pull-request-comment@v3.0.4 - with: - header: semver-check - path: semver-comment.txt - - # Clear a stale comment when the check now passes OR when the PR no longer - # publishes anything (publishing=false skips the check entirely). - - name: Remove Semver Comment on Success - if: (steps.publish.outputs.publishing != 'true' || steps.semver.outcome == 'success') && github.event.pull_request.head.repo.full_name == github.repository - uses: marocchino/sticky-pull-request-comment@v3.0.4 - with: - header: semver-check - delete: true + # Post a NEW comment on every push (not a sticky update) so each run's + # result is visible in the PR timeline. Only fires when the PR actually + # publishes something and is not from a fork (forks lack a write token). + - name: Post Semver Comment + if: steps.report.outputs.publishing == 'true' && github.event.pull_request.head.repo.full_name == github.repository + env: + GH_TOKEN: ${{ github.token }} + run: gh pr comment ${{ github.event.pull_request.number }} --body-file semver-comment.md extended-analysis: name: extended-analysis (${{ matrix.os }}) diff --git a/scripts/ci/compute-publishing-set.ps1 b/scripts/ci/compute-publishing-set.ps1 deleted file mode 100644 index d04a544c9..000000000 --- a/scripts/ci/compute-publishing-set.ps1 +++ /dev/null @@ -1,68 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -#Requires -Version 7.0 - -<# -.SYNOPSIS - Emits the set of workspace crates a PR is about to publish. - -.DESCRIPTION - A crate is "publishing" when its `[package] version = "..."` differs between - the PR base ref and the working tree (a new crate not present at the base - counts as publishing its first version). Only these crates are on a path to - reach crates.io, so only these need a cargo-semver-checks compatibility check - against their published baseline. - - Reuses `Get-PackagesWithVersionChanges` from the release library (the same - per-crate version-diff logic the release scripts use), then maps the changed - crate folders to their Cargo package names. - - Writes two GitHub Actions step outputs to the file named by -GitHubOutput - (defaults to $env:GITHUB_OUTPUT): - - publishing = 'true' | 'false' - packages = '--package NAME --package NAME ...' (empty when none) - -.PARAMETER BaseRef - The git ref to compare against, e.g. 'origin/main'. Must be fetched before - calling this script. - -.PARAMETER RepoRoot - Repository root. Defaults to the current directory. - -.PARAMETER GitHubOutput - Path to the GitHub Actions step-output file. Defaults to $env:GITHUB_OUTPUT. - When neither is set, the outputs are printed to stdout instead. -#> -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)][string]$BaseRef, - [string]$RepoRoot = (Get-Location).Path, - [string]$GitHubOutput = $env:GITHUB_OUTPUT -) - -. "$PSScriptRoot/../lib/releasing.ps1" - -$changedFolders = Get-PackagesWithVersionChanges -RepoRoot $RepoRoot -BaseRef $BaseRef -$packages = Get-WorkspacePackages -repoRoot $RepoRoot -$nameByFolder = @{} -foreach ($p in $packages) { $nameByFolder[$p.Folder] = $p.Name } - -$cargoNames = @( - foreach ($folder in $changedFolders) { - if ($nameByFolder.ContainsKey($folder)) { $nameByFolder[$folder] } - } -) | Sort-Object -Unique - -$pkgArgs = ($cargoNames | ForEach-Object { "--package $_" }) -join ' ' -$publishing = if ($cargoNames.Count -gt 0) { 'true' } else { 'false' } - -Write-Host "Publishing set: $(if ($pkgArgs) { $pkgArgs } else { '' })" - -$lines = @("publishing=$publishing", "packages=$pkgArgs") -if ([string]::IsNullOrEmpty($GitHubOutput)) { - $lines | ForEach-Object { Write-Output $_ } -} else { - $lines | Add-Content -Path $GitHubOutput -Encoding utf8 -} diff --git a/scripts/ci/semver-report.ps1 b/scripts/ci/semver-report.ps1 new file mode 100644 index 000000000..51c92008a --- /dev/null +++ b/scripts/ci/semver-report.ps1 @@ -0,0 +1,198 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +#Requires -Version 7.0 + +<# +.SYNOPSIS + Runs cargo-semver-checks for each crate a PR is publishing and renders a + rich, per-crate Markdown report comparing the on-disk bump against the + minimum bump the crate's API changes require versus its crates.io baseline. + +.DESCRIPTION + For every crate whose `[package] version` differs from the PR base ref (the + "publishing set"), this script: + + 1. reads the on-disk (this-PR) version, + 2. runs `cargo semver-checks --package ` against the crate's last + published crates.io release (the tool's default baseline), + 3. parses the baseline version and the required change type from the + output, and + 4. computes the *minimum* version the bump should reach given the detected + API changes. + + It writes a Markdown report to -ReportPath containing: + - a summary status line (🛑 when at least one crate is under-bumped, + ✅ when every publishing crate is sufficiently bumped), + - a table: Crate | crates.io | This PR | Minimum required | Status, + - collapsible per-crate `cargo semver-checks` detail for under-bumped + crates, and + - a link to the triggering Actions run. + + Two GitHub Actions step outputs are written to -GitHubOutput: + publishing = 'true' | 'false' + status = 'pass' | 'fail' (fail = at least one crate under-bumped) + + The report is informational: callers keep the job non-failing. + +.PARAMETER BaseRef + Git ref to diff against, e.g. 'origin/main'. Must be fetched beforehand. + +.PARAMETER ReportPath + Path to write the Markdown report to. + +.PARAMETER RunUrl + URL of the Actions run, embedded as a footer link. Optional. + +.PARAMETER RepoRoot + Repository root. Defaults to the current directory. + +.PARAMETER GitHubOutput + Path to the GitHub Actions step-output file. Defaults to $env:GITHUB_OUTPUT. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$BaseRef, + [Parameter(Mandatory = $true)][string]$ReportPath, + [string]$RunUrl = '', + [string]$RepoRoot = (Get-Location).Path, + [string]$GitHubOutput = $env:GITHUB_OUTPUT +) + +. "$PSScriptRoot/../lib/releasing.ps1" + +# --- 1. Determine the publishing set (version-bumped published crates). ------- +$changedFolders = @(Get-PackagesWithVersionChanges -RepoRoot $RepoRoot -BaseRef $BaseRef) +$packages = Get-WorkspacePackages -repoRoot $RepoRoot +$byFolder = @{} +foreach ($p in $packages) { $byFolder[$p.Folder] = $p } + +function Write-Outputs([string]$publishing, [string]$status) { + $lines = @("publishing=$publishing", "status=$status") + if ([string]::IsNullOrEmpty($GitHubOutput)) { + $lines | ForEach-Object { Write-Output $_ } + } else { + $lines | Add-Content -Path $GitHubOutput -Encoding utf8 + } +} + +if ($changedFolders.Count -eq 0) { + Write-Host 'No crate versions changed; nothing to publish.' + Write-Outputs -publishing 'false' -status 'pass' + return +} + +# --- 2. Run cargo-semver-checks per crate and gather results. ----------------- +# A row per crate: cargo name, on-disk (this-PR) version, crates.io baseline, +# the parsed required change type, the computed minimum version, and the raw +# tool detail (for under-bumped crates). +$rows = New-Object System.Collections.Generic.List[object] + +Push-Location $RepoRoot +try { + foreach ($folder in ($changedFolders | Sort-Object)) { + $pkg = $byFolder[$folder] + if ($null -eq $pkg) { continue } + + $cargoName = $pkg.Name + $onDisk = Get-CurrentVersion -cargoTomlPath (Join-Path $RepoRoot "crates/$folder/Cargo.toml") + + Write-Host "cargo semver-checks: $cargoName (on-disk v$onDisk) vs crates.io..." + $PSNativeCommandUseErrorActionPreference = $false + $out = & cargo semver-checks --package $cargoName --all-features --color never 2>&1 | Out-String + + $changeType = ConvertFrom-SemverChecksOutput -Output $out -ExitCode $LASTEXITCODE -PackageName $cargoName + + # Baseline version cargo-semver-checks pulled from the registry, parsed + # from a "Checking v -> v" line. Absent for a + # never-published crate (changeType 'none'). + $baseline = '—' + $m = [regex]::Match($out, "(?im)^\s*Checking\s+\S+\s+v(\d+\.\d+\.\d+\S*)\s*->") + if ($m.Success) { $baseline = $m.Groups[1].Value } + + # A 'breaking'/'non-breaking' verdict means the detected API changes need + # a stronger bump than the on-disk version gives over the baseline; the + # minimum acceptable version is baseline bumped by that change type. + # 'patch' means the on-disk bump already covers the changes; 'none' means + # the crate is new (no baseline to violate). + $sufficient = $changeType -in @('patch', 'none') + if ($sufficient) { + $required = $onDisk + } else { + $required = Get-NextVersion -currentVersion $baseline -ChangeType $changeType + } + + # Extract just the failure blocks for the collapsible detail. + $detail = ($out -split "`n" | + Where-Object { $_ -notmatch '^\s*(Cloning|Building|Built|Parsing|Parsed|Checking|Checked|Finished|Summary)\b' } | + ForEach-Object { $_.TrimEnd() }) -join "`n" + $detail = $detail.Trim() + + $rows.Add([pscustomobject]@{ + Crate = $cargoName + Baseline = $baseline + OnDisk = $onDisk + Required = $required + Sufficient = $sufficient + ChangeType = $changeType + Detail = $detail + }) + } +} finally { + Pop-Location +} + +# --- 3. Render the Markdown report. ------------------------------------------- +$underBumped = @($rows | Where-Object { -not $_.Sufficient }) +$overallFail = $underBumped.Count -gt 0 + +$bt = [char]0x60 # backtick, kept in a variable to avoid PowerShell escaping. +$sb = New-Object System.Text.StringBuilder +if ($overallFail) { + [void]$sb.AppendLine('## 🛑 Semver: version bump required') + [void]$sb.AppendLine() + [void]$sb.AppendLine("${bt}cargo semver-checks${bt} compared the crate(s) this PR publishes against their latest **crates.io** release. **$($underBumped.Count) of $($rows.Count)** need a higher version bump than the one on disk:") +} else { + [void]$sb.AppendLine('## ✅ Semver: version bumps look sufficient') + [void]$sb.AppendLine() + [void]$sb.AppendLine("${bt}cargo semver-checks${bt} compared the **$($rows.Count)** crate(s) this PR publishes against their latest **crates.io** release. Every bump is sufficient for the detected API changes.") +} +[void]$sb.AppendLine() +[void]$sb.AppendLine('| Crate | crates.io | This PR | Minimum required | Status |') +[void]$sb.AppendLine('|---|---|---|---|---|') +foreach ($r in $rows) { + if ($r.Sufficient) { + $status = '✅ ok' + $req = $r.Required + } else { + $status = "🛑 bump to at least ${bt}$($r.Required)${bt}" + $req = "**$($r.Required)**" + } + [void]$sb.AppendLine("| ${bt}$($r.Crate)${bt} | $($r.Baseline) | $($r.OnDisk) | $req | $status |") +} +[void]$sb.AppendLine() + +$fence = "$bt$bt$bt" +if ($overallFail) { + foreach ($r in $underBumped) { + [void]$sb.AppendLine("
🛑 $($r.Crate) — cargo semver-checks detail") + [void]$sb.AppendLine() + [void]$sb.AppendLine($fence) + [void]$sb.AppendLine($r.Detail) + [void]$sb.AppendLine($fence) + [void]$sb.AppendLine('
') + [void]$sb.AppendLine() + } + [void]$sb.AppendLine('> If these breaking changes are intentional, bump each crate to at least its **Minimum required** version. This check is **informational and does not block the merge**.') +} else { + [void]$sb.AppendLine('> This check is informational and does not block the merge.') +} + +if (-not [string]::IsNullOrEmpty($RunUrl)) { + [void]$sb.AppendLine() + [void]$sb.AppendLine("[View the semver check run]($RunUrl)") +} + +Set-Content -Path $ReportPath -Value $sb.ToString() -Encoding utf8 +Write-Host "Report written to $ReportPath" +Write-Outputs -publishing 'true' -status ($(if ($overallFail) { 'fail' } else { 'pass' })) From 6b081f6bf8db60d86e1de04904af03b58680ecd2 Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:37:42 +0100 Subject: [PATCH 07/17] refactor(release): registry-controlled semver baseline + review fixes Address round-2 PR review feedback: - Discover the semver-checks baseline with 'cargo info --registry ' (run outside the workspace) and pin it with --baseline-version, instead of relying on cargo-semver-checks' hard-coded crates.io default. Add an optional -Registry parameter (default 'crates-io') to release-packages.ps1, the classifier, and the CI report so forks publishing to a private registry can point the baseline at it. Split parsing into the pure ConvertFrom-CargoInfoOutput for unit testing. - Document the last-published-version baseline limitation (unpublished intermediate versions) and its manual-pin workaround, with a covering test. - Report wording: drop the noisy 'Semver' title prefix, replace colloquial 'bump' with 'additional version increment', and make the table column registry-agnostic ('Published'). - docs/releasing.md: explain the concrete defect, add the result->change -type mapping table, link cargo-semver-checks docs, drop the redundant hard-dependency paragraph, and fix 'semver-imposed' wording. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/main.yml | 10 +- docs/releasing.md | 71 ++++++++----- scripts/ci/semver-report.ps1 | 99 ++++++++++++------- scripts/lib/release-flow.ps1 | 7 +- scripts/lib/releasing.ps1 | 85 ++++++++++++++-- scripts/release-packages.ps1 | 20 +++- .../unit/releasing/PureFunctions.Tests.ps1 | 33 +++++++ 7 files changed, 244 insertions(+), 81 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8e8b1b08e..a3d7b524b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -224,11 +224,12 @@ jobs: cargo-tools: CARGO_SEMVER_CHECKS_VERSION # Compute the publishing set (version-bumped published crates), run - # cargo-semver-checks per crate against its crates.io baseline, and render + # cargo-semver-checks per crate against its last published version on the + # configured registry (SEMVER_REGISTRY, crates.io by default), and render # a Markdown report (scripts/ci/semver-report.ps1 — reuses the release # library's version-diff / next-version helpers). Sets outputs: # publishing = true|false (any crate version bumped) - # status = pass|fail (any crate under-bumped for its API changes) + # status = pass|fail (any crate under-incremented for its API changes) # Kept non-failing so registry/network hiccups never block the PR. - name: Semver Report id: report @@ -237,10 +238,13 @@ jobs: env: BASE_REF: ${{ github.base_ref }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + # Registry the baseline version is read from. Override in a fork that + # publishes to a private registry (name as in .cargo/config.toml). + SEMVER_REGISTRY: crates-io run: | $ErrorActionPreference = 'Stop' git fetch --no-tags origin "+refs/heads/$env:BASE_REF`:refs/remotes/origin/$env:BASE_REF" - ./scripts/ci/semver-report.ps1 -BaseRef "origin/$env:BASE_REF" -ReportPath semver-comment.md -RunUrl "$env:RUN_URL" + ./scripts/ci/semver-report.ps1 -BaseRef "origin/$env:BASE_REF" -ReportPath semver-comment.md -RunUrl "$env:RUN_URL" -Registry "$env:SEMVER_REGISTRY" # Post a NEW comment on every push (not a sticky update) so each run's # result is visible in the PR timeline. Only fires when the PR actually diff --git a/docs/releasing.md b/docs/releasing.md index 3ff081d79..af8fb41ac 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -207,34 +207,55 @@ published dependent as a cascade-source release. The change type for every release in the plan — both the directly-requested (user-source) crates and the cascade-pulled dependents — is derived by running [`cargo semver-checks`](https://crates.io/crates/cargo-semver-checks) -against each crate's **last published version on crates.io** (the tool's -default baseline). The current working-tree API is analysed, so a -coordinated release's in-progress edits — including a dependency whose -public types a dependent re-exports — are reflected in the dependent's -own API diff. This replaces the former -`[package.metadata.cargo_check_external_types]` allowlist heuristic, -which could misfire when the allowlist drifted from the real public API -(see the bug that motivated this change: a breaking change in an exposed -dependency was cascaded as `patch` instead of `breaking`). - -Mapping from `cargo semver-checks` verdicts to change types: - -- major API change required → `breaking` -- minor API change required → `non-breaking` -- compatible / no update required → `patch` -- crate not yet on crates.io (never published) → no constraint +against each crate's **last published version in the registry** (looked +up with `cargo info`, so it works against crates.io or a private +registry). The current working-tree API is analysed, so a coordinated +release's in-progress edits — including a dependency whose public types +a dependent re-exports — are reflected in the dependent's own API diff. + +This replaces the former +`[package.metadata.cargo_check_external_types]` allowlist heuristic. That +allowlist is a hand-maintained list of the external types a crate is +*permitted* to expose; it was repurposed as a proxy for the types a +crate *actually* re-exports. When the two drift apart — an entry missing +or stale — the heuristic misjudged whether a dependent re-exports a +changed dependency, so a breaking change in an exposed dependency could +be cascaded as `patch` instead of `breaking` (the motivating defect: a +breaking change in `bytesbuf` was not propagated to `bytesbuf_io`, which +re-exports `bytesbuf` types). Analysing the real API with +`cargo semver-checks` removes the proxy entirely. + +**How the change type is determined.** `cargo semver-checks` is invoked +as a CLI (not as a library) and its textual result is parsed into one of +our change types. The mapping mirrors the tool's own +[`required_bump`](https://docs.rs/cargo-semver-checks/latest/cargo_semver_checks/struct.CrateReport.html#method.required_bump) +notion (major / minor / none); the exact parsing lives in +`ConvertFrom-SemverChecksOutput` (`scripts/lib/releasing.ps1`): + +| `cargo semver-checks` result | change type | +|---|---| +| a major-level change is required | `breaking` | +| only a minor-level change is required | `non-breaking` | +| compatible / no update required | `patch` | +| crate not yet in the registry (never published) | no constraint | Cascade dependents are floored at `patch` (they must re-release to pick up the new dependency version even when their own public API is unchanged), then raised to whatever their own `cargo semver-checks` -verdict requires. - -> **cargo-semver-checks is a hard dependency of the release scripts.** -> Install the version pinned in `constants.env` -> (`CARGO_SEMVER_CHECKS_VERSION`) with -> `cargo install cargo-semver-checks --version --locked`. There -> is no heuristic fallback — the script errors out if the tool is -> missing or the crate cannot be analysed. +result requires. + +**Baseline limitation.** The baseline is always the crate's *last +published* version on the registry. If a version was committed but never +published — for example an aborted release that bumped `bytesbuf` to +`4.0.0` without it reaching the registry — the registry still reports the +older `3.3.3`, so `cargo semver-checks` diffs the working tree against +`3.3.3` and cannot isolate the API delta introduced by the unpublished +`4.0.0`. This is intentional: the tool trusts the registry as the source +of truth for "what consumers actually have". When you need to release on +top of an unpublished version, pin the target explicitly with a +`@..` token (and `-Force` if the pin is below +what the diff-against-`3.3.3` computes) instead of relying on the +automatic classification. The planner enforces **topological consistency**: if a user-supplied change type for a package is *weaker* than `cargo semver-checks` @@ -242,7 +263,7 @@ requires (for that package or via a cascade), the planner auto-upgrades it and notes the upgrade in the review output. The caller's `-Packages` token is therefore a *lower bound*, not a guarantee — the caller can always elevate further on the next iteration of the review, but cannot -suppress a semver-imposed change type. +suppress a change type the API analysis requires. ### Errors the planner rejects diff --git a/scripts/ci/semver-report.ps1 b/scripts/ci/semver-report.ps1 index 51c92008a..1ad7409d8 100644 --- a/scripts/ci/semver-report.ps1 +++ b/scripts/ci/semver-report.ps1 @@ -6,32 +6,35 @@ <# .SYNOPSIS Runs cargo-semver-checks for each crate a PR is publishing and renders a - rich, per-crate Markdown report comparing the on-disk bump against the - minimum bump the crate's API changes require versus its crates.io baseline. + rich, per-crate Markdown report comparing the on-disk version increment + against the minimum increment the crate's API changes require versus its + last published version. .DESCRIPTION For every crate whose `[package] version` differs from the PR base ref (the "publishing set"), this script: 1. reads the on-disk (this-PR) version, - 2. runs `cargo semver-checks --package ` against the crate's last - published crates.io release (the tool's default baseline), - 3. parses the baseline version and the required change type from the - output, and - 4. computes the *minimum* version the bump should reach given the detected - API changes. + 2. discovers the crate's last published version with + `cargo info --registry ` (crates.io by default), + 3. runs `cargo semver-checks --package --baseline-version ` + so the comparison source is the registry the crate is actually + published to, + 4. parses the required change type from the output, and + 5. computes the *minimum* version the increment should reach given the + detected API changes. It writes a Markdown report to -ReportPath containing: - - a summary status line (🛑 when at least one crate is under-bumped, - ✅ when every publishing crate is sufficiently bumped), - - a table: Crate | crates.io | This PR | Minimum required | Status, - - collapsible per-crate `cargo semver-checks` detail for under-bumped + - a summary status line (🛑 when at least one crate is under-incremented, + ✅ when every publishing crate is sufficiently incremented), + - a table: Crate | Published | This PR | Minimum required | Status, + - collapsible per-crate `cargo semver-checks` detail for under-incremented crates, and - a link to the triggering Actions run. Two GitHub Actions step outputs are written to -GitHubOutput: publishing = 'true' | 'false' - status = 'pass' | 'fail' (fail = at least one crate under-bumped) + status = 'pass' | 'fail' (fail = at least one crate under-incremented) The report is informational: callers keep the job non-failing. @@ -47,6 +50,11 @@ .PARAMETER RepoRoot Repository root. Defaults to the current directory. +.PARAMETER Registry + Registry whose last published version is used as the semver-checks baseline. + Defaults to 'crates-io'. Override with a private registry name (as configured + in `.cargo/config.toml`) when the crates are published elsewhere. + .PARAMETER GitHubOutput Path to the GitHub Actions step-output file. Defaults to $env:GITHUB_OUTPUT. #> @@ -56,6 +64,7 @@ param( [Parameter(Mandatory = $true)][string]$ReportPath, [string]$RunUrl = '', [string]$RepoRoot = (Get-Location).Path, + [string]$Registry = 'crates-io', [string]$GitHubOutput = $env:GITHUB_OUTPUT ) @@ -83,9 +92,9 @@ if ($changedFolders.Count -eq 0) { } # --- 2. Run cargo-semver-checks per crate and gather results. ----------------- -# A row per crate: cargo name, on-disk (this-PR) version, crates.io baseline, +# A row per crate: cargo name, on-disk (this-PR) version, published baseline, # the parsed required change type, the computed minimum version, and the raw -# tool detail (for under-bumped crates). +# tool detail (for under-incremented crates). $rows = New-Object System.Collections.Generic.List[object] Push-Location $RepoRoot @@ -97,29 +106,43 @@ try { $cargoName = $pkg.Name $onDisk = Get-CurrentVersion -cargoTomlPath (Join-Path $RepoRoot "crates/$folder/Cargo.toml") - Write-Host "cargo semver-checks: $cargoName (on-disk v$onDisk) vs crates.io..." + # Discover the baseline from the registry (crates.io by default, or a + # private registry via -Registry) using `cargo info`, run outside the + # workspace so it reports the published version, not the local one. + $baselineVersion = Get-PublishedCrateVersion -CargoName $cargoName -Registry $Registry + + if ([string]::IsNullOrWhiteSpace($baselineVersion)) { + # Never published on this registry: no baseline to compare against, + # so nothing to enforce. Skip the (slow) semver-checks run. + Write-Host "cargo semver-checks: $cargoName (on-disk v$onDisk) — not published on '$Registry', skipping." + $rows.Add([pscustomobject]@{ + Crate = $cargoName + Baseline = '—' + OnDisk = $onDisk + Required = $onDisk + Sufficient = $true + ChangeType = 'none' + Detail = '' + }) + continue + } + + Write-Host "cargo semver-checks: $cargoName (on-disk v$onDisk) vs $Registry v$baselineVersion..." $PSNativeCommandUseErrorActionPreference = $false - $out = & cargo semver-checks --package $cargoName --all-features --color never 2>&1 | Out-String + $out = & cargo semver-checks --package $cargoName --baseline-version $baselineVersion --all-features --color never 2>&1 | Out-String $changeType = ConvertFrom-SemverChecksOutput -Output $out -ExitCode $LASTEXITCODE -PackageName $cargoName - # Baseline version cargo-semver-checks pulled from the registry, parsed - # from a "Checking v -> v" line. Absent for a - # never-published crate (changeType 'none'). - $baseline = '—' - $m = [regex]::Match($out, "(?im)^\s*Checking\s+\S+\s+v(\d+\.\d+\.\d+\S*)\s*->") - if ($m.Success) { $baseline = $m.Groups[1].Value } - # A 'breaking'/'non-breaking' verdict means the detected API changes need - # a stronger bump than the on-disk version gives over the baseline; the - # minimum acceptable version is baseline bumped by that change type. - # 'patch' means the on-disk bump already covers the changes; 'none' means - # the crate is new (no baseline to violate). + # a stronger increment than the on-disk version gives over the baseline; + # the minimum acceptable version is the baseline incremented by that + # change type. 'patch' means the on-disk version already covers the + # changes; 'none' means the crate is new (no baseline to violate). $sufficient = $changeType -in @('patch', 'none') if ($sufficient) { $required = $onDisk } else { - $required = Get-NextVersion -currentVersion $baseline -ChangeType $changeType + $required = Get-NextVersion -currentVersion $baselineVersion -ChangeType $changeType } # Extract just the failure blocks for the collapsible detail. @@ -130,7 +153,7 @@ try { $rows.Add([pscustomobject]@{ Crate = $cargoName - Baseline = $baseline + Baseline = $baselineVersion OnDisk = $onDisk Required = $required Sufficient = $sufficient @@ -149,23 +172,23 @@ $overallFail = $underBumped.Count -gt 0 $bt = [char]0x60 # backtick, kept in a variable to avoid PowerShell escaping. $sb = New-Object System.Text.StringBuilder if ($overallFail) { - [void]$sb.AppendLine('## 🛑 Semver: version bump required') + [void]$sb.AppendLine('## 🛑 Additional version increments required') [void]$sb.AppendLine() - [void]$sb.AppendLine("${bt}cargo semver-checks${bt} compared the crate(s) this PR publishes against their latest **crates.io** release. **$($underBumped.Count) of $($rows.Count)** need a higher version bump than the one on disk:") + [void]$sb.AppendLine("${bt}cargo semver-checks${bt} compared the crate(s) this PR publishes against their latest published release. **$($underBumped.Count) of $($rows.Count)** need a higher version than this PR sets — the increment already applied is not enough for the API changes:") } else { - [void]$sb.AppendLine('## ✅ Semver: version bumps look sufficient') + [void]$sb.AppendLine('## ✅ Version increments look sufficient') [void]$sb.AppendLine() - [void]$sb.AppendLine("${bt}cargo semver-checks${bt} compared the **$($rows.Count)** crate(s) this PR publishes against their latest **crates.io** release. Every bump is sufficient for the detected API changes.") + [void]$sb.AppendLine("${bt}cargo semver-checks${bt} compared the **$($rows.Count)** crate(s) this PR publishes against their latest published release. Every version increment is sufficient for the detected API changes.") } [void]$sb.AppendLine() -[void]$sb.AppendLine('| Crate | crates.io | This PR | Minimum required | Status |') +[void]$sb.AppendLine('| Crate | Published | This PR | Minimum required | Status |') [void]$sb.AppendLine('|---|---|---|---|---|') foreach ($r in $rows) { if ($r.Sufficient) { $status = '✅ ok' $req = $r.Required } else { - $status = "🛑 bump to at least ${bt}$($r.Required)${bt}" + $status = "🛑 increase to at least ${bt}$($r.Required)${bt}" $req = "**$($r.Required)**" } [void]$sb.AppendLine("| ${bt}$($r.Crate)${bt} | $($r.Baseline) | $($r.OnDisk) | $req | $status |") @@ -183,14 +206,14 @@ if ($overallFail) { [void]$sb.AppendLine('') [void]$sb.AppendLine() } - [void]$sb.AppendLine('> If these breaking changes are intentional, bump each crate to at least its **Minimum required** version. This check is **informational and does not block the merge**.') + [void]$sb.AppendLine('> If these breaking changes are intentional, increase each crate to at least its **Minimum required** version. This check is **informational and does not block the merge**.') } else { [void]$sb.AppendLine('> This check is informational and does not block the merge.') } if (-not [string]::IsNullOrEmpty($RunUrl)) { [void]$sb.AppendLine() - [void]$sb.AppendLine("[View the semver check run]($RunUrl)") + [void]$sb.AppendLine("[View the check run]($RunUrl)") } Set-Content -Path $ReportPath -Value $sb.ToString() -Encoding utf8 diff --git a/scripts/lib/release-flow.ps1 b/scripts/lib/release-flow.ps1 index 6410cb512..2a9ce074b 100644 --- a/scripts/lib/release-flow.ps1 +++ b/scripts/lib/release-flow.ps1 @@ -541,6 +541,7 @@ function Resolve-ReleaseSet { # planning. A module-scope variable (rather than a captured closure) so the # classifier scriptblock below resolves it in the module session state. $script:ReleaseRepoRoot = $null +$script:ReleaseRegistry = 'crates-io' # The production classifier handed to Resolve-ReleaseSet / Invoke-PlanReview. # Defined at module scope so, when invoked via `& $GetRequiredChangeType` deep @@ -549,7 +550,7 @@ $script:ReleaseRepoRoot = $null # Get-CrateRequiredChangeType for tests. $script:DefaultSemverClassifier = { param([string]$folder, [string]$cargoName) - Get-CrateRequiredChangeType -Folder $folder -CargoName $cargoName -RepoRoot $script:ReleaseRepoRoot + Get-CrateRequiredChangeType -Folder $folder -CargoName $cargoName -RepoRoot $script:ReleaseRepoRoot -Registry $script:ReleaseRegistry } function Format-ConventionalCommits { @@ -2037,6 +2038,9 @@ function Invoke-ReleasePackagesMain { [AllowEmptyCollection()] [string[]]$Packages = @(), + [Parameter()] + [string]$Registry = 'crates-io', + [Parameter()] [switch]$Force ) @@ -2141,6 +2145,7 @@ function Invoke-ReleasePackagesMain { # test suites Mock Get-CrateRequiredChangeType. Get-CrateRequiredChangeType # memoises per crate so the interactive review loop re-resolves cheaply. $script:ReleaseRepoRoot = $repoRoot.Path + $script:ReleaseRegistry = $Registry # The classifier and Invoke-PlanReview surface planning errors (e.g. a pin # below the semver-required version) as terminating errors. Let them diff --git a/scripts/lib/releasing.ps1 b/scripts/lib/releasing.ps1 index ef884691a..67464b554 100644 --- a/scripts/lib/releasing.ps1 +++ b/scripts/lib/releasing.ps1 @@ -443,7 +443,8 @@ function Get-CrateRequiredChangeType { param( [Parameter(Mandatory = $true)][string]$Folder, [Parameter(Mandatory = $true)][string]$CargoName, - [Parameter(Mandatory = $true)][string]$RepoRoot + [Parameter(Mandatory = $true)][string]$RepoRoot, + [string]$Registry = 'crates-io' ) if ($null -eq $script:CrateSemverVerdictCache) { $script:CrateSemverVerdictCache = @{} } @@ -451,8 +452,8 @@ function Get-CrateRequiredChangeType { return $script:CrateSemverVerdictCache[$CargoName] } - Write-Host "🔎 cargo semver-checks: analysing '$CargoName' against its last published version..." -ForegroundColor Cyan - $result = Invoke-CrateSemverCheck -PackageName $CargoName -RepoRoot $RepoRoot + Write-Host "🔎 cargo semver-checks: analysing '$CargoName' against its last published version on '$Registry'..." -ForegroundColor Cyan + $result = Invoke-CrateSemverCheck -PackageName $CargoName -RepoRoot $RepoRoot -Registry $Registry $script:CrateSemverVerdictCache[$CargoName] = $result return $result } @@ -494,11 +495,67 @@ function Get-WorkspacePackages { return $packages } -# Runs `cargo semver-checks` for a single crate against its last published version -# (the crates.io / registry baseline — cargo-semver-checks' default when no -# --baseline-* flag is passed) and returns the minimum change type the current -# working-tree API requires: 'breaking', 'non-breaking', 'patch', or 'none' when -# the crate has never been published (no baseline to compare against). +# Extracts the published version from `cargo info` output. Pure (no I/O) so it can +# be unit-tested against captured tool output. `cargo info` prints a line like +# "version: 1.2.3" (optionally with a trailing " (yanked)" note we ignore); this +# returns the version string, or $null when no such line is present (the crate has +# never been published on the queried registry). +# +# NOTE (documented limitation): the returned version is the last *published* one. +# If a prior version was committed but never published (e.g. an aborted release), +# the API delta introduced by that unpublished version is folded into the diff +# against the older published baseline and cannot be isolated. The workaround is a +# manual explicit-version pin. This is intentional — see docs/releasing.md. +function ConvertFrom-CargoInfoOutput { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Output + ) + + $m = [regex]::Match($Output, '(?im)^\s*version:\s*([^\s(]+)') + if ($m.Success) { return $m.Groups[1].Value.Trim() } + return $null +} + +# Looks up the latest version of a crate as published on the given registry, using +# `cargo info --registry `. The command is run in a throwaway +# temp directory OUTSIDE the workspace: run inside the workspace, cargo resolves +# the name to the local path package and reports the working-tree version instead +# of the published one. Returns the version string, or $null when the crate has +# never been published (no registry entry). Registry defaults to 'crates-io' but +# can be pointed at a private registry (e.g. an enterprise mirror) so the baseline +# comes from the same source the crate is actually published to. +function Get-PublishedCrateVersion { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$CargoName, + [string]$Registry = 'crates-io' + ) + + $scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("semver-baseline-" + [System.Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $scratch -Force | Out-Null + try { + Push-Location $scratch + try { + $PSNativeCommandUseErrorActionPreference = $false + $output = & cargo info $CargoName --registry $Registry 2>&1 | Out-String + } finally { + Pop-Location + } + } finally { + Remove-Item $scratch -Recurse -Force -ErrorAction SilentlyContinue + } + + return ConvertFrom-CargoInfoOutput -Output $output +} + +# Runs `cargo semver-checks` for a single crate against its last published version. +# The baseline version is discovered via `cargo info --registry ` (see +# Get-PublishedCrateVersion) and pinned with `--baseline-version`, so the comparison +# source is the registry the crate is actually published to rather than +# cargo-semver-checks' hard-coded crates.io default. Returns the minimum change type +# the current working-tree API requires: 'breaking', 'non-breaking', 'patch', or +# 'none' when the crate has never been published (no baseline to compare against). # # The current API is analysed from the working tree, so a coordinated release's # in-progress source edits (including a dependency whose public types this crate @@ -508,15 +565,23 @@ function Invoke-CrateSemverCheck { [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$PackageName, - [Parameter(Mandatory = $true)][string]$RepoRoot + [Parameter(Mandatory = $true)][string]$RepoRoot, + [string]$Registry = 'crates-io' ) + # Discover the baseline from the registry. No published version => brand-new + # crate: nothing to compare against, so it imposes no change-type floor. + $baselineVersion = Get-PublishedCrateVersion -CargoName $PackageName -Registry $Registry + if ([string]::IsNullOrWhiteSpace($baselineVersion)) { + return 'none' + } + Push-Location $RepoRoot try { # Manage the exit code manually; cargo-semver-checks exits non-zero when a # bump is required, which is expected and not an error for our purposes. $PSNativeCommandUseErrorActionPreference = $false - $output = & cargo semver-checks --package $PackageName --all-features --color never 2>&1 | Out-String + $output = & cargo semver-checks --package $PackageName --baseline-version $baselineVersion --all-features --color never 2>&1 | Out-String $exitCode = $LASTEXITCODE } finally { Pop-Location diff --git a/scripts/release-packages.ps1 b/scripts/release-packages.ps1 index 563b5e062..1a60e38c5 100644 --- a/scripts/release-packages.ps1 +++ b/scripts/release-packages.ps1 @@ -56,8 +56,9 @@ Cargo's 0.x.y SemVer rules are honored throughout: for `0.x.y` packages a Breaking change becomes `0.(x+1).0`, NonBreaking and Patch both map to incrementing `y`. Every release in the plan is classified by running - `cargo semver-checks` against each crate's last published crates.io - version: a dependent whose own public API is unaffected by the release + `cargo semver-checks` against each crate's last published version on the + configured registry (crates.io by default; override with -Registry): a + dependent whose own public API is unaffected by the release cascades as a `patch` (it still re-releases to pick up the new dependency version), while a dependent whose API actually changes (e.g. because it re-exports a changed type) cascades at the severity `cargo semver-checks` @@ -151,6 +152,14 @@ never explicit version pins, so the pin-vs-cascade rejection cannot fire there. +.PARAMETER Registry + The registry whose last published version is used as the semver-checks + baseline for every crate in the plan. Defaults to `crates-io`. Point it at + a private registry (by the name configured in `.cargo/config.toml`) when the + crates are published somewhere other than crates.io — the baseline version + is discovered with `cargo info --registry `, so the + comparison uses the same source the crates are actually released to. + .EXAMPLE # Pin a specific version, e.g. release 'my-package' as 1.0.0. ./scripts/release-packages.ps1 -Packages 'my-package@1.0.0' @@ -185,7 +194,10 @@ param( [switch]$All, [Parameter(ParameterSetName = 'ByPackages')] - [switch]$Force + [switch]$Force, + + [Parameter()] + [string]$Registry = 'crates-io' ) # All helpers, configuration, and Invoke-ReleasePackagesMain live in the @@ -206,7 +218,7 @@ $mode = switch ($PSCmdlet.ParameterSetName) { # code, preserving the historical `exit 1`-on-error contract for command-line # and CI callers. try { - Invoke-ReleasePackagesMain -Mode $mode -Packages $Packages -Force:$Force | Out-Null + Invoke-ReleasePackagesMain -Mode $mode -Packages $Packages -Force:$Force -Registry $Registry | Out-Null } catch { Write-Error $_.Exception.Message exit 1 diff --git a/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 b/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 index 3b4673a1d..25823e667 100644 --- a/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 +++ b/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 @@ -320,6 +320,39 @@ Describe 'ConvertFrom-SemverChecksOutput' { } } +Describe 'ConvertFrom-CargoInfoOutput' { + It 'extracts the version from a cargo info block' { + $out = "bytesbuf_io`n An I/O adapter`nversion: 0.6.0`nlicense: MIT" + ConvertFrom-CargoInfoOutput -Output $out | Should -Be '0.6.0' + } + + It 'ignores a trailing yanked/annotation note after the version' { + $out = "version: 1.2.3 (yanked)" + ConvertFrom-CargoInfoOutput -Output $out | Should -Be '1.2.3' + } + + It 'returns $null when the crate is not published (no version line)' { + $out = "error: crate oxidizer_nope not found in registry" + ConvertFrom-CargoInfoOutput -Output $out | Should -BeNullOrEmpty + } + + It 'returns $null for empty output' { + ConvertFrom-CargoInfoOutput -Output '' | Should -BeNullOrEmpty + } + + It 'reports the last PUBLISHED version, not an unpublished intermediate (documented limitation)' { + # Baseline discovery reads whatever `cargo info` returns from the + # registry: the last *published* version. If a breaking change was + # committed as 4.0.0 but never published (an aborted release), the + # registry still reports 3.3.3, so semver-checks diffs the working tree + # against 3.3.3 and the delta from the unpublished 4.0.0 cannot be + # isolated. This is intentional; the workaround is a manual explicit + # version pin. See docs/releasing.md. + $registryReportsLastPublished = "version: 3.3.3" + ConvertFrom-CargoInfoOutput -Output $registryReportsLastPublished | Should -Be '3.3.3' + } +} + Describe 'Get-StrongerChangeType' { It 'returns the higher-ranked change type' { Get-StrongerChangeType 'patch' 'breaking' | Should -Be 'breaking' From 071cfa094bd14c7e50e3e46ef3ce6a5a69137cac Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:22:07 +0100 Subject: [PATCH 08/17] fix(ci): robust registry baseline lookup for semver check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first live run classified every publishing crate as sufficient because 'cargo info --registry crates-io' is rejected in a vanilla environment (cargo reserves the name 'crates-io' and refuses it as an explicit --registry value). The error was swallowed, so every crate fell through to 'unpublished' -> no baseline -> vacuously sufficient (the silent-fallback trap). Fix Get-PublishedCrateVersion: - Use cargo's default source (NO --registry flag) for crates.io; pass --registry only for a genuine private registry name. - Detect source-replacement dev boxes ('crates-io is replaced with remote registry X') and retry against the named replacement. - THROW on an indeterminate lookup instead of returning \, so a transient/config failure cannot be mistaken for 'unpublished' and silently skip the semver floor. \ is returned ONLY when cargo info specifically reports the crate is absent from the registry (a brand-new crate). The CI report now renders a stop-sign '⚠️ baseline unknown' row (not a false green) when a lookup fails. New pure helpers Test-CargoInfoCrateMissing and Get-CargoInfoReplacementRegistry are unit-tested; 399/399 Pester green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/ci/semver-report.ps1 | 38 ++++++- scripts/lib/releasing.ps1 | 105 ++++++++++++++---- .../unit/releasing/PureFunctions.Tests.ps1 | 35 ++++++ 3 files changed, 153 insertions(+), 25 deletions(-) diff --git a/scripts/ci/semver-report.ps1 b/scripts/ci/semver-report.ps1 index 1ad7409d8..1a1bdc35f 100644 --- a/scripts/ci/semver-report.ps1 +++ b/scripts/ci/semver-report.ps1 @@ -109,7 +109,23 @@ try { # Discover the baseline from the registry (crates.io by default, or a # private registry via -Registry) using `cargo info`, run outside the # workspace so it reports the published version, not the local one. - $baselineVersion = Get-PublishedCrateVersion -CargoName $cargoName -Registry $Registry + # A genuinely-unpublished crate returns $null; an indeterminate lookup + # throws — surface that as a ⚠️ row rather than a silent "sufficient". + try { + $baselineVersion = Get-PublishedCrateVersion -CargoName $cargoName -Registry $Registry + } catch { + Write-Host "cargo semver-checks: $cargoName (on-disk v$onDisk) — baseline lookup FAILED: $($_.Exception.Message)" + $rows.Add([pscustomobject]@{ + Crate = $cargoName + Baseline = '⚠️ unknown' + OnDisk = $onDisk + Required = '?' + Sufficient = $false + ChangeType = 'unknown' + Detail = "Baseline lookup failed — could not determine the last published version on '$Registry'. The semver comparison was skipped for this crate; verify the version increment manually.`n`n$($_.Exception.Message)" + }) + continue + } if ([string]::IsNullOrWhiteSpace($baselineVersion)) { # Never published on this registry: no baseline to compare against, @@ -117,7 +133,7 @@ try { Write-Host "cargo semver-checks: $cargoName (on-disk v$onDisk) — not published on '$Registry', skipping." $rows.Add([pscustomobject]@{ Crate = $cargoName - Baseline = '—' + Baseline = 'unpublished' OnDisk = $onDisk Required = $onDisk Sufficient = $true @@ -167,6 +183,8 @@ try { # --- 3. Render the Markdown report. ------------------------------------------- $underBumped = @($rows | Where-Object { -not $_.Sufficient }) +$unknownRows = @($rows | Where-Object { $_.ChangeType -eq 'unknown' }) +$realUnder = @($underBumped | Where-Object { $_.ChangeType -ne 'unknown' }) $overallFail = $underBumped.Count -gt 0 $bt = [char]0x60 # backtick, kept in a variable to avoid PowerShell escaping. @@ -174,7 +192,12 @@ $sb = New-Object System.Text.StringBuilder if ($overallFail) { [void]$sb.AppendLine('## 🛑 Additional version increments required') [void]$sb.AppendLine() - [void]$sb.AppendLine("${bt}cargo semver-checks${bt} compared the crate(s) this PR publishes against their latest published release. **$($underBumped.Count) of $($rows.Count)** need a higher version than this PR sets — the increment already applied is not enough for the API changes:") + if ($realUnder.Count -gt 0) { + [void]$sb.AppendLine("${bt}cargo semver-checks${bt} compared the crate(s) this PR publishes against their latest published release. **$($realUnder.Count) of $($rows.Count)** need a higher version than this PR sets — the increment already applied is not enough for the API changes:") + } + if ($unknownRows.Count -gt 0) { + [void]$sb.AppendLine("⚠️ The baseline (last published version) could not be determined for **$($unknownRows.Count)** crate(s); their version increment was **not** verified — check them manually.") + } } else { [void]$sb.AppendLine('## ✅ Version increments look sufficient') [void]$sb.AppendLine() @@ -184,7 +207,10 @@ if ($overallFail) { [void]$sb.AppendLine('| Crate | Published | This PR | Minimum required | Status |') [void]$sb.AppendLine('|---|---|---|---|---|') foreach ($r in $rows) { - if ($r.Sufficient) { + if ($r.ChangeType -eq 'unknown') { + $status = '⚠️ baseline unknown — not verified' + $req = '—' + } elseif ($r.Sufficient) { $status = '✅ ok' $req = $r.Required } else { @@ -198,7 +224,9 @@ foreach ($r in $rows) { $fence = "$bt$bt$bt" if ($overallFail) { foreach ($r in $underBumped) { - [void]$sb.AppendLine("
🛑 $($r.Crate) — cargo semver-checks detail") + $icon = if ($r.ChangeType -eq 'unknown') { '⚠️' } else { '🛑' } + $summary = if ($r.ChangeType -eq 'unknown') { 'baseline lookup detail' } else { 'cargo semver-checks detail' } + [void]$sb.AppendLine("
$icon $($r.Crate) — $summary") [void]$sb.AppendLine() [void]$sb.AppendLine($fence) [void]$sb.AppendLine($r.Detail) diff --git a/scripts/lib/releasing.ps1 b/scripts/lib/releasing.ps1 index 67464b554..31193e600 100644 --- a/scripts/lib/releasing.ps1 +++ b/scripts/lib/releasing.ps1 @@ -498,8 +498,7 @@ function Get-WorkspacePackages { # Extracts the published version from `cargo info` output. Pure (no I/O) so it can # be unit-tested against captured tool output. `cargo info` prints a line like # "version: 1.2.3" (optionally with a trailing " (yanked)" note we ignore); this -# returns the version string, or $null when no such line is present (the crate has -# never been published on the queried registry). +# returns the version string, or $null when no such line is present. # # NOTE (documented limitation): the returned version is the last *published* one. # If a prior version was committed but never published (e.g. an aborted release), @@ -517,14 +516,53 @@ function ConvertFrom-CargoInfoOutput { return $null } -# Looks up the latest version of a crate as published on the given registry, using -# `cargo info --registry `. The command is run in a throwaway -# temp directory OUTSIDE the workspace: run inside the workspace, cargo resolves -# the name to the local path package and reports the working-tree version instead -# of the published one. Returns the version string, or $null when the crate has -# never been published (no registry entry). Registry defaults to 'crates-io' but -# can be pointed at a private registry (e.g. an enterprise mirror) so the baseline -# comes from the same source the crate is actually published to. +# Pure predicate: does `cargo info` output specifically indicate the crate is +# absent from the registry (never published)? This is deliberately narrow — the +# same "no version" outcome can arise from a transient/config failure, which must +# NOT be treated as "unpublished" (that would silently skip the semver floor). +function Test-CargoInfoCrateMissing { + [CmdletBinding()] + param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Output) + + return [bool]( + $Output -match '(?i)could not find\s+`?[\w-]+`?\s+in registry' -or + $Output -match '(?i)not found in\s+(the\s+)?registry' -or + $Output -match '(?i)no\s+(released|published|matching)\s+versions?' + ) +} + +# Pure helper: when crates.io is source-replaced in .cargo/config (common on +# enterprise dev boxes), `cargo info` without an explicit --registry refuses and +# names the replacement, e.g. "crates-io is replaced with remote registry Foo". +# Returns that replacement registry name so the lookup can be retried against it, +# or $null when no such message is present. +function Get-CargoInfoReplacementRegistry { + [CmdletBinding()] + param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Output) + + $m = [regex]::Match($Output, '(?i)replaced with (?:remote )?registry\s+([^\s;,]+)') + if ($m.Success) { return $m.Groups[1].Value.Trim() } + return $null +} + +# Looks up the latest version of a crate as published on the given registry with +# `cargo info`, run in a throwaway temp directory OUTSIDE the workspace (run inside +# the workspace, cargo resolves the name to the local path package and reports the +# working-tree version instead of the published one). +# +# Registry defaults to 'crates-io', which uses cargo's DEFAULT source with no +# --registry flag: cargo reserves the name "crates-io" and rejects it as an +# explicit --registry value, so passing it fails in a vanilla environment. A +# non-default Registry value is passed through as --registry to target a +# private registry (e.g. an enterprise mirror). If crates.io is source-replaced +# locally, the refusal is detected and the lookup retried against the named +# replacement. +# +# Returns the version string; $null ONLY when cargo info specifically reports the +# crate is not in the registry (a brand-new, never-published crate — no baseline). +# Any other failure to determine the version throws rather than silently returning +# $null, so a transient/config error cannot be mistaken for "unpublished" and +# quietly skip the semver floor. function Get-PublishedCrateVersion { [CmdletBinding()] param( @@ -532,21 +570,48 @@ function Get-PublishedCrateVersion { [string]$Registry = 'crates-io' ) - $scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("semver-baseline-" + [System.Guid]::NewGuid().ToString('N')) - New-Item -ItemType Directory -Path $scratch -Force | Out-Null - try { - Push-Location $scratch + $runCargoInfo = { + param([string[]]$InfoArgs) + $scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("semver-baseline-" + [System.Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $scratch -Force | Out-Null try { - $PSNativeCommandUseErrorActionPreference = $false - $output = & cargo info $CargoName --registry $Registry 2>&1 | Out-String + Push-Location $scratch + try { + $PSNativeCommandUseErrorActionPreference = $false + $out = & cargo @InfoArgs 2>&1 | Out-String + $code = $LASTEXITCODE + } finally { + Pop-Location + } } finally { - Pop-Location + Remove-Item $scratch -Recurse -Force -ErrorAction SilentlyContinue } - } finally { - Remove-Item $scratch -Recurse -Force -ErrorAction SilentlyContinue + return [pscustomobject]@{ Output = $out; ExitCode = $code } + } + + if ($Registry -and $Registry -ne 'crates-io') { + $result = & $runCargoInfo @('info', $CargoName, '--registry', $Registry) + } else { + # Default crates.io source — no --registry flag (see note above). + $result = & $runCargoInfo @('info', $CargoName) + + # Source-replaced dev box: retry against the named replacement registry. + if ($result.ExitCode -ne 0) { + $replacement = Get-CargoInfoReplacementRegistry -Output $result.Output + if ($replacement) { + $result = & $runCargoInfo @('info', $CargoName, '--registry', $replacement) + } + } + } + + $version = ConvertFrom-CargoInfoOutput -Output $result.Output + if ($version) { return $version } + + if (Test-CargoInfoCrateMissing -Output $result.Output) { + return $null } - return ConvertFrom-CargoInfoOutput -Output $output + throw "Could not determine the last published version of '$CargoName' on registry '$Registry' (cargo info exit $($result.ExitCode)). This usually means cargo info failed, the registry is unreachable, or the output format was unexpected — it is NOT treated as 'unpublished' to avoid silently skipping the semver check. Output:`n$($result.Output)" } # Runs `cargo semver-checks` for a single crate against its last published version. diff --git a/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 b/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 index 25823e667..1281d0c15 100644 --- a/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 +++ b/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 @@ -353,6 +353,41 @@ Describe 'ConvertFrom-CargoInfoOutput' { } } +Describe 'Test-CargoInfoCrateMissing' { + It 'matches "could not find in registry"' { + Test-CargoInfoCrateMissing -Output 'error: could not find `foo` in registry `crates-io`' | Should -BeTrue + } + It 'matches "not found in registry"' { + Test-CargoInfoCrateMissing -Output 'error: crate foo not found in the registry' | Should -BeTrue + } + It 'matches "no matching versions"' { + Test-CargoInfoCrateMissing -Output 'error: no matching versions for `foo`' | Should -BeTrue + } + It 'does NOT match a transient/config failure (so it will throw, not skip)' { + # A network timeout or reserved-name rejection must not be mistaken for + # an unpublished crate — that would silently skip the semver floor. + Test-CargoInfoCrateMissing -Output 'error: failed to query registry: operation timed out' | Should -BeFalse + Test-CargoInfoCrateMissing -Output 'error: crates-io is replaced with remote registry Foo' | Should -BeFalse + } + It 'does NOT match empty output' { + Test-CargoInfoCrateMissing -Output '' | Should -BeFalse + } +} + +Describe 'Get-CargoInfoReplacementRegistry' { + It 'extracts the replacement registry name from a source-replacement error' { + Get-CargoInfoReplacementRegistry -Output 'error: crates-io is replaced with remote registry OxidizerDependencies;' | + Should -Be 'OxidizerDependencies' + } + It 'handles the "remote registry" phrasing without a trailing punctuation' { + Get-CargoInfoReplacementRegistry -Output 'crates-io is replaced with remote registry MyMirror' | + Should -Be 'MyMirror' + } + It 'returns $null when there is no replacement message' { + Get-CargoInfoReplacementRegistry -Output 'version: 1.2.3' | Should -BeNullOrEmpty + } +} + Describe 'Get-StrongerChangeType' { It 'returns the higher-ranked change type' { Get-StrongerChangeType 'patch' 'breaking' | Should -Be 'breaking' From ee2ee46d0a4c42c5053dfb3cd593031b1b8482f1 Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:28:50 +0100 Subject: [PATCH 09/17] fix(ci): parse ANSI-colorized cargo info output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cargo forces colour even when piped in CI, wrapping the version label in SGR escapes (ESC[1mESC[92mversion:ESC[0m 0.6.0), so the anchored ^version: match missed and every baseline lookup threw. Pass --color never to cargo info and strip ANSI escapes in ConvertFrom-CargoInfoOutput as a belt-and-suspenders. The previous run correctly surfaced this as a stop-sign '⚠️ baseline unknown' row rather than a false green, confirming the throw-on-indeterminate safety net. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/lib/releasing.ps1 | 13 +++++++++---- .../Pester/unit/releasing/PureFunctions.Tests.ps1 | 8 ++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/scripts/lib/releasing.ps1 b/scripts/lib/releasing.ps1 index 31193e600..dbd0639a0 100644 --- a/scripts/lib/releasing.ps1 +++ b/scripts/lib/releasing.ps1 @@ -511,7 +511,12 @@ function ConvertFrom-CargoInfoOutput { [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Output ) - $m = [regex]::Match($Output, '(?im)^\s*version:\s*([^\s(]+)') + # Strip ANSI colour escapes first: cargo forces colour in CI even when its + # output is piped, which would otherwise wrap the "version:" label in escape + # sequences (ESC[1mESC[92mversion:ESC[0m) and defeat the anchored match. + $clean = [regex]::Replace($Output, "`e\[[0-9;]*m", '') + + $m = [regex]::Match($clean, '(?im)^\s*version:\s*([^\s(]+)') if ($m.Success) { return $m.Groups[1].Value.Trim() } return $null } @@ -590,16 +595,16 @@ function Get-PublishedCrateVersion { } if ($Registry -and $Registry -ne 'crates-io') { - $result = & $runCargoInfo @('info', $CargoName, '--registry', $Registry) + $result = & $runCargoInfo @('info', $CargoName, '--registry', $Registry, '--color', 'never') } else { # Default crates.io source — no --registry flag (see note above). - $result = & $runCargoInfo @('info', $CargoName) + $result = & $runCargoInfo @('info', $CargoName, '--color', 'never') # Source-replaced dev box: retry against the named replacement registry. if ($result.ExitCode -ne 0) { $replacement = Get-CargoInfoReplacementRegistry -Output $result.Output if ($replacement) { - $result = & $runCargoInfo @('info', $CargoName, '--registry', $replacement) + $result = & $runCargoInfo @('info', $CargoName, '--registry', $replacement, '--color', 'never') } } } diff --git a/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 b/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 index 1281d0c15..263888456 100644 --- a/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 +++ b/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 @@ -331,6 +331,14 @@ Describe 'ConvertFrom-CargoInfoOutput' { ConvertFrom-CargoInfoOutput -Output $out | Should -Be '1.2.3' } + It 'parses ANSI-colorized output (cargo forces colour in CI)' { + # cargo wraps the label in SGR escapes even when piped in CI: + # ESC[1mESC[92mversion:ESC[0m 0.6.0 + $esc = [char]0x1b + $out = "${esc}[1m${esc}[92mversion:${esc}[0m 0.6.0" + ConvertFrom-CargoInfoOutput -Output $out | Should -Be '0.6.0' + } + It 'returns $null when the crate is not published (no version line)' { $out = "error: crate oxidizer_nope not found in registry" ConvertFrom-CargoInfoOutput -Output $out | Should -BeNullOrEmpty From faf4752582cac898c4dbbd337aa75de783699a8b Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Fri, 10 Jul 2026 06:49:01 +0100 Subject: [PATCH 10/17] fix(ci): do not post semver comment when no crates are publishing A PR that bumps no crate versions (e.g. this feature PR itself) still got a spurious '0 crate(s) - version increments look sufficient' comment on every push. Cause: Get-PackagesWithVersionChanges returns a HashSet via Write-Output -NoEnumerate, and '@(...)' wraps that as a 1-element array containing the (empty) set, so the 'publishing set empty' guard (\.Count -eq 0) never fired and the step emitted publishing=true. Cast the return to [string[]] (which reliably enumerates the set) before sorting, so an empty publishing set collapses to a zero-length array, the early return fires, publishing=false is emitted, and the Post Semver Comment step (gated on publishing == 'true') is skipped. Added two GitFs regression tests exercising the exact call-site expression for the empty and populated cases. 402/402 Pester green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/ci/semver-report.ps1 | 10 ++++++++-- .../Pester/unit/releasing/GitFs.Tests.ps1 | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/scripts/ci/semver-report.ps1 b/scripts/ci/semver-report.ps1 index 1a1bdc35f..b639657a8 100644 --- a/scripts/ci/semver-report.ps1 +++ b/scripts/ci/semver-report.ps1 @@ -71,7 +71,13 @@ param( . "$PSScriptRoot/../lib/releasing.ps1" # --- 1. Determine the publishing set (version-bumped published crates). ------- -$changedFolders = @(Get-PackagesWithVersionChanges -RepoRoot $RepoRoot -BaseRef $BaseRef) +# Get-PackagesWithVersionChanges returns a HashSet via Write-Output -NoEnumerate +# (so its internal callers can use .Contains()). Casting to [string[]] reliably +# enumerates that set into a flat array — do NOT wrap the raw return in @(...), +# which produces a 1-element array containing the (possibly empty) HashSet and +# makes the "nothing to publish" guard below never fire (leading to a spurious +# "0 crate(s)" comment on non-publishing PRs). +$changedFolders = @([string[]](Get-PackagesWithVersionChanges -RepoRoot $RepoRoot -BaseRef $BaseRef) | Sort-Object) $packages = Get-WorkspacePackages -repoRoot $RepoRoot $byFolder = @{} foreach ($p in $packages) { $byFolder[$p.Folder] = $p } @@ -99,7 +105,7 @@ $rows = New-Object System.Collections.Generic.List[object] Push-Location $RepoRoot try { - foreach ($folder in ($changedFolders | Sort-Object)) { + foreach ($folder in $changedFolders) { $pkg = $byFolder[$folder] if ($null -eq $pkg) { continue } diff --git a/scripts/tests/Pester/unit/releasing/GitFs.Tests.ps1 b/scripts/tests/Pester/unit/releasing/GitFs.Tests.ps1 index 5d3c0793a..0c56f7e54 100644 --- a/scripts/tests/Pester/unit/releasing/GitFs.Tests.ps1 +++ b/scripts/tests/Pester/unit/releasing/GitFs.Tests.ps1 @@ -427,6 +427,25 @@ Describe 'Get-PackagesWithVersionChanges' { $changedSet = Get-PackagesWithVersionChanges -RepoRoot $w2.Path -BaseRef 'HEAD' $changedSet.Count | Should -Be 0 } + + It 'the semver-report call-site expression yields a zero-length array when nothing changed' { + # Regression: the function returns a HashSet via Write-Output -NoEnumerate. + # Wrapping the raw return in @(...) produces a 1-element array containing + # the (empty) HashSet, so a `.Count -eq 0` guard never fires and a spurious + # "0 crate(s)" comment is posted. semver-report.ps1 casts to [string[]] and + # sorts; this must collapse to a true empty array. + Reset-ReleaseScriptCaches + $w = New-SyntheticWorkspace -Preset Linear2 -Path (Join-Path $TestDrive 'versionchanges-callsite-empty') + $changedFolders = @([string[]](Get-PackagesWithVersionChanges -RepoRoot $w.Path -BaseRef 'HEAD') | Sort-Object) + $changedFolders.Count | Should -Be 0 + } + + It 'the semver-report call-site expression yields the changed folders (sorted) when versions differ' { + Reset-ReleaseScriptCaches + $changedFolders = @([string[]](Get-PackagesWithVersionChanges -RepoRoot $script:Ws.Path -BaseRef 'HEAD~1') | Sort-Object) + $changedFolders.Count | Should -Be 1 + $changedFolders[0] | Should -Be 'a' + } } Describe 'Get-PendingReleases' { From 3feea1cf8cb09c9871822de43b72822e259347a5 Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:57:12 +0100 Subject: [PATCH 11/17] fix(ci): distinguish under-increment (fail) from unknown baseline (warn) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report headline and step 'status' output were computed from all non-sufficient rows, which lumps a crate whose baseline could not be determined (ChangeType 'unknown') in with genuinely under-incremented crates. That produced a '🛑 Additional version increments required' headline and status=fail even when nothing was actually under-incremented and the check was merely incomplete — contradicting the script's own contract that 'fail' means at least one crate is under-incremented. Separate the two: - fail (🛑) only when a real under-increment is detected; - warn (⚠️ 'Semver baseline could not be determined') when the sole problem is an unresolved baseline; - pass (✅) otherwise. The per-crate table and collapsible detail already flag unknown-baseline rows distinctly; this aligns the headline, the footer note, and the step 'status' output with that distinction. Docstring updated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/ci/semver-report.ps1 | 46 ++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/scripts/ci/semver-report.ps1 b/scripts/ci/semver-report.ps1 index b639657a8..39e87e1eb 100644 --- a/scripts/ci/semver-report.ps1 +++ b/scripts/ci/semver-report.ps1 @@ -26,15 +26,22 @@ It writes a Markdown report to -ReportPath containing: - a summary status line (🛑 when at least one crate is under-incremented, + ⚠️ when the only problem is a baseline that could not be determined, ✅ when every publishing crate is sufficiently incremented), - a table: Crate | Published | This PR | Minimum required | Status, - - collapsible per-crate `cargo semver-checks` detail for under-incremented - crates, and + - collapsible per-crate detail for under-incremented crates and for + crates whose baseline lookup failed, and - a link to the triggering Actions run. Two GitHub Actions step outputs are written to -GitHubOutput: publishing = 'true' | 'false' - status = 'pass' | 'fail' (fail = at least one crate under-incremented) + status = 'pass' | 'warn' | 'fail' + fail = at least one crate is under-incremented; + warn = no crate is under-incremented but at least one + baseline could not be determined (check incomplete); + pass = every crate is sufficiently incremented. + A failed/unknown baseline is NEVER reported as 'fail' on its own — 'fail' + is reserved for genuine under-increments per this contract. The report is informational: callers keep the job non-failing. @@ -191,19 +198,27 @@ try { $underBumped = @($rows | Where-Object { -not $_.Sufficient }) $unknownRows = @($rows | Where-Object { $_.ChangeType -eq 'unknown' }) $realUnder = @($underBumped | Where-Object { $_.ChangeType -ne 'unknown' }) -$overallFail = $underBumped.Count -gt 0 +$hasReal = $realUnder.Count -gt 0 +$hasUnknown = $unknownRows.Count -gt 0 +$anyProblem = $underBumped.Count -gt 0 $bt = [char]0x60 # backtick, kept in a variable to avoid PowerShell escaping. $sb = New-Object System.Text.StringBuilder -if ($overallFail) { +if ($hasReal) { + # At least one crate is genuinely under-incremented — the real failure case. [void]$sb.AppendLine('## 🛑 Additional version increments required') [void]$sb.AppendLine() - if ($realUnder.Count -gt 0) { - [void]$sb.AppendLine("${bt}cargo semver-checks${bt} compared the crate(s) this PR publishes against their latest published release. **$($realUnder.Count) of $($rows.Count)** need a higher version than this PR sets — the increment already applied is not enough for the API changes:") - } - if ($unknownRows.Count -gt 0) { - [void]$sb.AppendLine("⚠️ The baseline (last published version) could not be determined for **$($unknownRows.Count)** crate(s); their version increment was **not** verified — check them manually.") + [void]$sb.AppendLine("${bt}cargo semver-checks${bt} compared the crate(s) this PR publishes against their latest published release. **$($realUnder.Count) of $($rows.Count)** need a higher version than this PR sets — the increment already applied is not enough for the API changes:") + if ($hasUnknown) { + [void]$sb.AppendLine() + [void]$sb.AppendLine("⚠️ The baseline (last published version) could not be determined for **$($unknownRows.Count)** other crate(s); their version increment was **not** verified — check them manually.") } +} elseif ($hasUnknown) { + # No crate is under-incremented; the only problem is an unresolved baseline. + # This is a warning (the check is incomplete), NOT an under-increment failure. + [void]$sb.AppendLine('## ⚠️ Semver baseline could not be determined') + [void]$sb.AppendLine() + [void]$sb.AppendLine("${bt}cargo semver-checks${bt} could not determine the last published version for **$($unknownRows.Count)** of $($rows.Count) crate(s), so their version increment was **not** verified. No crate was found to be under-incremented; check the crate(s) below manually.") } else { [void]$sb.AppendLine('## ✅ Version increments look sufficient') [void]$sb.AppendLine() @@ -228,7 +243,7 @@ foreach ($r in $rows) { [void]$sb.AppendLine() $fence = "$bt$bt$bt" -if ($overallFail) { +if ($anyProblem) { foreach ($r in $underBumped) { $icon = if ($r.ChangeType -eq 'unknown') { '⚠️' } else { '🛑' } $summary = if ($r.ChangeType -eq 'unknown') { 'baseline lookup detail' } else { 'cargo semver-checks detail' } @@ -240,7 +255,11 @@ if ($overallFail) { [void]$sb.AppendLine('
') [void]$sb.AppendLine() } - [void]$sb.AppendLine('> If these breaking changes are intentional, increase each crate to at least its **Minimum required** version. This check is **informational and does not block the merge**.') + if ($hasReal) { + [void]$sb.AppendLine('> If these breaking changes are intentional, increase each crate to at least its **Minimum required** version. This check is **informational and does not block the merge**.') + } else { + [void]$sb.AppendLine('> The baseline could not be determined for the crate(s) above, so their version increments were not verified. This check is **informational and does not block the merge**.') + } } else { [void]$sb.AppendLine('> This check is informational and does not block the merge.') } @@ -252,4 +271,5 @@ if (-not [string]::IsNullOrEmpty($RunUrl)) { Set-Content -Path $ReportPath -Value $sb.ToString() -Encoding utf8 Write-Host "Report written to $ReportPath" -Write-Outputs -publishing 'true' -status ($(if ($overallFail) { 'fail' } else { 'pass' })) +$status = if ($hasReal) { 'fail' } elseif ($hasUnknown) { 'warn' } else { 'pass' } +Write-Outputs -publishing 'true' -status $status From 8ebbd42ec6362922939ae8026300ec1170cea16c Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:14:26 +0100 Subject: [PATCH 12/17] refactor(ci): semver baseline from previous version-bump commit (git history) Replace the crates.io registry baseline with a git-history baseline for semver-checks breakage analysis, per Sander's review on PR #554. Instead of discovering the last published version via `cargo info` and diffing against `--baseline-version`, the crate's previous version-bump commit (the most recent commit that changed its `[package] version`) is located in git history and passed to cargo-semver-checks as `--baseline-rev `, which rebuilds the baseline rustdoc from source. Why: versioning is a source-level concern and one workflow must serve both OSS and enterprise/offline consumers. The registry lookup cannot reach crates.io in private environments and its published content lags the source, so a declared-but-unpublished version (e.g. an aborted 4.0.0) was wrongly judged against a stale published 3.3.3. The git baseline is whatever the repo last declared, regardless of publish status, and needs no network access. Changes: - New Get-PreviousVersionBumpCommit helper (scripts/lib/releasing.ps1), unit-tested, returning the bump commit SHA + declared version. It matches only the [package] version (skips dependency-version and publish-only edits) and honours BaseRef so a PR's own bump is excluded. - CI report (scripts/ci/semver-report.ps1) now uses --baseline-rev; drops the -Registry param. Baseline column shows " ()". Graceful warn/fail contract preserved (unknown baseline is warn). - Release planner unified onto the same git baseline: Invoke-CrateSemverCheck uses the previous version-bump commit; -Registry / SEMVER_REGISTRY plumbing removed from release-packages.ps1, release-flow.ps1 and main.yml. The registry helpers (Get-PublishedCrateVersion, ConvertFrom-CargoInfoOutput, Test-CargoInfoCrateMissing, Get-CargoInfoReplacementRegistry) and their tests are removed. - docs/releasing.md: describe the previous-version-bump-commit baseline; the former "Baseline limitation" (4.0.0 vs 3.3.3) now behaves correctly. Validated: full Pester suite passes (316 unit + 24 scenarios + 53 integration) and a real `cargo semver-checks --baseline-rev` smoke run produces verdicts with no registry/`cargo info` calls. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/main.yml | 30 +-- docs/releasing.md | 47 ++-- scripts/ci/semver-report.ps1 | 92 ++++--- scripts/lib/release-flow.ps1 | 37 ++- scripts/lib/releasing.ps1 | 252 ++++++++---------- scripts/release-packages.ps1 | 29 +- .../Pester/unit/releasing/GitFs.Tests.ps1 | 60 +++++ .../unit/releasing/PureFunctions.Tests.ps1 | 76 ------ 8 files changed, 292 insertions(+), 331 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a3d7b524b..1c713caeb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -196,14 +196,17 @@ jobs: - name: Check Spelling run: just spellcheck - # Runs cargo-semver-checks against each crate's LAST PUBLISHED version on - # crates.io (the tool's default baseline — no --baseline-rev), scoped to the - # crates whose `version =` is bumped in this PR (the "publishing set"). The - # job is informational only: it never fails the build and posts a fresh PR - # comment on every push with a per-crate table (crates.io vs this-PR vs the + # Runs cargo-semver-checks against each crate's PREVIOUS VERSION-BUMP COMMIT in + # git history (the most recent commit on the base branch that changed the + # crate's `[package] version`, passed as --baseline-rev), scoped to the crates + # whose `version =` is bumped in this PR (the "publishing set"). The baseline + # rustdoc is rebuilt from that commit's source, so no registry access is needed + # and the check behaves identically for OSS and enterprise/offline consumers. + # The job is informational only: it never fails the build and posts a fresh PR + # comment on every push with a per-crate table (baseline vs this-PR vs the # minimum required version) plus a link to the run. It is a no-op on PRs that # do not bump any crate version, since only version-bumping PRs trigger a - # publish and thus need a compatibility check against the published baseline. + # publish and thus need a compatibility check against the previous version. semver: needs: [delta] if: github.event_name == 'pull_request' && needs.delta.outputs.skip != 'true' @@ -224,13 +227,13 @@ jobs: cargo-tools: CARGO_SEMVER_CHECKS_VERSION # Compute the publishing set (version-bumped published crates), run - # cargo-semver-checks per crate against its last published version on the - # configured registry (SEMVER_REGISTRY, crates.io by default), and render - # a Markdown report (scripts/ci/semver-report.ps1 — reuses the release - # library's version-diff / next-version helpers). Sets outputs: + # cargo-semver-checks per crate against its previous version-bump commit in + # git history (--baseline-rev, no registry), and render a Markdown report + # (scripts/ci/semver-report.ps1 — reuses the release library's version-diff + # / next-version helpers). Sets outputs: # publishing = true|false (any crate version bumped) # status = pass|fail (any crate under-incremented for its API changes) - # Kept non-failing so registry/network hiccups never block the PR. + # Kept non-failing so a git/tooling hiccup never blocks the PR. - name: Semver Report id: report continue-on-error: true @@ -238,13 +241,10 @@ jobs: env: BASE_REF: ${{ github.base_ref }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - # Registry the baseline version is read from. Override in a fork that - # publishes to a private registry (name as in .cargo/config.toml). - SEMVER_REGISTRY: crates-io run: | $ErrorActionPreference = 'Stop' git fetch --no-tags origin "+refs/heads/$env:BASE_REF`:refs/remotes/origin/$env:BASE_REF" - ./scripts/ci/semver-report.ps1 -BaseRef "origin/$env:BASE_REF" -ReportPath semver-comment.md -RunUrl "$env:RUN_URL" -Registry "$env:SEMVER_REGISTRY" + ./scripts/ci/semver-report.ps1 -BaseRef "origin/$env:BASE_REF" -ReportPath semver-comment.md -RunUrl "$env:RUN_URL" # Post a NEW comment on every push (not a sticky update) so each run's # result is visible in the PR timeline. Only fires when the PR actually diff --git a/docs/releasing.md b/docs/releasing.md index af8fb41ac..bae9ccfbd 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -207,11 +207,23 @@ published dependent as a cascade-source release. The change type for every release in the plan — both the directly-requested (user-source) crates and the cascade-pulled dependents — is derived by running [`cargo semver-checks`](https://crates.io/crates/cargo-semver-checks) -against each crate's **last published version in the registry** (looked -up with `cargo info`, so it works against crates.io or a private -registry). The current working-tree API is analysed, so a coordinated -release's in-progress edits — including a dependency whose public types -a dependent re-exports — are reflected in the dependent's own API diff. +against each crate's **previous version-bump commit in git history** — +the most recent commit that changed the crate's `[package] version`, +supplied to the tool as `--baseline-rev `. cargo-semver-checks +rebuilds the baseline rustdoc from the crate's source at that commit, so +**no registry access is required** and the check behaves identically for +open-source (crates.io) and enterprise/offline consumers. The current +working-tree API is analysed, so a coordinated release's in-progress +edits — including a dependency whose public types a dependent re-exports +— are reflected in the dependent's own API diff. + +Versioning is treated as a **source-level** concern: the baseline is the +version the repository last *declared*, regardless of whether it was ever +published anywhere. This is what lets one workflow serve both public and +private/enterprise environments (which cannot reach crates.io and whose +published content lags the source), and it means an aborted release that +bumped a crate to `4.0.0` without publishing is still the baseline the +next change is measured against. This replaces the former `[package.metadata.cargo_check_external_types]` allowlist heuristic. That @@ -237,25 +249,24 @@ notion (major / minor / none); the exact parsing lives in | a major-level change is required | `breaking` | | only a minor-level change is required | `non-breaking` | | compatible / no update required | `patch` | -| crate not yet in the registry (never published) | no constraint | +| no prior version-bump commit (new crate) | no constraint | Cascade dependents are floored at `patch` (they must re-release to pick up the new dependency version even when their own public API is unchanged), then raised to whatever their own `cargo semver-checks` result requires. -**Baseline limitation.** The baseline is always the crate's *last -published* version on the registry. If a version was committed but never -published — for example an aborted release that bumped `bytesbuf` to -`4.0.0` without it reaching the registry — the registry still reports the -older `3.3.3`, so `cargo semver-checks` diffs the working tree against -`3.3.3` and cannot isolate the API delta introduced by the unpublished -`4.0.0`. This is intentional: the tool trusts the registry as the source -of truth for "what consumers actually have". When you need to release on -top of an unpublished version, pin the target explicitly with a -`@..` token (and `-Force` if the pin is below -what the diff-against-`3.3.3` computes) instead of relying on the -automatic classification. +**Baseline semantics.** The baseline is the crate's previous +version-bump commit — the most recent commit (before the change under +review) that altered the crate's `[package] version`. Because it comes +from git history rather than a registry, a version that was committed but +never published *is* the baseline: an aborted release that bumped +`bytesbuf` to `4.0.0` without publishing means the next change is +compared against `4.0.0`, not a stale published `3.3.3`. A brand-new +crate with no prior version-bump commit has no baseline and imposes no +constraint. This works offline and in enterprise environments with no +crates.io access, since the baseline API is rebuilt from the crate's own +source at the baseline commit. The planner enforces **topological consistency**: if a user-supplied change type for a package is *weaker* than `cargo semver-checks` diff --git a/scripts/ci/semver-report.ps1 b/scripts/ci/semver-report.ps1 index 39e87e1eb..286c3e9d8 100644 --- a/scripts/ci/semver-report.ps1 +++ b/scripts/ci/semver-report.ps1 @@ -7,19 +7,20 @@ .SYNOPSIS Runs cargo-semver-checks for each crate a PR is publishing and renders a rich, per-crate Markdown report comparing the on-disk version increment - against the minimum increment the crate's API changes require versus its - last published version. + against the minimum increment the crate's API changes require versus the + crate's previous version-bump commit in git history. .DESCRIPTION For every crate whose `[package] version` differs from the PR base ref (the "publishing set"), this script: 1. reads the on-disk (this-PR) version, - 2. discovers the crate's last published version with - `cargo info --registry ` (crates.io by default), - 3. runs `cargo semver-checks --package --baseline-version ` - so the comparison source is the registry the crate is actually - published to, + 2. locates the crate's PREVIOUS version-bump commit — the most recent commit + reachable from -BaseRef (so this PR's own bump, which lives only on the PR + head, is excluded) that changed the crate's `[package] version` line, + 3. runs `cargo semver-checks --package --baseline-rev ` so the + comparison source is the crate's own source at that commit (the baseline + rustdoc is rebuilt from git — no registry access, works OSS + enterprise), 4. parses the required change type from the output, and 5. computes the *minimum* version the increment should reach given the detected API changes. @@ -28,9 +29,9 @@ - a summary status line (🛑 when at least one crate is under-incremented, ⚠️ when the only problem is a baseline that could not be determined, ✅ when every publishing crate is sufficiently incremented), - - a table: Crate | Published | This PR | Minimum required | Status, + - a table: Crate | Baseline | This PR | Minimum required | Status, - collapsible per-crate detail for under-incremented crates and for - crates whose baseline lookup failed, and + crates whose baseline could not be determined, and - a link to the triggering Actions run. Two GitHub Actions step outputs are written to -GitHubOutput: @@ -47,6 +48,8 @@ .PARAMETER BaseRef Git ref to diff against, e.g. 'origin/main'. Must be fetched beforehand. + Also the ref the previous version-bump commit is searched from, so this PR's + own version bump is excluded from the baseline. .PARAMETER ReportPath Path to write the Markdown report to. @@ -57,11 +60,6 @@ .PARAMETER RepoRoot Repository root. Defaults to the current directory. -.PARAMETER Registry - Registry whose last published version is used as the semver-checks baseline. - Defaults to 'crates-io'. Override with a private registry name (as configured - in `.cargo/config.toml`) when the crates are published elsewhere. - .PARAMETER GitHubOutput Path to the GitHub Actions step-output file. Defaults to $env:GITHUB_OUTPUT. #> @@ -71,7 +69,6 @@ param( [Parameter(Mandatory = $true)][string]$ReportPath, [string]$RunUrl = '', [string]$RepoRoot = (Get-Location).Path, - [string]$Registry = 'crates-io', [string]$GitHubOutput = $env:GITHUB_OUTPUT ) @@ -105,7 +102,7 @@ if ($changedFolders.Count -eq 0) { } # --- 2. Run cargo-semver-checks per crate and gather results. ----------------- -# A row per crate: cargo name, on-disk (this-PR) version, published baseline, +# A row per crate: cargo name, on-disk (this-PR) version, git-history baseline, # the parsed required change type, the computed minimum version, and the raw # tool detail (for under-incremented crates). $rows = New-Object System.Collections.Generic.List[object] @@ -119,13 +116,12 @@ try { $cargoName = $pkg.Name $onDisk = Get-CurrentVersion -cargoTomlPath (Join-Path $RepoRoot "crates/$folder/Cargo.toml") - # Discover the baseline from the registry (crates.io by default, or a - # private registry via -Registry) using `cargo info`, run outside the - # workspace so it reports the published version, not the local one. - # A genuinely-unpublished crate returns $null; an indeterminate lookup - # throws — surface that as a ⚠️ row rather than a silent "sufficient". + # Locate the crate's previous version-bump commit reachable from BaseRef + # (so this PR's own bump is excluded). Its declared [package] version is + # the baseline number; its SHA is the semver-checks --baseline-rev. + # Locating the commit inspects git history only — no registry access. try { - $baselineVersion = Get-PublishedCrateVersion -CargoName $cargoName -Registry $Registry + $bump = Get-PreviousVersionBumpCommit -RepoRoot $RepoRoot -BaseRef $BaseRef -PackageFolder $folder } catch { Write-Host "cargo semver-checks: $cargoName (on-disk v$onDisk) — baseline lookup FAILED: $($_.Exception.Message)" $rows.Add([pscustomobject]@{ @@ -135,18 +131,19 @@ try { Required = '?' Sufficient = $false ChangeType = 'unknown' - Detail = "Baseline lookup failed — could not determine the last published version on '$Registry'. The semver comparison was skipped for this crate; verify the version increment manually.`n`n$($_.Exception.Message)" + Detail = "Baseline lookup failed — could not locate the crate's previous version-bump commit in git history. The semver comparison was skipped for this crate; verify the version increment manually.`n`n$($_.Exception.Message)" }) continue } - if ([string]::IsNullOrWhiteSpace($baselineVersion)) { - # Never published on this registry: no baseline to compare against, - # so nothing to enforce. Skip the (slow) semver-checks run. - Write-Host "cargo semver-checks: $cargoName (on-disk v$onDisk) — not published on '$Registry', skipping." + if ($null -eq $bump) { + # No prior version-bump commit reachable from BaseRef: a brand-new + # crate (or one with no committed version history) — there is no + # baseline to compare against, so nothing to enforce. + Write-Host "cargo semver-checks: $cargoName (on-disk v$onDisk) — no prior version-bump commit, skipping." $rows.Add([pscustomobject]@{ Crate = $cargoName - Baseline = 'unpublished' + Baseline = 'new crate' OnDisk = $onDisk Required = $onDisk Sufficient = $true @@ -156,11 +153,32 @@ try { continue } - Write-Host "cargo semver-checks: $cargoName (on-disk v$onDisk) vs $Registry v$baselineVersion..." + $baselineVersion = $bump.Version + $baselineSha = $bump.Sha + $shortSha = if ($baselineSha.Length -ge 7) { $baselineSha.Substring(0, 7) } else { $baselineSha } + + Write-Host "cargo semver-checks: $cargoName (on-disk v$onDisk) vs v$baselineVersion @ $shortSha..." $PSNativeCommandUseErrorActionPreference = $false - $out = & cargo semver-checks --package $cargoName --baseline-version $baselineVersion --all-features --color never 2>&1 | Out-String + $out = & cargo semver-checks --package $cargoName --baseline-rev $baselineSha --all-features --color never 2>&1 | Out-String - $changeType = ConvertFrom-SemverChecksOutput -Output $out -ExitCode $LASTEXITCODE -PackageName $cargoName + # A build/tool failure makes ConvertFrom-SemverChecksOutput throw (no + # silent fallback); surface that as a ⚠️ unknown row rather than failing + # the whole report or misreporting the crate as sufficient. + try { + $changeType = ConvertFrom-SemverChecksOutput -Output $out -ExitCode $LASTEXITCODE -PackageName $cargoName + } catch { + Write-Host "cargo semver-checks: $cargoName — analysis FAILED: $($_.Exception.Message)" + $rows.Add([pscustomobject]@{ + Crate = $cargoName + Baseline = "⚠️ $baselineVersion ($shortSha)" + OnDisk = $onDisk + Required = '?' + Sufficient = $false + ChangeType = 'unknown' + Detail = "cargo semver-checks could not be evaluated against ``$baselineSha`` (v$baselineVersion). The version increment was NOT verified — check it manually.`n`n$($_.Exception.Message)" + }) + continue + } # A 'breaking'/'non-breaking' verdict means the detected API changes need # a stronger increment than the on-disk version gives over the baseline; @@ -182,7 +200,7 @@ try { $rows.Add([pscustomobject]@{ Crate = $cargoName - Baseline = $baselineVersion + Baseline = "$baselineVersion ($shortSha)" OnDisk = $onDisk Required = $required Sufficient = $sufficient @@ -208,24 +226,24 @@ if ($hasReal) { # At least one crate is genuinely under-incremented — the real failure case. [void]$sb.AppendLine('## 🛑 Additional version increments required') [void]$sb.AppendLine() - [void]$sb.AppendLine("${bt}cargo semver-checks${bt} compared the crate(s) this PR publishes against their latest published release. **$($realUnder.Count) of $($rows.Count)** need a higher version than this PR sets — the increment already applied is not enough for the API changes:") + [void]$sb.AppendLine("${bt}cargo semver-checks${bt} compared the crate(s) this PR publishes against their previous version-bump commit in git history. **$($realUnder.Count) of $($rows.Count)** need a higher version than this PR sets — the increment already applied is not enough for the API changes:") if ($hasUnknown) { [void]$sb.AppendLine() - [void]$sb.AppendLine("⚠️ The baseline (last published version) could not be determined for **$($unknownRows.Count)** other crate(s); their version increment was **not** verified — check them manually.") + [void]$sb.AppendLine("⚠️ The baseline (previous version-bump commit) could not be determined for **$($unknownRows.Count)** other crate(s); their version increment was **not** verified — check them manually.") } } elseif ($hasUnknown) { # No crate is under-incremented; the only problem is an unresolved baseline. # This is a warning (the check is incomplete), NOT an under-increment failure. [void]$sb.AppendLine('## ⚠️ Semver baseline could not be determined') [void]$sb.AppendLine() - [void]$sb.AppendLine("${bt}cargo semver-checks${bt} could not determine the last published version for **$($unknownRows.Count)** of $($rows.Count) crate(s), so their version increment was **not** verified. No crate was found to be under-incremented; check the crate(s) below manually.") + [void]$sb.AppendLine("${bt}cargo semver-checks${bt} could not determine the previous version-bump commit for **$($unknownRows.Count)** of $($rows.Count) crate(s), so their version increment was **not** verified. No crate was found to be under-incremented; check the crate(s) below manually.") } else { [void]$sb.AppendLine('## ✅ Version increments look sufficient') [void]$sb.AppendLine() - [void]$sb.AppendLine("${bt}cargo semver-checks${bt} compared the **$($rows.Count)** crate(s) this PR publishes against their latest published release. Every version increment is sufficient for the detected API changes.") + [void]$sb.AppendLine("${bt}cargo semver-checks${bt} compared the **$($rows.Count)** crate(s) this PR publishes against their previous version-bump commit in git history. Every version increment is sufficient for the detected API changes.") } [void]$sb.AppendLine() -[void]$sb.AppendLine('| Crate | Published | This PR | Minimum required | Status |') +[void]$sb.AppendLine('| Crate | Baseline | This PR | Minimum required | Status |') [void]$sb.AppendLine('|---|---|---|---|---|') foreach ($r in $rows) { if ($r.ChangeType -eq 'unknown') { diff --git a/scripts/lib/release-flow.ps1 b/scripts/lib/release-flow.ps1 index 2a9ce074b..54a46ed59 100644 --- a/scripts/lib/release-flow.ps1 +++ b/scripts/lib/release-flow.ps1 @@ -368,7 +368,8 @@ function Update-EntryForRequiredChangeType { # Note: cascade is one-level. The set of dependents reachable from a user # target is the transitive published dependents BFS; each dependent's # cascade-applied change type is derived from cargo-semver-checks analysing the -# dependent's OWN current working-tree public API vs its last published version +# dependent's OWN current working-tree public API vs its previous version-bump +# commit # (floored at 'patch' — it must re-release to pick up the new dependency # version even when its own API is unchanged). This replaces the former # allowed_external_types exposure heuristic. @@ -378,7 +379,7 @@ function Resolve-ReleaseSet { [Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$WorkspaceBaseline, # Classifier scriptblock: (folder, cargoName) -> 'breaking'|'non-breaking'| # 'patch'|'none'. Decides each crate's minimum change type from its real - # API diff (cargo-semver-checks) vs its last published version. Production + # API diff (cargo-semver-checks) vs its previous version-bump commit. Production # passes $script:DefaultSemverClassifier (which calls # Get-CrateRequiredChangeType); the default here is a no-op ('none') so # callers that only exercise version/pin/BFS math need not supply one. @@ -465,7 +466,7 @@ function Resolve-ReleaseSet { # The dependent's change type is decided by cargo-semver-checks # analysing ITS OWN current working-tree API (which already reflects # the target's in-progress changes, including re-exported types) vs - # its last published version — floored at 'patch' because it must be + # its previous version-bump commit — floored at 'patch' because it must be # re-released to pick up the new dependency version even when its own # public API is unchanged. This replaces the old # allowed_external_types exposure heuristic. @@ -521,8 +522,8 @@ function Resolve-ReleaseSet { } # Self-floor: raise each USER-source entry's change type to at least what - # cargo-semver-checks requires for its OWN public API vs its last published - # version. The cascade above already floored dependents; this catches + # cargo-semver-checks requires for its OWN public API vs its previous + # version-bump commit. The cascade above already floored dependents; this catches # user-source ROOTS that nothing cascades into (e.g. releasing a single leaf # crate whose own API broke). Author intent is never downgraded — only raised # when the real API diff demands a stronger change type. @@ -541,7 +542,6 @@ function Resolve-ReleaseSet { # planning. A module-scope variable (rather than a captured closure) so the # classifier scriptblock below resolves it in the module session state. $script:ReleaseRepoRoot = $null -$script:ReleaseRegistry = 'crates-io' # The production classifier handed to Resolve-ReleaseSet / Invoke-PlanReview. # Defined at module scope so, when invoked via `& $GetRequiredChangeType` deep @@ -550,7 +550,7 @@ $script:ReleaseRegistry = 'crates-io' # Get-CrateRequiredChangeType for tests. $script:DefaultSemverClassifier = { param([string]$folder, [string]$cargoName) - Get-CrateRequiredChangeType -Folder $folder -CargoName $cargoName -RepoRoot $script:ReleaseRepoRoot -Registry $script:ReleaseRegistry + Get-CrateRequiredChangeType -Folder $folder -CargoName $cargoName -RepoRoot $script:ReleaseRepoRoot } function Format-ConventionalCommits { @@ -2038,9 +2038,6 @@ function Invoke-ReleasePackagesMain { [AllowEmptyCollection()] [string[]]$Packages = @(), - [Parameter()] - [string]$Registry = 'crates-io', - [Parameter()] [switch]$Force ) @@ -2051,10 +2048,10 @@ function Invoke-ReleasePackagesMain { } # cargo-semver-checks decides every change type in the plan (against each - # crate's last published version). It is a hard dependency — there is no + # crate's previous version-bump commit). It is a hard dependency — there is no # heuristic fallback — so fail fast with an actionable message if missing. if (-not (Test-CommandExists -command 'cargo-semver-checks')) { - throw "cargo-semver-checks is not installed or not found in your PATH. Install the version pinned in constants.env (CARGO_SEMVER_CHECKS_VERSION) with 'cargo install cargo-semver-checks --version --locked'. It is required to classify releases against their last published versions." + throw "cargo-semver-checks is not installed or not found in your PATH. Install the version pinned in constants.env (CARGO_SEMVER_CHECKS_VERSION) with 'cargo install cargo-semver-checks --version --locked'. It is required to classify releases against their previous version-bump commit." } $repoRoot = Get-Location @@ -2137,15 +2134,15 @@ function Invoke-ReleasePackagesMain { $planReviewMode = if ($Mode -eq 'targeted') { 'targeted' } else { 'all-changed' } # Classifier passed to the planner: decides every change type in the plan - # (user-source and cascade) from each crate's real API diff vs its last - # published version — no allowed_external_types heuristic, no fallback. - # $script:DefaultSemverClassifier is a module-scope scriptblock (defined - # below Resolve-ReleaseSet) so it resolves Get-CrateRequiredChangeType and - # $script:ReleaseRepoRoot in the module session state; that also lets the - # test suites Mock Get-CrateRequiredChangeType. Get-CrateRequiredChangeType - # memoises per crate so the interactive review loop re-resolves cheaply. + # (user-source and cascade) from each crate's real API diff vs its previous + # version-bump commit in git history — no allowed_external_types heuristic, + # no registry, no fallback. $script:DefaultSemverClassifier is a module-scope + # scriptblock (defined below Resolve-ReleaseSet) so it resolves + # Get-CrateRequiredChangeType and $script:ReleaseRepoRoot in the module + # session state; that also lets the test suites Mock Get-CrateRequiredChangeType. + # Get-CrateRequiredChangeType memoises per crate so the interactive review loop + # re-resolves cheaply. $script:ReleaseRepoRoot = $repoRoot.Path - $script:ReleaseRegistry = $Registry # The classifier and Invoke-PlanReview surface planning errors (e.g. a pin # below the semver-required version) as terminating errors. Let them diff --git a/scripts/lib/releasing.ps1 b/scripts/lib/releasing.ps1 index dbd0639a0..94ca37e96 100644 --- a/scripts/lib/releasing.ps1 +++ b/scripts/lib/releasing.ps1 @@ -365,6 +365,80 @@ function Get-PackageVersionFromRef { return $result } +# Finds the crate's PREVIOUS version-bump commit: the most recent commit reachable +# from $BaseRef whose diff changed the `[package] version = "..."` line in +# crates//Cargo.toml. Returns a [pscustomobject] with: +# Sha - the commit SHA (suitable for `cargo semver-checks --baseline-rev`) +# Version - the [package] version declared at that commit +# or $null when no such commit exists (a brand-new crate introduced in the range +# above $BaseRef, or a crate with no committed history at $BaseRef). +# +# This is the SOURCE-LEVEL semver baseline: the version the repository previously +# *declared*, regardless of whether it was ever published to a registry. It works +# identically in OSS and enterprise/offline environments because it never touches +# crates.io — the baseline rustdoc is rebuilt from the crate's source at that +# commit by cargo-semver-checks' `--baseline-rev`. +# +# $BaseRef controls which bump is "previous": +# - CI report: pass the PR base (e.g. origin/main) so THIS PR's own bump — which +# lives only on the PR head — is excluded, and the baseline is the last bump +# that already landed on the base branch. +# - Release planner: pass HEAD so the baseline is the last committed version bump +# (the previous release), compared against the working-tree API being released. +# +# Implementation: walk `git log -- ` newest-first and, for +# each touching commit, compare the [package] version at the commit against the +# version at its parent (via Get-PackageVersionFromRef, which matches only the +# [package]-scoped version — not dependency-table versions). The first commit +# where they differ is the bump. Matching on the parsed [package] version (rather +# than a raw `-G` line-diff) means a commit that only edited a dependency's +# `version = "..."` or toggled `publish` is correctly skipped. +# +# Cached for the lifetime of the script run (the script never commits, so the +# result per (RepoRoot, BaseRef, PackageFolder) is invariant). Cleared by +# Reset-ReleaseScriptCaches between test scenarios. +function Get-PreviousVersionBumpCommit { + param( + [Parameter(Mandatory = $true)][string]$RepoRoot, + [Parameter(Mandatory = $true)][string]$BaseRef, + [Parameter(Mandatory = $true)][string]$PackageFolder + ) + + if ($null -eq $script:PreviousVersionBumpCommitCache) { + $script:PreviousVersionBumpCommitCache = @{} + } + $cacheKey = "$RepoRoot`u{2402}$BaseRef`u{2402}$PackageFolder" + if ($script:PreviousVersionBumpCommitCache.ContainsKey($cacheKey)) { + return $script:PreviousVersionBumpCommitCache[$cacheKey] + } + + $relPath = "crates/$PackageFolder/Cargo.toml" + $commits = Invoke-Git -Arguments @('log', '--format=%H', $BaseRef, '--', $relPath) -RepoRoot $RepoRoot -AllowFailure + + $result = $null + if ($null -ne $commits) { + foreach ($line in @($commits)) { + $sha = $line.ToString().Trim() + if ([string]::IsNullOrWhiteSpace($sha)) { continue } + + $verAt = Get-PackageVersionFromRef -RepoRoot $RepoRoot -BaseRef $sha -PackageFolder $PackageFolder + if ($null -eq $verAt) { continue } + + # Version at the parent commit; $null when the crate did not exist there + # (this commit introduced it) or when $sha is the repository root. + $verParent = Get-PackageVersionFromRef -RepoRoot $RepoRoot -BaseRef "$sha^" -PackageFolder $PackageFolder + + if ($verAt -ne $verParent) { + $result = [pscustomobject]@{ Sha = $sha; Version = $verAt } + break + } + } + } + + $script:PreviousVersionBumpCommitCache[$cacheKey] = $result + return $result +} + # --- WORKSPACE METADATA --- # Cached `cargo metadata --no-deps` for the workspace. Graph topology is safe to cache @@ -386,6 +460,7 @@ $script:CachedWorkspaceMetadata = $null $script:PackageLastReleaseBaselineCache = $null $script:PackageCommittedChangesCache = $null $script:PackageVersionAtRefCache = $null +$script:PreviousVersionBumpCommitCache = $null function Get-WorkspaceMetadata { param([string]$repoRoot) @@ -428,23 +503,24 @@ function Reset-ReleaseScriptCaches { $script:PackageLastReleaseBaselineCache = $null $script:PackageCommittedChangesCache = $null $script:PackageVersionAtRefCache = $null + $script:PreviousVersionBumpCommitCache = $null $script:CrateSemverVerdictCache = $null } # Memoised, mockable classifier: returns the minimum change type a crate's -# current working-tree public API requires versus its last published version -# ('breaking' / 'non-breaking' / 'patch' / 'none' when never published), by -# running cargo-semver-checks once per crate. Resolve-ReleaseSet is invoked many -# times during the interactive review loop, so results are cached per cargo name -# for the run. Test suites Mock this function to supply deterministic verdicts -# without invoking the real tool (see the scenario harness). +# current working-tree public API requires versus its previous version-bump +# commit ('breaking' / 'non-breaking' / 'patch' / 'none' when there is no prior +# bump to compare against), by running cargo-semver-checks once per crate. +# Resolve-ReleaseSet is invoked many times during the interactive review loop, so +# results are cached per cargo name for the run. Test suites Mock this function to +# supply deterministic verdicts without invoking the real tool (see the scenario +# harness). function Get-CrateRequiredChangeType { [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$Folder, [Parameter(Mandatory = $true)][string]$CargoName, - [Parameter(Mandatory = $true)][string]$RepoRoot, - [string]$Registry = 'crates-io' + [Parameter(Mandatory = $true)][string]$RepoRoot ) if ($null -eq $script:CrateSemverVerdictCache) { $script:CrateSemverVerdictCache = @{} } @@ -452,8 +528,8 @@ function Get-CrateRequiredChangeType { return $script:CrateSemverVerdictCache[$CargoName] } - Write-Host "🔎 cargo semver-checks: analysing '$CargoName' against its last published version on '$Registry'..." -ForegroundColor Cyan - $result = Invoke-CrateSemverCheck -PackageName $CargoName -RepoRoot $RepoRoot -Registry $Registry + Write-Host "🔎 cargo semver-checks: analysing '$CargoName' against its previous version-bump commit..." -ForegroundColor Cyan + $result = Invoke-CrateSemverCheck -PackageName $CargoName -PackageFolder $Folder -RepoRoot $RepoRoot $script:CrateSemverVerdictCache[$CargoName] = $result return $result } @@ -495,137 +571,20 @@ function Get-WorkspacePackages { return $packages } -# Extracts the published version from `cargo info` output. Pure (no I/O) so it can -# be unit-tested against captured tool output. `cargo info` prints a line like -# "version: 1.2.3" (optionally with a trailing " (yanked)" note we ignore); this -# returns the version string, or $null when no such line is present. +# Runs `cargo semver-checks` for a single crate against its previous version-bump +# commit in git history. The baseline commit is located with +# Get-PreviousVersionBumpCommit and passed to cargo-semver-checks as +# `--baseline-rev `, which rebuilds the baseline rustdoc from the crate's +# source at that commit — so the comparison source is what the repository last +# *declared*, with no registry access. This works identically in OSS and +# enterprise/offline environments and treats a declared-but-unpublished version as +# the baseline (unlike the former registry lookup). # -# NOTE (documented limitation): the returned version is the last *published* one. -# If a prior version was committed but never published (e.g. an aborted release), -# the API delta introduced by that unpublished version is folded into the diff -# against the older published baseline and cannot be isolated. The workaround is a -# manual explicit-version pin. This is intentional — see docs/releasing.md. -function ConvertFrom-CargoInfoOutput { - [CmdletBinding()] - param( - [Parameter(Mandatory = $true)][AllowEmptyString()][string]$Output - ) - - # Strip ANSI colour escapes first: cargo forces colour in CI even when its - # output is piped, which would otherwise wrap the "version:" label in escape - # sequences (ESC[1mESC[92mversion:ESC[0m) and defeat the anchored match. - $clean = [regex]::Replace($Output, "`e\[[0-9;]*m", '') - - $m = [regex]::Match($clean, '(?im)^\s*version:\s*([^\s(]+)') - if ($m.Success) { return $m.Groups[1].Value.Trim() } - return $null -} - -# Pure predicate: does `cargo info` output specifically indicate the crate is -# absent from the registry (never published)? This is deliberately narrow — the -# same "no version" outcome can arise from a transient/config failure, which must -# NOT be treated as "unpublished" (that would silently skip the semver floor). -function Test-CargoInfoCrateMissing { - [CmdletBinding()] - param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Output) - - return [bool]( - $Output -match '(?i)could not find\s+`?[\w-]+`?\s+in registry' -or - $Output -match '(?i)not found in\s+(the\s+)?registry' -or - $Output -match '(?i)no\s+(released|published|matching)\s+versions?' - ) -} - -# Pure helper: when crates.io is source-replaced in .cargo/config (common on -# enterprise dev boxes), `cargo info` without an explicit --registry refuses and -# names the replacement, e.g. "crates-io is replaced with remote registry Foo". -# Returns that replacement registry name so the lookup can be retried against it, -# or $null when no such message is present. -function Get-CargoInfoReplacementRegistry { - [CmdletBinding()] - param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Output) - - $m = [regex]::Match($Output, '(?i)replaced with (?:remote )?registry\s+([^\s;,]+)') - if ($m.Success) { return $m.Groups[1].Value.Trim() } - return $null -} - -# Looks up the latest version of a crate as published on the given registry with -# `cargo info`, run in a throwaway temp directory OUTSIDE the workspace (run inside -# the workspace, cargo resolves the name to the local path package and reports the -# working-tree version instead of the published one). -# -# Registry defaults to 'crates-io', which uses cargo's DEFAULT source with no -# --registry flag: cargo reserves the name "crates-io" and rejects it as an -# explicit --registry value, so passing it fails in a vanilla environment. A -# non-default Registry value is passed through as --registry to target a -# private registry (e.g. an enterprise mirror). If crates.io is source-replaced -# locally, the refusal is detected and the lookup retried against the named -# replacement. -# -# Returns the version string; $null ONLY when cargo info specifically reports the -# crate is not in the registry (a brand-new, never-published crate — no baseline). -# Any other failure to determine the version throws rather than silently returning -# $null, so a transient/config error cannot be mistaken for "unpublished" and -# quietly skip the semver floor. -function Get-PublishedCrateVersion { - [CmdletBinding()] - param( - [Parameter(Mandatory = $true)][string]$CargoName, - [string]$Registry = 'crates-io' - ) - - $runCargoInfo = { - param([string[]]$InfoArgs) - $scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("semver-baseline-" + [System.Guid]::NewGuid().ToString('N')) - New-Item -ItemType Directory -Path $scratch -Force | Out-Null - try { - Push-Location $scratch - try { - $PSNativeCommandUseErrorActionPreference = $false - $out = & cargo @InfoArgs 2>&1 | Out-String - $code = $LASTEXITCODE - } finally { - Pop-Location - } - } finally { - Remove-Item $scratch -Recurse -Force -ErrorAction SilentlyContinue - } - return [pscustomobject]@{ Output = $out; ExitCode = $code } - } - - if ($Registry -and $Registry -ne 'crates-io') { - $result = & $runCargoInfo @('info', $CargoName, '--registry', $Registry, '--color', 'never') - } else { - # Default crates.io source — no --registry flag (see note above). - $result = & $runCargoInfo @('info', $CargoName, '--color', 'never') - - # Source-replaced dev box: retry against the named replacement registry. - if ($result.ExitCode -ne 0) { - $replacement = Get-CargoInfoReplacementRegistry -Output $result.Output - if ($replacement) { - $result = & $runCargoInfo @('info', $CargoName, '--registry', $replacement, '--color', 'never') - } - } - } - - $version = ConvertFrom-CargoInfoOutput -Output $result.Output - if ($version) { return $version } - - if (Test-CargoInfoCrateMissing -Output $result.Output) { - return $null - } - - throw "Could not determine the last published version of '$CargoName' on registry '$Registry' (cargo info exit $($result.ExitCode)). This usually means cargo info failed, the registry is unreachable, or the output format was unexpected — it is NOT treated as 'unpublished' to avoid silently skipping the semver check. Output:`n$($result.Output)" -} - -# Runs `cargo semver-checks` for a single crate against its last published version. -# The baseline version is discovered via `cargo info --registry ` (see -# Get-PublishedCrateVersion) and pinned with `--baseline-version`, so the comparison -# source is the registry the crate is actually published to rather than -# cargo-semver-checks' hard-coded crates.io default. Returns the minimum change type -# the current working-tree API requires: 'breaking', 'non-breaking', 'patch', or -# 'none' when the crate has never been published (no baseline to compare against). +# $BaseRef selects which bump counts as "previous"; the planner uses HEAD (the +# last committed version bump = the previous release). Returns the minimum change +# type the current working-tree API requires: 'breaking', 'non-breaking', 'patch', +# or 'none' when there is no prior version-bump commit to compare against (a +# brand-new crate). # # The current API is analysed from the working tree, so a coordinated release's # in-progress source edits (including a dependency whose public types this crate @@ -635,14 +594,15 @@ function Invoke-CrateSemverCheck { [CmdletBinding()] param( [Parameter(Mandatory = $true)][string]$PackageName, + [Parameter(Mandatory = $true)][string]$PackageFolder, [Parameter(Mandatory = $true)][string]$RepoRoot, - [string]$Registry = 'crates-io' + [string]$BaseRef = 'HEAD' ) - # Discover the baseline from the registry. No published version => brand-new - # crate: nothing to compare against, so it imposes no change-type floor. - $baselineVersion = Get-PublishedCrateVersion -CargoName $PackageName -Registry $Registry - if ([string]::IsNullOrWhiteSpace($baselineVersion)) { + # Locate the previous version-bump commit. No such commit => brand-new crate: + # nothing to compare against, so it imposes no change-type floor. + $bump = Get-PreviousVersionBumpCommit -RepoRoot $RepoRoot -BaseRef $BaseRef -PackageFolder $PackageFolder + if ($null -eq $bump) { return 'none' } @@ -651,7 +611,7 @@ function Invoke-CrateSemverCheck { # Manage the exit code manually; cargo-semver-checks exits non-zero when a # bump is required, which is expected and not an error for our purposes. $PSNativeCommandUseErrorActionPreference = $false - $output = & cargo semver-checks --package $PackageName --baseline-version $baselineVersion --all-features --color never 2>&1 | Out-String + $output = & cargo semver-checks --package $PackageName --baseline-rev $bump.Sha --all-features --color never 2>&1 | Out-String $exitCode = $LASTEXITCODE } finally { Pop-Location diff --git a/scripts/release-packages.ps1 b/scripts/release-packages.ps1 index 1a60e38c5..f873ca41e 100644 --- a/scripts/release-packages.ps1 +++ b/scripts/release-packages.ps1 @@ -56,12 +56,14 @@ Cargo's 0.x.y SemVer rules are honored throughout: for `0.x.y` packages a Breaking change becomes `0.(x+1).0`, NonBreaking and Patch both map to incrementing `y`. Every release in the plan is classified by running - `cargo semver-checks` against each crate's last published version on the - configured registry (crates.io by default; override with -Registry): a - dependent whose own public API is unaffected by the release - cascades as a `patch` (it still re-releases to pick up the new dependency - version), while a dependent whose API actually changes (e.g. because it - re-exports a changed type) cascades at the severity `cargo semver-checks` + `cargo semver-checks` against each crate's previous version-bump commit in + git history (the most recent commit that changed the crate's `[package] + version`, with the baseline rustdoc rebuilt from source via `--baseline-rev`, + so no registry access is required and it works in OSS and enterprise/offline + environments alike): a dependent whose own public API is unaffected by the + release cascades as a `patch` (it still re-releases to pick up the new + dependency version), while a dependent whose API actually changes (e.g. because + it re-exports a changed type) cascades at the severity `cargo semver-checks` reports. Dev-only dependents are skipped — they automatically pick up the new workspace version. cargo-semver-checks is a hard dependency (install the version pinned in constants.env); there is no heuristic fallback. @@ -152,14 +154,6 @@ never explicit version pins, so the pin-vs-cascade rejection cannot fire there. -.PARAMETER Registry - The registry whose last published version is used as the semver-checks - baseline for every crate in the plan. Defaults to `crates-io`. Point it at - a private registry (by the name configured in `.cargo/config.toml`) when the - crates are published somewhere other than crates.io — the baseline version - is discovered with `cargo info --registry `, so the - comparison uses the same source the crates are actually released to. - .EXAMPLE # Pin a specific version, e.g. release 'my-package' as 1.0.0. ./scripts/release-packages.ps1 -Packages 'my-package@1.0.0' @@ -194,10 +188,7 @@ param( [switch]$All, [Parameter(ParameterSetName = 'ByPackages')] - [switch]$Force, - - [Parameter()] - [string]$Registry = 'crates-io' + [switch]$Force ) # All helpers, configuration, and Invoke-ReleasePackagesMain live in the @@ -218,7 +209,7 @@ $mode = switch ($PSCmdlet.ParameterSetName) { # code, preserving the historical `exit 1`-on-error contract for command-line # and CI callers. try { - Invoke-ReleasePackagesMain -Mode $mode -Packages $Packages -Force:$Force -Registry $Registry | Out-Null + Invoke-ReleasePackagesMain -Mode $mode -Packages $Packages -Force:$Force | Out-Null } catch { Write-Error $_.Exception.Message exit 1 diff --git a/scripts/tests/Pester/unit/releasing/GitFs.Tests.ps1 b/scripts/tests/Pester/unit/releasing/GitFs.Tests.ps1 index 0c56f7e54..8b906b8ae 100644 --- a/scripts/tests/Pester/unit/releasing/GitFs.Tests.ps1 +++ b/scripts/tests/Pester/unit/releasing/GitFs.Tests.ps1 @@ -145,6 +145,66 @@ Describe 'Get-PackageLastReleaseBaseline' { } } +Describe 'Get-PreviousVersionBumpCommit' { + BeforeAll { + Reset-ReleaseScriptCaches + $script:Ws = New-SyntheticWorkspace -Preset Linear3 -Path (Join-Path $TestDrive 'prevbump') + # C0 = baseline commit: 'b' created at 0.2.0, 'c' created at 0.3.0. + $script:Sha_C0 = $script:Ws.GitSha('HEAD') + + # C1 = source-only edit to 'b' (no version change). + $script:Ws.ModifySource('b') + $script:Ws.AddCommit('source edit to b') + $script:Sha_C1 = $script:Ws.GitSha('HEAD') + + # C2 = the version bump under test: 'b' 0.2.0 -> 0.2.1. + $script:Ws.SetVersion('b', '0.2.1') + $script:Ws.AddCommit('bump b to 0.2.1') + $script:Sha_C2 = $script:Ws.GitSha('HEAD') + + # C3 = publish toggle only (touches Cargo.toml but NOT the version line). + $script:Ws.SetPublishFalse('b') + $script:Ws.AddCommit('set b publish=false') + $script:Sha_C3 = $script:Ws.GitSha('HEAD') + } + + It 'returns the most recent [package] version-bump commit and its version' { + $r = Get-PreviousVersionBumpCommit -RepoRoot $script:Ws.Path -BaseRef 'HEAD' -PackageFolder 'b' + $r.Sha | Should -Be $script:Sha_C2 + $r.Version | Should -Be '0.2.1' + } + + It 'ignores a commit that changed only publish, not the package version' { + # HEAD is the publish-toggle commit C3; the helper must skip it and + # return the earlier version-bump C2 rather than treating a publish edit + # as a version bump (this is where it differs from the -G line match in + # Get-PackageLastReleaseBaseline). + (Get-PreviousVersionBumpCommit -RepoRoot $script:Ws.Path -BaseRef 'HEAD' -PackageFolder 'b').Sha | + Should -Be $script:Sha_C2 + } + + It 'excludes bumps newer than BaseRef (a PR''s own bump is not its own baseline)' { + # Searching from C1 (before the 0.2.1 bump) yields the crate's creation + # commit at 0.2.0 — exactly the previously-declared version. This mirrors + # CI passing origin/main so THIS PR's bump is not the baseline. + $r = Get-PreviousVersionBumpCommit -RepoRoot $script:Ws.Path -BaseRef $script:Sha_C1 -PackageFolder 'b' + $r.Sha | Should -Be $script:Sha_C0 + $r.Version | Should -Be '0.2.0' + } + + It 'treats a crate''s creation commit as its version bump when never re-bumped' { + # 'c' is created at 0.3.0 in the baseline commit and never changed. + $r = Get-PreviousVersionBumpCommit -RepoRoot $script:Ws.Path -BaseRef 'HEAD' -PackageFolder 'c' + $r.Sha | Should -Be $script:Sha_C0 + $r.Version | Should -Be '0.3.0' + } + + It 'returns null for a package folder that has never existed' { + Get-PreviousVersionBumpCommit -RepoRoot $script:Ws.Path -BaseRef 'HEAD' -PackageFolder 'nonexistent' | + Should -BeNullOrEmpty + } +} + Describe 'Get-PackageLastReleaseBaseline (TOML publish-syntax variants)' { # The baseline detector inspects committed history for diff hunks whose diff --git a/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 b/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 index 263888456..3b4673a1d 100644 --- a/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 +++ b/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 @@ -320,82 +320,6 @@ Describe 'ConvertFrom-SemverChecksOutput' { } } -Describe 'ConvertFrom-CargoInfoOutput' { - It 'extracts the version from a cargo info block' { - $out = "bytesbuf_io`n An I/O adapter`nversion: 0.6.0`nlicense: MIT" - ConvertFrom-CargoInfoOutput -Output $out | Should -Be '0.6.0' - } - - It 'ignores a trailing yanked/annotation note after the version' { - $out = "version: 1.2.3 (yanked)" - ConvertFrom-CargoInfoOutput -Output $out | Should -Be '1.2.3' - } - - It 'parses ANSI-colorized output (cargo forces colour in CI)' { - # cargo wraps the label in SGR escapes even when piped in CI: - # ESC[1mESC[92mversion:ESC[0m 0.6.0 - $esc = [char]0x1b - $out = "${esc}[1m${esc}[92mversion:${esc}[0m 0.6.0" - ConvertFrom-CargoInfoOutput -Output $out | Should -Be '0.6.0' - } - - It 'returns $null when the crate is not published (no version line)' { - $out = "error: crate oxidizer_nope not found in registry" - ConvertFrom-CargoInfoOutput -Output $out | Should -BeNullOrEmpty - } - - It 'returns $null for empty output' { - ConvertFrom-CargoInfoOutput -Output '' | Should -BeNullOrEmpty - } - - It 'reports the last PUBLISHED version, not an unpublished intermediate (documented limitation)' { - # Baseline discovery reads whatever `cargo info` returns from the - # registry: the last *published* version. If a breaking change was - # committed as 4.0.0 but never published (an aborted release), the - # registry still reports 3.3.3, so semver-checks diffs the working tree - # against 3.3.3 and the delta from the unpublished 4.0.0 cannot be - # isolated. This is intentional; the workaround is a manual explicit - # version pin. See docs/releasing.md. - $registryReportsLastPublished = "version: 3.3.3" - ConvertFrom-CargoInfoOutput -Output $registryReportsLastPublished | Should -Be '3.3.3' - } -} - -Describe 'Test-CargoInfoCrateMissing' { - It 'matches "could not find in registry"' { - Test-CargoInfoCrateMissing -Output 'error: could not find `foo` in registry `crates-io`' | Should -BeTrue - } - It 'matches "not found in registry"' { - Test-CargoInfoCrateMissing -Output 'error: crate foo not found in the registry' | Should -BeTrue - } - It 'matches "no matching versions"' { - Test-CargoInfoCrateMissing -Output 'error: no matching versions for `foo`' | Should -BeTrue - } - It 'does NOT match a transient/config failure (so it will throw, not skip)' { - # A network timeout or reserved-name rejection must not be mistaken for - # an unpublished crate — that would silently skip the semver floor. - Test-CargoInfoCrateMissing -Output 'error: failed to query registry: operation timed out' | Should -BeFalse - Test-CargoInfoCrateMissing -Output 'error: crates-io is replaced with remote registry Foo' | Should -BeFalse - } - It 'does NOT match empty output' { - Test-CargoInfoCrateMissing -Output '' | Should -BeFalse - } -} - -Describe 'Get-CargoInfoReplacementRegistry' { - It 'extracts the replacement registry name from a source-replacement error' { - Get-CargoInfoReplacementRegistry -Output 'error: crates-io is replaced with remote registry OxidizerDependencies;' | - Should -Be 'OxidizerDependencies' - } - It 'handles the "remote registry" phrasing without a trailing punctuation' { - Get-CargoInfoReplacementRegistry -Output 'crates-io is replaced with remote registry MyMirror' | - Should -Be 'MyMirror' - } - It 'returns $null when there is no replacement message' { - Get-CargoInfoReplacementRegistry -Output 'version: 1.2.3' | Should -BeNullOrEmpty - } -} - Describe 'Get-StrongerChangeType' { It 'returns the higher-ranked change type' { Get-StrongerChangeType 'patch' 'breaking' | Should -Be 'breaking' From 776575c3d8adc585f417c2b9acae2b3bba8eb82a Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Mon, 13 Jul 2026 07:24:46 +0100 Subject: [PATCH 13/17] feat(ci): show resolved baseline commit per crate in semver report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Baseline commit" column to the semver report table showing the exact commit each crate was compared against (the --baseline-rev SHA). The SHA is rendered as a short hash linking to the commit when the repo can be derived from the Actions run URL, and as "—" for new crates and failed baseline lookups (no commit). The Baseline column now carries the version only, so the commit is no longer buried in parentheses. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/ci/semver-report.ps1 | 38 ++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/scripts/ci/semver-report.ps1 b/scripts/ci/semver-report.ps1 index 286c3e9d8..726ace229 100644 --- a/scripts/ci/semver-report.ps1 +++ b/scripts/ci/semver-report.ps1 @@ -29,7 +29,7 @@ - a summary status line (🛑 when at least one crate is under-incremented, ⚠️ when the only problem is a baseline that could not be determined, ✅ when every publishing crate is sufficiently incremented), - - a table: Crate | Baseline | This PR | Minimum required | Status, + - a table: Crate | Baseline | Baseline commit | This PR | Minimum required | Status, - collapsible per-crate detail for under-incremented crates and for crates whose baseline could not be determined, and - a link to the triggering Actions run. @@ -127,6 +127,7 @@ try { $rows.Add([pscustomobject]@{ Crate = $cargoName Baseline = '⚠️ unknown' + BaselineSha = '' OnDisk = $onDisk Required = '?' Sufficient = $false @@ -144,6 +145,7 @@ try { $rows.Add([pscustomobject]@{ Crate = $cargoName Baseline = 'new crate' + BaselineSha = '' OnDisk = $onDisk Required = $onDisk Sufficient = $true @@ -170,7 +172,8 @@ try { Write-Host "cargo semver-checks: $cargoName — analysis FAILED: $($_.Exception.Message)" $rows.Add([pscustomobject]@{ Crate = $cargoName - Baseline = "⚠️ $baselineVersion ($shortSha)" + Baseline = "⚠️ $baselineVersion" + BaselineSha = $baselineSha OnDisk = $onDisk Required = '?' Sufficient = $false @@ -200,7 +203,8 @@ try { $rows.Add([pscustomobject]@{ Crate = $cargoName - Baseline = "$baselineVersion ($shortSha)" + Baseline = $baselineVersion + BaselineSha = $baselineSha OnDisk = $onDisk Required = $required Sufficient = $sufficient @@ -243,8 +247,16 @@ if ($hasReal) { [void]$sb.AppendLine("${bt}cargo semver-checks${bt} compared the **$($rows.Count)** crate(s) this PR publishes against their previous version-bump commit in git history. Every version increment is sufficient for the detected API changes.") } [void]$sb.AppendLine() -[void]$sb.AppendLine('| Crate | Baseline | This PR | Minimum required | Status |') -[void]$sb.AppendLine('|---|---|---|---|---|') +# Derive a commit base URL from the Actions run URL (…///actions/runs/) +# so each baseline commit SHA links to the exact commit semver-checks ran against. +# Falls back to a bare code-formatted short SHA when the URL can't be parsed. +$commitBaseUrl = '' +if (-not [string]::IsNullOrEmpty($RunUrl)) { + $m = [regex]::Match($RunUrl, '^(?https?://[^\s]+?)/actions/runs/') + if ($m.Success) { $commitBaseUrl = "$($m.Groups['base'].Value)/commit" } +} +[void]$sb.AppendLine('| Crate | Baseline | Baseline commit | This PR | Minimum required | Status |') +[void]$sb.AppendLine('|---|---|---|---|---|---|') foreach ($r in $rows) { if ($r.ChangeType -eq 'unknown') { $status = '⚠️ baseline unknown — not verified' @@ -256,7 +268,21 @@ foreach ($r in $rows) { $status = "🛑 increase to at least ${bt}$($r.Required)${bt}" $req = "**$($r.Required)**" } - [void]$sb.AppendLine("| ${bt}$($r.Crate)${bt} | $($r.Baseline) | $($r.OnDisk) | $req | $status |") + + # The resolved commit semver-checks compared against (--baseline-rev), shown + # per crate. Empty for new crates and failed baseline lookups (no commit). + if ([string]::IsNullOrEmpty($r.BaselineSha)) { + $commitCell = '—' + } else { + $short = if ($r.BaselineSha.Length -ge 7) { $r.BaselineSha.Substring(0, 7) } else { $r.BaselineSha } + if ($commitBaseUrl) { + $commitCell = "[${bt}$short${bt}]($commitBaseUrl/$($r.BaselineSha))" + } else { + $commitCell = "${bt}$short${bt}" + } + } + + [void]$sb.AppendLine("| ${bt}$($r.Crate)${bt} | $($r.Baseline) | $commitCell | $($r.OnDisk) | $req | $status |") } [void]$sb.AppendLine() From 6e77530652d20ca5a3ad3605e2c78bc40892280b Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:27:32 +0100 Subject: [PATCH 14/17] fix(ci): compare on-disk version against required minimum in semver report Address review feedback on PR #554: - semver-report.ps1: a crate's sufficiency was derived from the cargo-semver-checks verdict alone ('patch'/'none' => sufficient), which ignored the on-disk bump. A correctly-bumped crate (e.g. baseline 1.0.0 -> on-disk 1.1.0 with a 'non-breaking' verdict) was wrongly flagged as under-incremented. Now compute the minimum acceptable version (baseline incremented by the required change type) and mark the crate sufficient when its on-disk version is >= that minimum, via Compare-SemanticVersions. 'none' (no baseline) stays unconstrained. Verified across 1.x and 0.x semver rules. - main.yml: the Semver Report step comment claimed the step emits status = pass|fail, but the script documents and emits pass|warn|fail (warn = a baseline could not be determined). Comment corrected to match the actual output contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/main.yml | 7 +++++-- scripts/ci/semver-report.ps1 | 22 +++++++++++++--------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1c713caeb..daf93c1e8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -231,8 +231,11 @@ jobs: # git history (--baseline-rev, no registry), and render a Markdown report # (scripts/ci/semver-report.ps1 — reuses the release library's version-diff # / next-version helpers). Sets outputs: - # publishing = true|false (any crate version bumped) - # status = pass|fail (any crate under-incremented for its API changes) + # publishing = true|false (any crate version bumped) + # status = pass|warn|fail (fail = a crate is under-incremented for + # its API changes; warn = a baseline could + # not be determined so a check is + # incomplete; pass = all sufficient) # Kept non-failing so a git/tooling hiccup never blocks the PR. - name: Semver Report id: report diff --git a/scripts/ci/semver-report.ps1 b/scripts/ci/semver-report.ps1 index 726ace229..984a1a5dd 100644 --- a/scripts/ci/semver-report.ps1 +++ b/scripts/ci/semver-report.ps1 @@ -183,16 +183,20 @@ try { continue } - # A 'breaking'/'non-breaking' verdict means the detected API changes need - # a stronger increment than the on-disk version gives over the baseline; - # the minimum acceptable version is the baseline incremented by that - # change type. 'patch' means the on-disk version already covers the - # changes; 'none' means the crate is new (no baseline to violate). - $sufficient = $changeType -in @('patch', 'none') - if ($sufficient) { - $required = $onDisk + # Determine the minimum acceptable version and whether the on-disk version + # meets it. 'none' means there is no baseline (new crate) — no constraint. + # Otherwise the minimum is the baseline incremented by the required change + # type, and the crate is sufficient when its on-disk version is >= that + # minimum. Comparing on-disk against the minimum (rather than trusting the + # verdict alone) means a correctly-bumped crate — e.g. baseline 1.0.0 -> + # on-disk 1.1.0 with a 'non-breaking' verdict (min 1.1.0) — is reported as + # sufficient instead of being flagged for a bump it already has. + if ($changeType -eq 'none') { + $required = $onDisk + $sufficient = $true } else { - $required = Get-NextVersion -currentVersion $baselineVersion -ChangeType $changeType + $required = Get-NextVersion -currentVersion $baselineVersion -ChangeType $changeType + $sufficient = (Compare-SemanticVersions -version1 $onDisk -version2 $required) -ge 0 } # Extract just the failure blocks for the collapsible detail. From 3bab117fa320690975df424dc97110fd4c13af68 Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:37:26 +0100 Subject: [PATCH 15/17] refactor(release): drop unreachable registry-absence branch in semver parser Address review feedback on PR #554: with the git-history baseline (--baseline-rev), cargo-semver-checks always builds the baseline from source, so it never emits registry "not found / no released versions" messages. Those branches in ConvertFrom-SemverChecksOutput were dead code and the accompanying comment described a registry baseline that no longer applies. Remove the branches, and document that the 'none' (no baseline) case is decided by the caller before invoking the parser when there is no previous version-bump commit. Update the unit tests accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/lib/releasing.ps1 | 33 ++++++------------- .../unit/releasing/PureFunctions.Tests.ps1 | 20 ++++------- 2 files changed, 16 insertions(+), 37 deletions(-) diff --git a/scripts/lib/releasing.ps1 b/scripts/lib/releasing.ps1 index 94ca37e96..00eef2549 100644 --- a/scripts/lib/releasing.ps1 +++ b/scripts/lib/releasing.ps1 @@ -621,11 +621,16 @@ function Invoke-CrateSemverCheck { } # Parses `cargo semver-checks` combined output into a change type. Pure (no I/O) -# so it can be unit-tested against captured tool output. Mapping: +# so it can be unit-tested against captured tool output. With the git-history +# baseline (`--baseline-rev`) cargo-semver-checks always builds the baseline from +# source, so the only outcomes are a semver verdict or a genuine tool/build +# failure. Mapping: # * "N major and M minor checks failed" -> major>0 breaking; minor>0 non-breaking -# * "no semver update required" -> patch (compatible) -# * baseline not found on the registry (never published) -> none -# * anything else (tool/network/build failure) -> throw (no silent fallback) +# * "no semver update required" -> patch (compatible) +# * anything else (tool/build failure) -> throw (no silent fallback) +# The 'none' (no baseline) case is decided by the CALLER before this function is +# invoked — when there is no previous version-bump commit to compare against — +# not from tool output here. function ConvertFrom-SemverChecksOutput { [CmdletBinding()] param( @@ -645,25 +650,7 @@ function ConvertFrom-SemverChecksOutput { return 'patch' } - # No baseline: the crate (or a specific version) is not on the registry. This - # is the expected state for a brand-new, never-published crate — there is - # nothing to compare against, so it imposes no change-type floor. - # - # Match ONLY messages that specifically indicate the crate/version is absent - # from the registry. The generic wrapper line "failed to retrieve crate data - # from registry" is deliberately NOT matched here: it also fires on transient - # network/registry outages, and treating those as 'none' would silently skip - # classification (violating the no-fallback contract). When the crate is - # genuinely unpublished the specific cause line ("... not found in registry" - # / "no released versions") is present in the output and matches below; a - # transient failure has no such line and falls through to the throw. - if ($Output -match '(?i)not\s+found\s+in\s+(the\s+)?registry' -or - $Output -match '(?i)no\s+(released|published)\s+versions?' -or - $Output -match '(?i)could\s+not\s+find\s+.*\bin\s+registry') { - return 'none' - } - - throw "cargo semver-checks did not produce a parseable result for '$PackageName' (exit $ExitCode). This usually means the tool is missing, the network/registry is unreachable, or the crate failed to build. Output:`n$Output" + throw "cargo semver-checks did not produce a parseable result for '$PackageName' (exit $ExitCode). This usually means the tool is missing or the crate/baseline failed to build. Output:`n$Output" } # BFS over the reverse dependency graph. Returns the folder names of all published diff --git a/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 b/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 index 3b4673a1d..888814ea3 100644 --- a/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 +++ b/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 @@ -296,20 +296,12 @@ Describe 'ConvertFrom-SemverChecksOutput' { ConvertFrom-SemverChecksOutput -Output $out | Should -Be 'patch' } - It 'maps a missing registry baseline to none (new/unpublished crate)' { - $out = "error: failed to retrieve crate data from registry`n`nCaused by:`n crate foo version 999.999.999 not found in registry" - ConvertFrom-SemverChecksOutput -Output $out | Should -Be 'none' - } - - It 'maps "no released versions" to none' { - ConvertFrom-SemverChecksOutput -Output 'error: foo has no released versions on the registry' | Should -Be 'none' - } - - It 'throws (does NOT return none) on a transient registry-retrieval failure with no "not found" cause' { - # A network/registry outage emits the generic wrapper line but no - # crate-absent cause. Treating it as an unpublished crate ('none') would - # silently skip classification, violating the no-fallback contract. - $out = "error: failed to retrieve crate data from registry`n`nCaused by:`n error sending request for url (https://index.crates.io/...): operation timed out" + It 'throws on a tool/build failure (no silent fallback)' { + # Under --baseline-rev the baseline is built from source, so a failure to + # produce a verdict (e.g. the baseline crate failed to build) must surface + # as an error rather than being silently classified. The caller (CI report) + # turns this into an explicit "baseline unknown" row. + $out = " Building foo v1.2.3 (baseline)`nerror: could not compile ``foo`` (lib)" { ConvertFrom-SemverChecksOutput -Output $out -PackageName 'foo' } | Should -Throw -ExpectedMessage "*did not produce a parseable result for 'foo'*" } From e65a6322f5578a640f267ba86951a85c7c27af3a Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:34:19 +0100 Subject: [PATCH 16/17] ci: use unambiguous ${env:...} form in semver fetch refspec Address review feedback on PR #554: the Semver Report step's git fetch refspec escaped the ':' separator with a backtick and used unbraced $env:BASE_REF, which is fragile and easy to misread. Switch to the explicit ${env:BASE_REF} form (and brace the other env references in the step) so the refspec is unambiguous with no backtick escaping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index daf93c1e8..056a410ad 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -246,8 +246,8 @@ jobs: RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | $ErrorActionPreference = 'Stop' - git fetch --no-tags origin "+refs/heads/$env:BASE_REF`:refs/remotes/origin/$env:BASE_REF" - ./scripts/ci/semver-report.ps1 -BaseRef "origin/$env:BASE_REF" -ReportPath semver-comment.md -RunUrl "$env:RUN_URL" + git fetch --no-tags origin "+refs/heads/${env:BASE_REF}:refs/remotes/origin/${env:BASE_REF}" + ./scripts/ci/semver-report.ps1 -BaseRef "origin/${env:BASE_REF}" -ReportPath semver-comment.md -RunUrl "${env:RUN_URL}" # Post a NEW comment on every push (not a sticky update) so each run's # result is visible in the PR timeline. Only fires when the PR actually From 318f7773a8b770277eb1785d9bb93c8a5b038d53 Mon Sep 17 00:00:00 2001 From: Vaiz <4908982+Vaiz@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:04:18 +0100 Subject: [PATCH 17/17] fix(release): surface baseline lookup failures instead of silent 'none' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on PR #554: Get-PreviousVersionBumpCommit ran `git log` with -AllowFailure, so a genuine failure (an unresolvable BaseRef that was never fetched or is a typo, repo corruption) returned $null and was silently treated as "no previous version-bump commit" => 'none' (no change-type floor). That could misclassify a release and make the CI semver report incorrectly pass when the baseline could not actually be determined. Now guard with Test-GitRef (throws a clear error when the ref cannot be resolved) and run `git log` without -AllowFailure so any other git error also propagates. A valid ref that simply matches no version-bump commit still exits 0 with empty output and correctly yields $null (brand-new crate). The CI report already catches the throw and records an unknown (⚠️ warn) row; the release planner treats it as a hard error. Adds a unit test for the unresolvable-ref case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/lib/releasing.ps1 | 22 ++++++++++++++++++- .../Pester/unit/releasing/GitFs.Tests.ps1 | 7 ++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/scripts/lib/releasing.ps1 b/scripts/lib/releasing.ps1 index 00eef2549..c650ee59e 100644 --- a/scripts/lib/releasing.ps1 +++ b/scripts/lib/releasing.ps1 @@ -373,6 +373,15 @@ function Get-PackageVersionFromRef { # or $null when no such commit exists (a brand-new crate introduced in the range # above $BaseRef, or a crate with no committed history at $BaseRef). # +# Genuine lookup FAILURES are NOT swallowed: if $BaseRef cannot be resolved (e.g. +# it was never fetched, or is a typo) or git otherwise fails, this THROWS rather +# than returning $null. A silent $null would become 'none' (no change-type floor) +# downstream and make the CI report incorrectly pass when the baseline could not +# actually be determined. The CI report catches the throw and records an +# ⚠️ unknown/warn row; the release planner treats it as a hard error. Only a +# SUCCESSFUL git log that finds no version-bump commit (a valid ref, but the +# crate's [package] version never changed in reachable history) yields $null. +# # This is the SOURCE-LEVEL semver baseline: the version the repository previously # *declared*, regardless of whether it was ever published to a registry. It works # identically in OSS and enterprise/offline environments because it never touches @@ -413,7 +422,18 @@ function Get-PreviousVersionBumpCommit { } $relPath = "crates/$PackageFolder/Cargo.toml" - $commits = Invoke-Git -Arguments @('log', '--format=%H', $BaseRef, '--', $relPath) -RepoRoot $RepoRoot -AllowFailure + + # Surface a genuine failure rather than silently returning "no baseline". + # An unresolvable ref (not fetched / typo) must not be mistaken for a + # brand-new crate — that would drop the change-type floor and let an + # under-incremented release pass. Test-GitRef never throws; a false result + # means the ref is bad. The git log itself runs WITHOUT -AllowFailure so any + # other git error also propagates. A valid ref with no matching commit exits + # 0 with empty output and correctly yields $null (new crate). + if (-not (Test-GitRef -Ref $BaseRef -RepoRoot $RepoRoot)) { + throw "Cannot locate the previous version-bump commit for '$PackageFolder': base ref '$BaseRef' could not be resolved in the repository. Ensure it is fetched (CI checks out with fetch-depth: 0) and spelled correctly." + } + $commits = Invoke-Git -Arguments @('log', '--format=%H', $BaseRef, '--', $relPath) -RepoRoot $RepoRoot $result = $null if ($null -ne $commits) { diff --git a/scripts/tests/Pester/unit/releasing/GitFs.Tests.ps1 b/scripts/tests/Pester/unit/releasing/GitFs.Tests.ps1 index 8b906b8ae..aa6ef0b01 100644 --- a/scripts/tests/Pester/unit/releasing/GitFs.Tests.ps1 +++ b/scripts/tests/Pester/unit/releasing/GitFs.Tests.ps1 @@ -203,6 +203,13 @@ Describe 'Get-PreviousVersionBumpCommit' { Get-PreviousVersionBumpCommit -RepoRoot $script:Ws.Path -BaseRef 'HEAD' -PackageFolder 'nonexistent' | Should -BeNullOrEmpty } + + It 'throws when the base ref cannot be resolved (not fetched / typo)' { + # A genuine lookup failure must NOT be silently treated as "no baseline" + # (which would drop the change-type floor and let CI wrongly pass). + { Get-PreviousVersionBumpCommit -RepoRoot $script:Ws.Path -BaseRef 'origin/does-not-exist' -PackageFolder 'b' } | + Should -Throw -ExpectedMessage "*could not be resolved*" + } } Describe 'Get-PackageLastReleaseBaseline (TOML publish-syntax variants)' {