diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5a10505bb..056a410ad 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -196,6 +196,17 @@ jobs: - name: Check Spelling run: just spellcheck + # 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 previous version. semver: needs: [delta] if: github.event_name == 'pull_request' && needs.delta.outputs.skip != 'true' @@ -215,41 +226,37 @@ jobs: rust-toolchain: RUST_LATEST cargo-tools: CARGO_SEMVER_CHECKS_VERSION - # execute - - name: Semver Compatibility - id: semver + # Compute the publishing set (version-bumped published crates), run + # 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|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 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: | - 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 - - # report - - name: Prepare Semver Comment - if: steps.semver.outcome == 'failure' - run: | - echo "## ⚠️ Breaking Changes Detected" > 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 "" >> 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 - uses: marocchino/sticky-pull-request-comment@v3.0.4 - with: - header: semver-check - path: semver-comment.txt - - - name: Remove Semver Comment on Success - if: 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 + $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}" + + # 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/docs/releasing.md b/docs/releasing.md index 7187a4974..bae9ccfbd 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -203,31 +203,91 @@ 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 **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 +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` | +| 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 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 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 change type the API analysis requires. ### 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/ci/semver-report.ps1 b/scripts/ci/semver-report.ps1 new file mode 100644 index 000000000..984a1a5dd --- /dev/null +++ b/scripts/ci/semver-report.ps1 @@ -0,0 +1,323 @@ +# 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 version increment + 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. 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. + + 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 | 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. + + Two GitHub Actions step outputs are written to -GitHubOutput: + publishing = 'true' | 'false' + 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. + +.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. + +.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). ------- +# 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 } + +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, 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] + +Push-Location $RepoRoot +try { + foreach ($folder in $changedFolders) { + $pkg = $byFolder[$folder] + if ($null -eq $pkg) { continue } + + $cargoName = $pkg.Name + $onDisk = Get-CurrentVersion -cargoTomlPath (Join-Path $RepoRoot "crates/$folder/Cargo.toml") + + # 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 { + $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]@{ + Crate = $cargoName + Baseline = '⚠️ unknown' + BaselineSha = '' + OnDisk = $onDisk + Required = '?' + Sufficient = $false + ChangeType = 'unknown' + 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 ($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 = 'new crate' + BaselineSha = '' + OnDisk = $onDisk + Required = $onDisk + Sufficient = $true + ChangeType = 'none' + Detail = '' + }) + continue + } + + $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-rev $baselineSha --all-features --color never 2>&1 | Out-String + + # 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" + BaselineSha = $baselineSha + 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 + } + + # 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 + $sufficient = (Compare-SemanticVersions -version1 $onDisk -version2 $required) -ge 0 + } + + # 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 = $baselineVersion + BaselineSha = $baselineSha + OnDisk = $onDisk + Required = $required + Sufficient = $sufficient + ChangeType = $changeType + Detail = $detail + }) + } +} finally { + Pop-Location +} + +# --- 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' }) +$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 ($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 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 (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 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 previous version-bump commit in git history. Every version increment is sufficient for the detected API changes.") +} +[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' + $req = '—' + } elseif ($r.Sufficient) { + $status = '✅ ok' + $req = $r.Required + } else { + $status = "🛑 increase to at least ${bt}$($r.Required)${bt}" + $req = "**$($r.Required)**" + } + + # 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() + +$fence = "$bt$bt$bt" +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' } + [void]$sb.AppendLine("
$icon $($r.Crate) — $summary") + [void]$sb.AppendLine() + [void]$sb.AppendLine($fence) + [void]$sb.AppendLine($r.Detail) + [void]$sb.AppendLine($fence) + [void]$sb.AppendLine('
') + [void]$sb.AppendLine() + } + 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.') +} + +if (-not [string]::IsNullOrEmpty($RunUrl)) { + [void]$sb.AppendLine() + [void]$sb.AppendLine("[View the check run]($RunUrl)") +} + +Set-Content -Path $ReportPath -Value $sb.ToString() -Encoding utf8 +Write-Host "Report written to $ReportPath" +$status = if ($hasReal) { 'fail' } elseif ($hasUnknown) { 'warn' } else { 'pass' } +Write-Outputs -publishing 'true' -status $status diff --git a/scripts/lib/release-flow.ps1 b/scripts/lib/release-flow.ps1 index 62f077e91..54a46ed59 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,62 @@ 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 + ) + + # 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 + + 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 +366,24 @@ 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 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. 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 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. + [Parameter(Mandatory = $false)][scriptblock]$GetRequiredChangeType = { param($folder, $cargoName) 'none' }, [Parameter(Mandatory = $false)][switch]$Force ) @@ -329,8 +398,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 +457,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 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. + $dependentChangeType = Get-StrongerChangeType 'patch' (& $GetRequiredChangeType $depPkg.Folder $depPkg.Name) $depBreakingForReason = Test-IsBreakingChange -oldVersion $depPkg.Version -ChangeType $dependentChangeType $cascadeReason = [pscustomobject]@{ @@ -426,45 +497,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 +521,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 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. + 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 +1510,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 +1578,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 +1661,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 } } @@ -1953,8 +2021,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( @@ -1973,19 +2044,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 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 previous version-bump commit." } $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 @@ -1993,31 +2068,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 = @() } @@ -2066,14 +2132,27 @@ 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' } - try { - $resolvedHash = Invoke-PlanReview -RepoRoot $repoRoot.Path ` - -ParsedTokens $parsedTokens -WorkspaceBaseline $workspaceBaseline ` - -ModifiedSnapshot $modifiedSnapshot -Mode $planReviewMode -Force:$Force - } catch { - Write-Error $_.Exception.Message - Exit 1 - } + + # 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 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 + + # 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 @@ -2094,8 +2173,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/lib/releasing.ps1 b/scripts/lib/releasing.ps1 index d340a1b58..c650ee59e 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) @@ -347,6 +365,100 @@ 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). +# +# 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 +# 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" + + # 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) { + 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 @@ -368,6 +480,7 @@ $script:CachedWorkspaceMetadata = $null $script:PackageLastReleaseBaselineCache = $null $script:PackageCommittedChangesCache = $null $script:PackageVersionAtRefCache = $null +$script:PreviousVersionBumpCommitCache = $null function Get-WorkspaceMetadata { param([string]$repoRoot) @@ -410,6 +523,35 @@ 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 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 + ) + + 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 previous version-bump commit..." -ForegroundColor Cyan + $result = Invoke-CrateSemverCheck -PackageName $CargoName -PackageFolder $Folder -RepoRoot $RepoRoot + $script:CrateSemverVerdictCache[$CargoName] = $result + return $result } # Returns information about all workspace packages as an array of objects with: @@ -417,8 +559,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 +579,98 @@ 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 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). +# +# $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 +# 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]$PackageFolder, + [Parameter(Mandatory = $true)][string]$RepoRoot, + [string]$BaseRef = 'HEAD' ) - if ($null -eq $dependent.AllowedExternalTypes) { - return $true + # 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' } - $normalizedTarget = $targetPackageName.Replace('-', '_') - foreach ($entry in $dependent.AllowedExternalTypes) { - $root = ($entry -split '::', 2)[0] - if ($root -eq $normalizedTarget) { - 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 --baseline-rev $bump.Sha --all-features --color never 2>&1 | Out-String + $exitCode = $LASTEXITCODE + } finally { + Pop-Location + } + + 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. 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) +# * 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( + [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' } - return $false + 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/release-packages.ps1 b/scripts/release-packages.ps1 index 173155daa..f873ca41e 100644 --- a/scripts/release-packages.ps1 +++ b/scripts/release-packages.ps1 @@ -55,20 +55,26 @@ 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 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. + + 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 @@ -197,4 +203,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/_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/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). 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..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 @@ -47,6 +53,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..2d8b1b2ba 100644 --- a/scripts/tests/Pester/unit/release-packages/ResolveReleaseSet.Tests.ps1 +++ b/scripts/tests/Pester/unit/release-packages/ResolveReleaseSet.Tests.ps1 @@ -14,20 +14,33 @@ 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. In production the classifier is $script:DefaultSemverClassifier, which + # calls Get-CrateRequiredChangeType (a cached cargo-semver-checks wrapper). + 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 +227,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 +256,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 +295,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 +325,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 +359,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 +384,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 +400,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 +428,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 +445,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 +478,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/GitFs.Tests.ps1 b/scripts/tests/Pester/unit/releasing/GitFs.Tests.ps1 index 5d3c0793a..aa6ef0b01 100644 --- a/scripts/tests/Pester/unit/releasing/GitFs.Tests.ps1 +++ b/scripts/tests/Pester/unit/releasing/GitFs.Tests.ps1 @@ -145,6 +145,73 @@ 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 + } + + 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)' { # The baseline detector inspects committed history for diff hunks whose @@ -427,6 +494,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' { diff --git a/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 b/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 index 444776ade..888814ea3 100644 --- a/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 +++ b/scripts/tests/Pester/unit/releasing/PureFunctions.Tests.ps1 @@ -275,31 +275,64 @@ 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 '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'*" + } + + 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' } }