Skip to content

feat(scripts): detect modified-but-unreleased upstream deps during release#436

Draft
sandersaares wants to merge 32 commits into
mainfrom
feat/release-deps-upstream-scan
Draft

feat(scripts): detect modified-but-unreleased upstream deps during release#436
sandersaares wants to merge 32 commits into
mainfrom
feat/release-deps-upstream-scan

Conversation

@sandersaares
Copy link
Copy Markdown
Member

@sandersaares sandersaares commented May 20, 2026

Problem

When releasing a workspace 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 against the last released version of each dependency on
crates.io — missing the new changes.

Crucially, those upstream modifications may have been introduced in an
earlier PR that merged to main without a version bump, not necessarily
the current PR. A diff of the current branch vs the PR base would never see
them, but they are just as unreleased as same-PR changes.

The existing cascade in release-crate.ps1 propagates bumps downstream
(target -> its dependents) but never upstream. This PR closes that gap.

Solution

Two layers of automation, sharing logic via the new scripts/lib/releasing.ps1
library and the scripts/lib/release-flow.ps1 orchestration library.

1. Interactive layer — release-crate.ps1

After the existing downstream cascade finishes, the script scans the release
set
(every crate whose version differs from the PR base ref) for transitive
workspace dependencies that:

  • live in this workspace,
  • have unreleased modifications (see "Per-crate baseline" below),
  • are not themselves in the release set.

For each finding the author is prompted [y/N] and (if yes) picks a bump
kind. Accepted crates are queued and released next, each with their own
cascade and re-scan. Declined crates are recorded so they aren't re-prompted.

2. CI layer — release-deps job

.github/workflows/main.yml gains a release-deps job that runs
scripts/check-unreleased-dependencies.ps1 (the same analysis,
non-interactive). When findings exist it posts a sticky PR comment listing
each modified-but-unreleased dependency with the chain that reaches it, so
reviewers can sanity-check materiality decisions. When there are no findings
the script exits fast, so the job always runs and stays cheap.

Per-crate baseline

"Modified" is evaluated per crate against that crate's own last release
boundary, not against the PR base ref. For each crate the analysis runs:

git log -1 --format=%H -G '^(version|publish)\s*=' -- crates/<folder>/Cargo.toml

That returns the most recent commit whose diff touched the crate's top-level
version = or publish = line. Any change under crates/<folder>/ newer
than that commit — committed, working-tree, or untracked — is treated as
unreleased and feeds into the BFS from each release-set crate.

The release-set itself (Get-CratesWithVersionBumps) intentionally still
diffs against the PR base ref — that's the correct anchor for "what is this
PR releasing".

Tags are not consulted: a CI clone or partial fetch may not have them, and
the Cargo.toml edit is the canonical cause of a release while a tag is
downstream evidence.

Notable design points

  • scripts/lib/releasing.ps1 is a pure dot-source library (no top-level
    param(), no side effects). It owns shared regex patterns, the safe-git
    wrapper, semver helpers, and workspace/dep analyses.
  • scripts/lib/release-flow.ps1 owns the release orchestration (changelog
    formatting, cascade, prompts, Invoke-ReleaseMain). It dot-sources
    releasing.ps1 so consumers (tests, the CLI) only need to import the
    flow library. release-crate.ps1 is now a thin CLI shell.
  • Dependency graph uses Cargo metadata's normal + build deps; dev-dependencies
    are 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. Bullet format is
    Now requires <version> of \`(a structured, formal wording rather than the colloquialbump ... to ...`; consistent across both the fresh-bump
    and re-cascade emission paths).
  • All git invocations go through Invoke-Git (array-argument, no
    Invoke-Expression).

Tests

A comprehensive Pester suite was added under scripts/tests/Pester/:

  • Unit tests for pure functions (semver math, regex helpers, conventional-commit
    formatting) and Git/filesystem helpers — Pester/unit/.
  • Integration tests covering the BFS / aggregation analyses against synthetic
    Cargo workspaces — Pester/integration/. The N1-N9 multi-PR / per-crate
    baseline scenarios that motivated this PR are pinned here.
  • End-to-end scenario tests with agent-driven interactive answers —
    Pester/scenarios/.

Run locally with just test-scripts or pwsh ./scripts/tests/Pester/Run-Tests.ps1.
CI runs the same suite via the script-tests job on both Windows and Linux.

Files

  • new: scripts/lib/releasing.ps1, scripts/lib/release-flow.ps1,
    scripts/check-unreleased-dependencies.ps1, scripts/tests/Pester/**
  • modified: scripts/release-crate.ps1 (slimmed to a thin CLI shell),
    .github/workflows/main.yml (new release-deps and script-tests jobs)
  • removed: .github/prompts/bump-crate-version.prompt.md — obsolete now that
    the cascade automation handles version bumps end-to-end without a manual
    prompt-driven workflow.

sandersaares and others added 2 commits May 20, 2026 14:44
…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
Copy link
Copy Markdown

codecov Bot commented May 20, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.0%. Comparing base (074a147) to head (3b36946).
⚠️ Report is 3 commits behind head on main.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #436   +/-   ##
=======================================
  Coverage   100.0%   100.0%           
=======================================
  Files         286      286           
  Lines       22879    22978   +99     
=======================================
+ Hits        22879    22978   +99     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ralfbiedert
Copy link
Copy Markdown
Collaborator

Whops, I missed the draft on this

sandersaares and others added 2 commits May 21, 2026 13:53
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>
@sandersaares sandersaares marked this pull request as ready for review May 21, 2026 11:06
Copilot AI review requested due to automatic review settings May 21, 2026 11:06
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ps1 to (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.

Comment thread scripts/release-crate.ps1 Outdated
Comment thread scripts/check-unreleased-dependencies.ps1
- 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>
Copilot AI review requested due to automatic review settings May 21, 2026 12:14
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread scripts/release-crate.ps1 Outdated
sandersaares and others added 2 commits May 21, 2026 15:33
…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>
Copilot AI review requested due to automatic review settings May 21, 2026 14:54
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Comment thread scripts/check-unreleased-dependencies.ps1 Outdated
Comment thread scripts/release-crate.ps1 Outdated
sandersaares and others added 2 commits May 21, 2026 18:32
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>
Copilot AI review requested due to automatic review settings May 22, 2026 04:56
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Comment thread scripts/check-unreleased-dependencies.ps1 Outdated
Comment thread .github/workflows/main.yml Outdated
Comment thread scripts/release-crate.ps1 Outdated
Comment thread scripts/tests/RELEASE-DEPS-TEST-CASES.md Outdated
sandersaares and others added 2 commits May 22, 2026 08:13
…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>
Copilot AI review requested due to automatic review settings May 22, 2026 05:28
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Comment thread scripts/release-crate.ps1 Outdated
Comment thread scripts/release-crate.ps1 Outdated
sandersaares and others added 2 commits May 22, 2026 08:55
The replacement string `\\\\` was producing two literal backslashes
before each escaped metacharacter (e.g. `1.2.3` -> `1\\.2\\.3`), because
`\` is *not* a special character in .NET regex replacement-string syntax
— it's literal — and the two `\\` characters in the PowerShell single-
quoted string both pass through verbatim. The correct replacement is
`\\`: one literal backslash plus the group-1 backreference, matching
`[regex]::Escape` semantics and matching the sibling pattern at
`Add-CascadeBulletToVersionSection`.

This was latent because the only inputs (crate names) never contain any
regex metacharacters, so the double-escape never fired. Fixed for
consistency and to prevent future surprises if the helper is reused.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
scripts/release-crate.ps1 now fully automates the version-bump cascade
(non-dev workspace dependents, root Cargo.toml updates, breaking-vs-patch
classification) that the prompt was guiding humans/agents through. Also
drops the now-stale reference to the prompt from release-crate.ps1's
synopsis.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 22, 2026 07:04
@sandersaares sandersaares marked this pull request as draft May 22, 2026 07:09
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Comment thread scripts/release-crate.ps1 Outdated
}
}

Set-Content -LiteralPath $ChangelogFile -Value $new -Encoding utf8
Comment thread scripts/release-crate.ps1
Comment on lines 23 to +29
is applied: enough to refresh the workspace-pinned version, but without overstating
the change to downstream consumers.
Dev-only dependents are skipped — they automatically pick up the new workspace version.
This mirrors the guidance in `.github/prompts/bump-crate-version.prompt.md`.
3. Changelog Generation: A CHANGELOG.md entry is generated for the target and every cascaded
dependent. Cascaded crates that have no other commits since their last release get a single
`bump \`<target>\` to <new-version>` entry under `🔧 Maintenance` (or `⚠️ Breaking` for
major bumps).
`Now requires <new-version> of \`<target>\`` entry under `🔧 Maintenance` (or `⚠️ Breaking`
for major bumps).
sandersaares and others added 15 commits May 22, 2026 10:16
Replace "file(s)", "crate(s)", "finding(s)", "commit(s)",
"package(s)", "test group(s)", "example(s)" with the bare plural.
Accept the cosmetic "1 crates" case rather than the wishy-washy
parenthetical. Applies the rule consistently across all script outputs
(both .ps1 and .rs), not just the lines in this PR's diff.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…nvoke-PostReleaseDepScan

The interactive post-release scan computes $new (findings to prompt for) once
at the top of each outer iteration. Within the foreach over $new, accepting a
release calls Invoke-ReleaseFlow, which can cascade-bump other crates that are
still pending in the same $new. The loop previously continued prompting for
those crates, producing the misleading "Leaving X unreleased" message even
though X had just been released via cascade.

Track the current release set in a HashSet seeded from
Get-CratesWithVersionBumps and grown after every nested Invoke-ReleaseFlow.
Before each Read-Host, skip with a clear "cascade-bumped by a prior release in
this run (now at <version>) — skipping prompt" message when the entry is now
in the release set.

Adds a T17 row to the manual test cases doc covering the regression.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Introduces scripts/tests/Pester/ with:
  * Run-Tests.ps1 entry point that validates Pester 5.7+ availability,
    discovers *.Tests.ps1 under unit/, integration/, scenarios/, and
    emits NUnit XML for CI consumption.
  * _common/TestHelpers.ps1 with Get-OxiRepoRoot for deterministic path
    resolution (set via OXI_TEST_COMMON in Run-Tests.ps1).
  * _common/New-SyntheticWorkspace.ps1 — a synthetic Cargo-workspace
    fixture builder with 9 topology presets (Linear2/3/4, Diamond4,
    Macros3, FanOut5, UpDown5, Mixed6, Detached) and ad-hoc -Spec
    support. Workspaces use workspace inheritance (foo.workspace = true)
    to mirror production layout and avoid a latent Update-CrateVersion
    bug pinned by Phase 5.
  * unit/releasing/Smoke.Tests.ps1 sanity check for shared-library
    loading.
  * integration/Topology-Presets.Tests.ps1 — one round-trip test per
    preset covering BFS, dev-dep filtering, publish=false filtering,
    and disconnected-component bleed prevention.

Adds a 'just test-scripts' recipe; the existing 'just install-tools'
recipe now installs Pester 5.7+ idempotently. DEVELOPMENT.md lists
Pester among the prerequisites and points at 'just test-scripts'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pulls the inline entrypoint of release-crate.ps1 into two new functions so
Pester scenarios can drive the full release flow in-process:

  * Invoke-WorkspaceCheck wraps the post-release 'cargo check --workspace'
    call. Tests Mock it; production code calls it inside Invoke-ReleaseMain.
  * Invoke-ReleaseMain wraps input validation, pre-flight checks, GitHub
    remote detection, base-ref resolution, the workflow, and the workspace
    check. Returns the array of release records so tests can assert on
    final state. The script-level execution block is now a single line
    that invokes Invoke-ReleaseMain with the script-level parameters.

Also fixes a latent bug surfaced during refactor validation: when
Test-GitRef sets \ = '' because the base ref could not be
resolved, downstream calls to Invoke-CascadeStep, Invoke-ReleaseFlow,
and Invoke-PostReleaseDepScan would fail parameter binding with a
misleading 'Cannot bind argument' error. The internal short-circuit
'if (\ is empty) return' never fires because parameter
validation rejects the empty string before the body runs. Adding
[AllowEmptyString()] to BaseRef on all three functions lets the
intended skip path execute.

No behavior change for callers that always pass a valid BaseRef.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds 55 unit tests covering pure helpers in scripts/lib/releasing.ps1 and
scripts/release-crate.ps1:

  * SemVer arithmetic: Compare-SemanticVersions, Get-NextVersion (incl.
    Cargo 0.x.y and 0.0.z rules), Get-BumpKindFromVersions,
    Test-IsBreakingChange.
  * Input validation: Test-ValidVersion, Test-ValidCrateName.
  * Workspace metadata helpers: Test-CrateExposesTarget,
    Get-CrateFolderForPath.
  * release-crate.ps1 helpers: Sort-KeysByPreferredOrder,
    Format-ConventionalCommits (header grouping, breaking-change lift,
    PR-link injection, ignored-type filtering, miscellaneous bucket).

To dot-source release-crate.ps1 without running its entrypoint, the
script now skips Invoke-ReleaseMain when the env var
OXI_RELEASE_CRATE_NOEXEC is set to "1". CrateName is no longer mandatory
at the script level — Invoke-ReleaseMain validates it after dispatch.
Production behavior is unchanged when the env var is unset.

Found two latent bugs (tracked for Phase 8 bug-bash):
  * Compare-SemanticVersions hits an infinite loop on single-segment
    inputs like "1" because PowerShell scalar += 0 never promotes to an
    array. Production callers never pass such values (Test-ValidVersion
    rejects them) so the bug is dormant, but the pad-to-3 loop is unsafe
    and should be rewritten using [System.Version] or explicit array
    construction.
  * Update-CrateVersion crate-level version-rewrite regex is overly
    broad and would clobber declared versions of inline workspace deps
    (Phase 2 smoke notes). Dormant in production because real crates use
    workspace inheritance.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds 27 unit tests covering helpers that touch git or the filesystem in
scripts/lib/releasing.ps1 and scripts/release-crate.ps1:

  * Test-GitRef — branch / HEAD / non-existent / SHA cases.
  * Get-CurrentVersion — happy path and missing-file error.
  * Get-CrateVersionFromRef — HEAD, prior commit, and non-existent
    crate folder.
  * Get-CrateLastReleaseBaseline — multi-commit history with source
    edits surrounding a version-changing commit (verifies the right
    commit SHA is returned).
  * Get-WorkspaceCrates — Mixed6 preset coverage of publish=false and
    dev-deps exclusion.
  * Get-AllTransitiveDependents — Diamond4 dedup and Mixed6
    publish=false exclusion.
  * Get-CratesWithUnreleasedChanges — committed / working-tree /
    untracked file paths, and publish=false skip.
  * Get-CratesWithVersionBumps — version-differs and empty-set cases.
  * Add-CascadeBulletToVersionSection — Maintenance vs Breaking
    sub-header selection, missing version section, missing changelog.

Each Describe block calls Invalidate-WorkspaceMetadataCache in BeforeAll
to avoid bleed from a prior fixture; tests that build their own ad-hoc
workspace inside an It also invalidate first.

Full suite is now 93/93 green in ~82s on Windows.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add 20 Pester integration tests under scripts/tests/Pester/integration/:

- 13 BFS/topology tests re-encoding the N1..N9 scenarios from the manual
  RELEASE-DEPS-TEST-CASES.md harness (plus Linear4/Diamond4/Detached
  topology variants).
- 3 Update-CrateVersion tests, including a pin for the inline-dep
  workspace-version clobbering bug logged in Phase 8.
- 4 Invoke-CascadeStep tests (fresh bump, sufficient pre-bump, upgrade
  insufficient pre-bump, missing crate warning).

Two defensive fixes to support these tests:

- `Invoke-CascadeStep` `-PrBaseUrl` gained `[AllowEmptyString()]`
  so empty strings pass through (production already calls it with empty
  values when `OXI_PR_BASE_URL` is unset). Same class of latent issue
  as the BaseRef fix in Phase 2.
- `Invoke-Git` now disables `\$PSNativeCommandUseErrorActionPreference`
  locally; the function manages exit codes manually via `\$LASTEXITCODE`
  and was throwing prematurely under Run-Tests.ps1's strict-mode default.

Suite size: 113 tests (Phases 1-5), full green in ~140s.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds a PSD1-driven scenario runner that exercises the full
release-crate.ps1 entrypoint under mocked prompts and a synthetic
workspace, and lands seven initial scenarios covering smoke, clean
upstream, accept/decline mix, decline-all, non-interactive mode,
diamond aggregation, and the cascade-mid-foreach skip path.

Each .scenario.psd1 declares its topology (preset or custom spec),
history transformations, the release invocation, scripted prompt
answers, and an expected outcome (released crates + versions + raised
prompts + unconsumed answers). The runner mocks Read-Host,
Invoke-WorkspaceCheck, and Test-InteractiveSession, replays the
scenario history, then calls Invoke-ReleaseMain in-process so each
scenario runs in under a second.

While building the scenarios I uncovered a real UX bug in
Get-UnreleasedModifiedDependencies: \ was a plain Hashtable,
whose enumeration order is non-deterministic across processes. That
made the post-release prompt order flaky in production (users would
see prompts in different orders run-to-run) and impossible to assert
in tests. Fix is two parts:

  - \ is now an [ordered]@{} keyed on folder, so BFS
    insertion order is preserved when iterating .Values.
  - The release-set foreach now sorts the input HashSet
    alphabetically so the BFS roots themselves are visited in a
    stable order.

OrderedDictionary uses .Contains(key) instead of .ContainsKey(key);
updated the membership check accordingly.

113 prior tests + 7 scenarios = 120 green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds a new `script-tests` job to .github/workflows/main.yml that runs
the Pester suite (`scripts/tests/Pester/`) on ubuntu-latest and
windows-latest. The job has no delta gate (delta tracks Rust crates and
would mark script-only PRs as skippable, which is exactly the opposite
of what we want here). It installs Pester 5.7.1+ inline using the same
incantation as `just install-tools` and reuses the shared Setup
composite action for cargo (the synthetic-workspace fixture builder
shells out to `cargo metadata`).

The new job is added to `required-checks.needs` so branch protection
keeps requiring only that single context per AGENTS.md policy. It also
has a strategy.matrix, so being part of the fan-in correctly avoids the
stuck-context bug for the matrix-expanded contexts.

Also tightens documentation:
- DEVELOPMENT.md: add `just test-scripts` to the Linux validation list
  for symmetry with the Windows list (already had it).
- RELEASE-DEPS-TEST-CASES.md: reframe as a historical/reference
  document; most behaviours are now pinned by the automated Pester
  suite, but the prose specification is still useful when diagnosing
  against the real workspace.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Update-CrateVersion previously applied
`(?<=version\s*=\s*")[^"]+` to the crate's Cargo.toml via PowerShell's
`-replace` operator. That regex matches every occurrence of
`version = "..."` in the file, so any inline workspace-dep declaration
written like `dep = { path = "...", version = "x.y.z" }` was silently
overwritten with the bumped crate's version. The fault only manifested
when a crate explicitly declared an inline dep version (workspace
inheritance via `dep.workspace = true` is the default and was
unaffected), which is why the symptom was rare but still real.

The fix captures the `[package]` prefix up to the opening quote of the
version literal as group 1, anchors the replacement to that group, and
substitutes only the literal. The lazy character class `[^\[]*?` keeps
the match scoped to the package table (any `[` ends a TOML table
header). An explicit pre-match check emits a clearer error if the file
does not contain a `[package]` table at all.

Flips the Phase 8 BUG PIN integration test from documenting the buggy
output to asserting the corrected behaviour: the inline upstream-dep
version stays at `0.2.0` when the host crate is bumped from `0.1.0` to
`0.1.1`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ment hang

Compare-SemanticVersions, Get-NextVersion, Get-BumpKindFromVersions, and
Test-IsBreakingChange all parsed their inputs with the same idiom:

    $parts = $version.Split('.') | ForEach-Object { [int]$_ }
    while ($parts.Count -lt 3) { $parts += 0 }

When the input has a single segment (e.g. '1'), the `ForEach-Object`
pipeline emits a single scalar [int]. PowerShell's pipeline collapses
single-item output to a scalar, and `[int] += 0` performs arithmetic
rather than array concatenation. The pad-to-3 loop then never terminates
and the script hangs.

Production callers never pass single-segment versions (Test-ValidVersion
rejects them up front), so the bug is dormant. It still wants pinning
because any future caller that bypasses validation would hang the
release flow.

Fix: wrap each pipeline in `@(...)` to force array context. The same
`+= 0` idiom is then a true array append in all four functions.

Adds short-form coverage to PureFunctions.Tests.ps1:
  - Compare-SemanticVersions accepts 'X' and 'X.Y' shapes.
  - Get-NextVersion, Get-BumpKindFromVersions, Test-IsBreakingChange
    each accept a single-segment current/old version.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…t-version

The Update-CrateVersion fix in 511fd90 used \bversion which still
matched rust-version because - is a non-word character (a word boundary
sits between - and �ersion). The legacy CargoVersionRegex used by
Get-CurrentVersion and Get-CrateVersionFromRef had the same weakness —
it was never scoped to the [package] table at all.

Centralize a single line-anchored, [package]-scoped regex in releasing.ps1
(CargoPackageVersionRegex) and route Get-CurrentVersion,
Get-CrateVersionFromRef, and Update-CrateVersion through it. The new
regex:

- anchors at line start via (?m), so substring keys like rust-version
  cannot match;
- walks from the [package] header through subsequent lines that don't
  start a new TOML table, so descriptions containing [brackets] are
  tolerated but [package.metadata.*] subtables correctly cut off the
  match;
- exposes group 2 as the literal version, so callers do not have to
  re-parse;
- is used with [regex]::Replace count=1 in Update-CrateVersion, so even
  if a synthetic file somehow had two matches the second is left alone.

Real-world impact is dormant today because every per-crate Cargo.toml
in the workspace uses rust-version.workspace = true, but the bug would
fire the moment any crate inlined rust-version = "1.88" near its
version literal. Adds pinning tests in Get-CurrentVersion and
Update-CrateVersion covering both rust-version-before and -after
orderings plus the bracketed-description tolerance.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
release-crate.ps1 previously did double duty: it was both the CLI entry
point and an implicit library that tests dot-sourced via an
OXI_RELEASE_CRATE_NOEXEC environment-guard hack at the bottom of the
file. The guard made CrateName non-mandatory so dot-sourcing wouldn't
trigger Read-Host on the missing parameter, and tests had to set/unset
the env variable around every dot-source call.

Extract the configuration tables, 17 helper functions, and the
Invoke-ReleaseMain entrypoint into scripts/lib/release-flow.ps1. The
library dot-sources scripts/lib/releasing.ps1 internally so tests
import a single file. release-crate.ps1 is now a thin CLI shell with a
mandatory CrateName parameter, no env-guard, and a single call to
Invoke-ReleaseMain.

Update all five Pester BeforeAll blocks (PureFunctions, GitFs, two in
Releasing-Integration, Scenarios) to dot-source the library directly,
and update Invoke-Scenario.ps1's docstring to describe the new pattern.

Pester suite still 129/129 green on Windows.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The T-series (T1-T17) and N-series (N1-N10) scenarios are now encoded
in the Pester suite (scripts/tests/Pester/integration and
scripts/tests/Pester/scenarios). The harness-crafting hints section is
about the now-retired worktree-based manual harness, replaced by
synthetic workspaces.

The "humans decide" CHANGELOG-only policy noted in the open-questions
section is pinned by N6 in Releasing-Integration.Tests.ps1.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@sandersaares sandersaares force-pushed the feat/release-deps-upstream-scan branch from 23a2e09 to 008263f Compare May 26, 2026 12:20
sandersaares and others added 2 commits May 26, 2026 15:30
Two behavioural fixes from PR review:

* Cascade changelog bullets now use �ump \<target>\ to <new-version>
  to match the established repo convention (see crates/cachet/CHANGELOG.md,
  crates/seatbelt/CHANGELOG.md, etc.). The earlier
  Now requires <version> of \<target>\` wording would have created
  changelog inconsistency across past and future cascade releases.

* Add-CascadeBulletToVersionSection previously wrote the changelog via
  Set-Content with a string-array, which joins entries with
  [Environment]::NewLine — CRLF on Windows. Since .gitattributes enforces
  `* text eol=lf`, that produced noisy whole-file diffs when the script
  ran from Windows. Same problem in the Out-File new-changelog path in
  Write-Changelog. Both sites now build a single string joined with \n
  and use Set-Content -NoNewline.

GitFs unit tests updated for the new bullet wording. Pester suite still
129/129 green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…wording

Two corrections to the prior commit:

* Restored the cascade changelog bullet wording to
  `Now requires <version> of \<target>\` — the colloquial
  `bump <target> to <version>` wording was a regression. The new
  wording is the intended professional form.

* Replaced the hardcoded LF write with auto-detected line endings so the
  script behaves correctly on any repo it might be used in, not just one
  that enforces LF via .gitattributes. New helper Get-FileLineEnding in
  scripts/lib/releasing.ps1 samples the existing file's CRLF/LF ratio
  and returns the dominant convention (LF default for missing/empty
  files). Both write sites (Write-Changelog''s existing-file path and
  Add-CascadeBulletToVersionSection) now match the file''s own
  convention. Unit tests cover all five EOL-detection cases and pin
  both LF and CRLF preservation through the cascade-bullet path.

Pester suite 136/136 green (5 new EOL helper tests + 2 new
preservation tests).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants