feat(scripts): detect modified-but-unreleased dependencies during release#436
Open
sandersaares wants to merge 106 commits into
Open
feat(scripts): detect modified-but-unreleased dependencies during release#436sandersaares wants to merge 106 commits into
sandersaares wants to merge 106 commits into
Conversation
…lease When releasing a crate, an author may have also modified one of its upstream workspace dependencies but forgotten to release the dependency too. Locally everything builds via path-references, but once published the released crate resolves to the last released version on crates.io, missing the new changes. This adds two layers of automation: 1. Interactive layer (release-crate.ps1): After the existing downstream cascade finishes, scan the release set for transitive workspace dependencies that have file changes vs the PR base ref but are not themselves being released. Prompt the author (y/N + bump kind) for each finding so material changes get an extra release queued, while immaterial changes (formatting, doc tweaks) can be declined. 2. CI layer (scripts/check-unreleased-dependencies.ps1 + .github/workflows/main.yml release-deps job): Runs the same analysis non-interactively and posts a sticky PR comment listing any findings so reviewers can sanity-check materiality decisions. Shared logic lives in the new scripts/lib/releasing.ps1 library, dot-sourced by both entry-point scripts. Workspace dependency types kind=normal and kind=build are tracked; kind=dev is excluded (cannot affect downstream consumers via crates.io). Cascade re-bump is idempotent: re-cascading into a crate already at a sufficient version appends a maintenance bullet to its existing changelog section instead of double-bumping. The .delta.toml Cargo.toml trip-wire is documented as a dependency of the release-deps CI gate so any version bump touches Cargo.toml -> skip=false. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Migrate the three remaining Invoke-GitCommand callsites in release-crate.ps1 (tag --list, git log, remote get-url) to the array-argument Invoke-Git wrapper in releasing.ps1, then delete the legacy wrapper. Removes the last use of Invoke-Expression-based git invocation in the release tooling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #436 +/- ##
=======================================
Coverage 100.0% 100.0%
=======================================
Files 335 335
Lines 25586 25586
=======================================
Hits 25586 25586 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
ralfbiedert
approved these changes
May 21, 2026
Collaborator
|
Whops, I missed the |
The release-deps job previously depended on the delta job and used `delta.outputs.skip != 'true'` as a compute optimization to skip the analysis when no crate was affected. The optimization was structurally fragile - it relied on the implicit invariant that Cargo.toml and scripts/* remain in .delta.toml's trip_wire_patterns - and only saved a few seconds when there are no findings. Drop both the needs: [delta] dependency and the delta.skip gate. The check-unreleased-dependencies.ps1 script already exits fast (no findings -> no markdown, no comment) when there is nothing to report, so always running it is safe and simple. Also remove the corresponding NOTE in .delta.toml since that dependency no longer exists. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR strengthens the workspace crate release tooling by detecting “modified-but-unreleased” upstream workspace dependencies that could be missed when publishing to crates.io, and surfaces the same signal in CI via a new informational job.
Changes:
- Extracts shared release/dependency-graph and git helper logic into a dot-sourced PowerShell library (
scripts/lib/releasing.ps1). - Extends
scripts/release-crate.ps1to (optionally) scan for modified-but-unreleased upstream workspace deps after the existing downstream cascade, with interactive prompting to release them. - Adds a CI-only analyzer script and a new workflow job that posts/removes a sticky PR comment when such unreleased upstream dependency changes are detected.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| scripts/release-crate.ps1 | Adds base-ref/non-interactive options, refactors cascade flow, and performs a post-release upstream dependency scan. |
| scripts/lib/releasing.ps1 | New shared library providing safe git invocation, SemVer helpers, workspace metadata, and unreleased-dependency analysis. |
| scripts/check-unreleased-dependencies.ps1 | New CI companion script that emits a markdown report + step output for unreleased upstream dependency changes. |
| .github/workflows/main.yml | Adds release-deps job to run the CI analyzer and post/remove a sticky PR comment. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- release-crate.ps1: drop -AllowFailure on the fetch in the base-ref resolver so the surrounding try/catch can actually trigger and emit a warning on fetch failure (previously the catch was unreachable because -AllowFailure returns $null instead of throwing). - check-unreleased-dependencies.ps1: route Get-RepoRoot through Invoke-Git instead of shelling out directly, matching the design described in the PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
martintmk
approved these changes
May 21, 2026
…rades them When the post-release dep scan runs a nested `Invoke-ReleaseFlow` for an upstream crate the user opts to release, the resulting cascade may upgrade a crate that was already in the release set (e.g., the initial release patch-bumped `foo` and the nested major release of an upstream dep now requires a major on `foo`). Previously the merge skipped duplicates outright, so `Show-ReleaseSummary` and the final `feat(crate): release v<version>` message reported a stale version that did not match what was actually written to `Cargo.toml`. Replace the skip-on-duplicate logic with an in-place update: keep the original `OldVersion` (the pre-PR baseline) and adopt the latest `NewVersion` from the nested cascade. New crates are still appended as before. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The previous logic compared the working tree against the PR base ref to decide which workspace crates had unreleased modifications. That missed a real scenario: an earlier PR merges a source change to `bytesbuf` without bumping its version, and a later PR bumps `bytesbuf_io` (which depends on `bytesbuf`). On crates.io the published `bytesbuf_io` resolves to the last released `bytesbuf`, which does *not* include the unreleased modification. Because the modification predates the PR's base ref, the old `BaseRef`- relative scan saw nothing to flag. Switch each crate's "modification baseline" to its own most-recent commit that touched `version =` or `publish =` in its `Cargo.toml`, derived via `git log -1 -G '^(version|publish)\s*='`. Any change under `crates/<folder>/` newer than that commit (committed, working-tree, or untracked) is treated as unreleased. `Get-CratesWithVersionBumps` (release-set detection) intentionally still diffs against the PR base ref — that's the correct anchor for "what is this PR releasing". Replaces `Get-GitFileChangeSet` / `Get-CratesWithFileChanges` with the new `Get-CrateLastReleaseBaseline` + `Get-CratesWithUnreleasedChanges` helpers and rewrites `Get-UnreleasedModifiedDependencies` to consume the per-crate modification map. Also persists the manual test plan as `scripts/RELEASE-DEPS-TEST-CASES.md` so future agents can re-run T1-T16 (original PR-vs-base coverage) and N1-N10 (multi-PR baseline coverage) when the logic changes. All N1-N9 scenarios were verified in a scratch worktree before this commit; N10 (brand-new crate) is structurally covered by the new-crate branch in `Get-CratesWithVersionBumps`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two real defects flagged on the latest review pass:
1. `check-unreleased-dependencies.ps1` was supposed to alphabetically sort
the release-set listing in the sticky PR comment, but the chain
`@(Get-CratesWithVersionBumps ...) | Sort-Object` silently broke. The
helper returns its HashSet via `Write-Output -NoEnumerate` so callers
can use `.Contains()`. That wrapping makes `Sort-Object` receive a
single object (the HashSet itself), so the sort is a no-op and the
foreach below iterates the HashSet in insertion order. Fixed by
unwrapping with `... | ForEach-Object { $_ }` before sorting.
2. `Add-CascadeBulletToVersionSection`'s `if ($subStart -ge 0)` branch
built the new file content with
`@($lines[0..($insertAt - 1)]) + @($bullet) + @($lines[$insertAt..($lines.Count - 1)])`.
When `$insertAt` equals `$lines.Count` (target sub-header is the last
content in the file, no bullets yet, no trailing blank lines), the
right-hand slice becomes `$lines[N..N-1]` which is a reverse-range that
silently aliases to the last element — so the last line was duplicated.
Reproduced on a synthesised changelog before fixing. Mirrored the EOF
guard that the `else` branch already has.
Verified by direct PowerShell repros for both: sort now yields
alphabetical order, EOF insertion no longer duplicates the sub-header
line, and the non-EOF + idempotency paths are unchanged.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Anticipates a future home for other script-related test material. The doc is unchanged; only its location moves. References inside the doc point at other scripts (`scripts/lib/releasing.ps1`, `scripts/release-crate.ps1`, `scripts/check-unreleased-dependencies.ps1`) from the repo root and remain valid after the move. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…e set Documentation drift after the per-crate baseline refactor (cedd750). Four Copilot review comments on PR #436 flagged that synopses, parameter docs, and the user-facing PR-comment / interactive-warning text still said "modified vs the PR base ref" — but only the release-set anchor uses BaseRef; modifications are evaluated per crate against each crate's own last `version =` / `publish =` commit. Updated: - `scripts/check-unreleased-dependencies.ps1` — synopsis, description, BaseRef parameter docs, and the markdown body line ("unreleased modifications — changes newer than their last `version =` or `publish =` bump"). - `scripts/release-crate.ps1` — BaseRef parameter comment, the `Invoke-PostReleaseDepScan` function docstring, and the interactive warning text. (Switched the warning from a `"..."` to a `'...'` literal so the inline `` `version =` `` / `` `publish =` `` backticks aren't interpreted as PowerShell escape sequences — `` `v `` is vertical tab.) - `.github/workflows/main.yml` — `release-deps` job comment. Self-reviewed the rest of the PR diff for similar drift; the remaining "vs base" mentions (release-set BFS docstring, "Fetch base ref" workflow step name, "What this means" sticky-comment block) are legitimate and unchanged. Re-ran N1-N9 against the updated scripts in a scratch worktree: 9/9 pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Capture the non-obvious lessons learned while building the N-series and T-series test harnesses, so future maintainers/agents who need to rebuild a harness from scratch don't have to relearn them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The change detection that powers -Changed (and the snapshot used for elevation review under -Packages) is scoped to files under `crates/<package>/`. Modifications elsewhere in the repository — the workspace-level Cargo.toml, .cargo/, deny.toml, shared CI workflows, top-level scripts — are invisible to the scan even when they affect how a package builds or behaves. Document this in three places so the limitation cannot be missed: * `scripts/release-packages.ps1` — Description block for -Changed mode and the `.PARAMETER Changed` help entry. * `docs/releasing.md` — both the top-of-document `-Changed` bullet and the dedicated "Guided modes" section. Each note cross-references `-All` (which surfaces every publishable package regardless of detected changes) and `-Packages` (which lets the user list affected packages explicitly) as the two correct mitigations for cross-cutting changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
We do not need to support Windows PowerShell 5.1. Declare a hard floor of PowerShell 7.0 on every release-tooling script introduced by this PR so the version expectation is explicit and machine- checked at parse time. Files updated with `#Requires -Version 7.0` immediately after the copyright header (before any comment-based help): * `scripts/release-packages.ps1` * `scripts/check-unreleased-dependencies.ps1` * `scripts/tests/Pester/Run-Tests.ps1` * `scripts/lib/releasing.ps1` * `scripts/lib/release-flow.ps1` * `scripts/lib/check-unreleased-deps.ps1` `#Requires` is honored both in scripts that are invoked directly and in scripts that are dot-sourced; dot-sourcing a library that declares a minimum version into an interpreter that does not meet it fails fast with a clear diagnostic, which is what we want. Drop the `-Encoding utf8` arguments from the two `Add-Content` / `Set-Content` calls in `scripts/lib/check-unreleased-deps.ps1` that wrote to `$env:GITHUB_OUTPUT` and `$OutputFile`. PowerShell 7 defaults `*-Content` cmdlet encoding to `utf8NoBOM`, which is exactly the format GitHub Actions wants; the explicit argument was redundant. Also rewrite the now-misleading comment block at the top of `Set-StepOutput` that justified the explicit encoding with a claim about Windows PowerShell 5.1 BOM defaults — the `-LiteralPath` rationale is preserved and the encoding rationale is recast as "PS 7 already defaults to utf8NoBOM". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The skipped placeholder asserted that Invoke-PlanReview throws a
"no progress" diagnostic when an iteration body completes without
mutating any of {userTokens, declined, reviewedCascadeAsIs}. After
auditing the current control flow, that branch is genuinely
unreachable from any well-formed caller:
* an empty findings queue early-returns before the next iteration's
signature check runs;
* `Get-PackageReleaseDecision` only returns 'ignore' or one of
{'breaking', 'non-breaking', 'patch'};
* ignore always adds to `declined` or `reviewedCascadeAsIs`;
* accept always appends a token to `userTokens`;
* the switch's `default` arm throws "Internal error" on any other
action — never falling through to the loop body's tail.
A unit test would therefore have to inject a buggy state via mocks
that no real caller can produce — a tautology that adds no signal.
Drop the placeholder so the suite reports zero skipped tests instead
of carrying a permanent "(1 skipped)" footnote that hides any
genuinely-skipped regression in the future.
The signature-progress check itself is retained in production code
as defense-in-depth: a future change that introduces a
state-leak path (e.g. a new `continue`-without-mutate branch) will
abort with a clear diagnostic instead of silently spinning to the
runaway cap. The inline comment on the check is rewritten to
acknowledge the unreachable-but-defensive nature; a sibling comment
in PromptFlow.Tests.ps1 documents why the test was removed.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…eview Add the two specific tests the test-suite review identified as the highest-priority gaps. Both pin contracts that unit-level coverage of the individual helpers cannot demonstrate — they require driving the production entry points end-to-end. C1 — atomic multi-package on-disk product (integration test) Releasing-Integration.Tests.ps1 gets a new Describe block that calls Invoke-ResolvedRelease directly with a hand-built two-package plan (upstream user-source non-breaking → 0.2.1, downstream cascade-source → 0.1.1) and asserts every per-folder side effect: * package Cargo.toml × 2 — new [package] version line written, old line gone, other fields preserved * workspace root Cargo.toml — both [workspace.dependencies] inline versions updated, neither old version remains * CHANGELOG × 2 — new ## [version] - <date> section prepended, top- level # Changelog preserved, manually-curated ## [Unreleased] body folded into the new section, conventional-commit bullets present * downstream CHANGELOG — cascade-from-dependency bullet emitted (🔧 Maintenance / Now requires `0.2.1` of `upstream`) * Update-Readme — mocked and asserted invoked once per release-set member with the right -packageName argument Closes the "wrote Cargo.toml but skipped CHANGELOG/README" regression mode the review highlighted: previously, that regression would only have been observable through the returned Releases array, which lies about disk state. C2 — -Force end-to-end via the scenario harness Invoke-Scenario.ps1 grows Run.Force support in the targeted branch (splatted through to Invoke-ReleasePackagesMain). The changed/all branches throw if Force is set, mirroring the production parameter- validation rule that -Force is only valid with -Packages. S24-pin-with-cascade-conflict-force.scenario.psd1 mirrors S14 (same two-package workspace, same pin that conflicts with cascade) but sets Run.Force = $true. Where S14 asserts the resolver throws, S24 asserts the run completes with the explicit pin honoured verbatim (target 2.0.0, dependent 1.0.1 — below cascade's >=2.0.0 requirement). Closes the gap that -Force had only unit coverage (of Resolve-ReleaseSet); no test previously proved the CLI switch was plumbed all the way from Invoke-ReleasePackagesMain through Invoke-PlanReview into the resolver. Pester: 390 / 0 / 0 (was 388 / 0 / 0). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Reviewer noted S00 was the only scenario whose internal `Name` field diverged from its file base name. Pester's per-scenario It display name is derived from the file base name (Scenarios.Tests.ps1) while workspace paths and error messages use the scenario `Name` field (Invoke-Scenario.ps1); the mismatch made failure messages slightly harder to correlate. Surveyed the full `scenarios/` directory; S00 was the only mismatch. Rename the internal field to `S00-smoke-fresh-release` and fold the descriptive `-no-cascade` qualifier into the Description so the extra detail isn't lost. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…-root filter
The unreleased-deps check was intended to run both interactively (during
`release-packages.ps1`) and at CI time as an informational PR comment.
Surface-level the two flows look symmetric, but they make different
"what counts as a release-set member" assumptions: the interactive flow
knows when a release-set entry is cascade-source-with-no-mods (and can
correctly skip it as a BFS root, since such a member cannot have started
consuming any new dep features), while the CI-time approximation derived
the release set purely by diffing version bumps against the PR base ref
and had no way to distinguish cascade vs user-source entries — leading
to systematic false positives on every cascade-bumped package whose
dependencies happened to have unrelated edits in the same PR.
Rather than ship a check whose precision we cannot match between the
two flows, the CI scope is deferred until we have a more foolproof
architecture (one that does not depend on diffing package directories
against `main`). The interactive scan remains the source of truth.
Concrete changes:
* `.github/workflows/main.yml`: remove the `release-deps` job and its
reference in any documentation that listed it.
* `scripts/check-unreleased-dependencies.ps1`,
`scripts/lib/check-unreleased-deps.ps1`,
`scripts/tests/Pester/unit/check-unreleased-deps/`: deleted.
* `scripts/lib/releasing.ps1` — `Get-UnreleasedModifiedDependencies`:
apply the LIVE-flow filter as a default precondition on BFS root
selection. A release-set member only counts as a BFS root when it
also has source modifications past its per-package baseline.
Pure-cascade members (version bump only, no source changes of their
own) are categorically incapable of producing real findings via BFS,
only false positives, so they are skipped.
* `scripts/lib/releasing.ps1` — Phase B sweep: generalise from
`-IncludeAllModifiedAsRoots`-gated (iterating `modifiedMap.Keys`) to
always run over `$rootFolders`. This is the only way to preserve
Invariant B (release-set elevation review) for leaf-position release-set
members once the LIVE filter is applied — without it, a release-set
leaf with own mods could no longer surface for elevation because no
other release-set member would BFS to it.
* Doc comments (`releasing.ps1`, `release-flow.ps1`, `AGENTS.md`,
`docs/releasing.md`): strip references to the CI check.
* Existing tests updated for the LIVE filter precondition: the
release-set member used as a BFS root now needs its own
`ModifySource('<member>')` to qualify. Topology-Presets contexts and
Releasing-Integration tests (N1, N2, N4, N5, N6, N8, N9, N10, T16-style,
"tags non-release-set findings", "uses caller-provided snapshot",
"returns no findings when snapshot is empty", "behaves identically with
or without switch", and the WorkspaceDependencyChains tests) updated
accordingly. Scenarios S02, S03, S05, S07, S08, S09, S10, S11 each gain
a `ModifySource('<release-target>')` History op so the post-release
scan still has a BFS root.
* New Describe block in Releasing-Integration.Tests.ps1
("Get-UnreleasedModifiedDependencies: LIVE-flow BFS-root filter"):
four It blocks pinning the filter's contract for cascade- and
user-source release-set members, with and without own modifications.
Result: 367 Pester tests pass; `just spellcheck` clean.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… into feat/release-deps-upstream-scan
…ges.ps1 Examples used .\release-packages.ps1, which only works after cd'ing into scripts/. Use ./scripts/release-packages.ps1 (cross-platform in pwsh) so the examples are runnable from the repo root, matching the rest of the documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… into feat/release-deps-upstream-scan
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
When releasing a workspace package, an author may have also modified one of its workspace dependencies but forgotten to release that dependency. Locally everything builds via path-references, but once published the released package resolves against the last released version of each dependency on crates.io — missing the new changes. The dependency-side modification may even have landed in a previous PR that merged to main without a version increment, so a plain "diff vs PR base" check is not enough to catch it.
What changes for the user
Dependency review during release
release-packages.ps1now scans the dependency graph of every package in the release plan and surfaces every workspace dependency with unreleased modifications — evaluated per package against that dependency's own lastversion =/publish =commit, not against the PR base. For each finding the user can view the diff, skip the package, or release it alongside the original target.Multi-package releases are now atomic
Previously the script handled one package per invocation, with cascade decisions surfaced incrementally across re-runs. The new driver takes the entire release plan up-front:
After plan review, all writes —
Cargo.tomlversion increments,CHANGELOG.mdentries,README.mdregeneration, workspace[workspace.dependencies]updates — happen in one shot, followed by a workspacecargo check. Two guided alternatives are available when you don't have the full list ready up-front:-Changed(walk every package with unreleased modifications) and-All(walk every publishable package, modified or not).Semantic change type instead of numeric version components
The previous
-Bump major|minor|patchCLI conflated numeric version-string components with semantic intent — and they are not interchangeable: a breaking change on a0.xpackage moves the minor component, on a>=1.xpackage the major component. The new change-spec is a semantic label per package:breaking,nonbreaking, orpatch. Users who want to pin an exact target can do so explicitly —foo@1.0.0,bar@1.0.0-rc.1— with full SemVer 2.0 (three numeric components plus optional pre-release / build metadata) accepted.If the cascade computes a stronger change type than the user requested (e.g. the user said
patchbut the dependent exposes a breaking-changed dependency in its public API), the change type is auto-upgraded and the upgrade is shown in the plan display. Explicit version pins are not silently overridden — by default the plan is rejected; the new-Forceswitch overrides this when the pin really is intentional.Breaking changes for tooling muscle memory
scripts/release-crate.ps1is removed; usescripts/release-packages.ps1.-Bump major|minor|patchis gone; supply-Packages '<name>@<change-spec>', ...instead.Crate*→Package*symbol rename swept the release tooling (release-record property, function names, log strings). Cargo's own terminology (crates/,crates.io,Cargo.toml,cargo metadata) is preserved verbatim.Documentation and tests
Release vocabulary, workflow, and invariants now live in
docs/releasing.md;AGENTS.mdshrinks to a list of pointers. A Pester suite underscripts/tests/Pester/covers pure helpers, BFS / aggregation analyses on synthetic workspaces, and agent-driven end-to-end interactive scenarios. Runs locally viajust test-scriptsand on Windows + Linux in thescript-testsCI job.