Give ReleaseCommand an end-to-end test harness - #341
Conversation
BuildServiceProvider was a private method of Program, which is marked [ExcludeFromCodeCoverage]: the graph every command runs on was neither reachable from a test nor counted as covered. A test that wanted the real composition had to rebuild it by hand, and drift from the real one on the first registration added here. The registrations move verbatim into ServiceCollectionExtensions as AddBvServices, leaving Program with the five singletons a host decides for itself: the console, the reporter, GlobalSettings, CommandParameters, and the home directory provider. A host that fakes a boundary now registers its fake after the call, where the last registration of a service type wins. No behavior change: same registrations, same order, same lifetimes. The coverage exclusion's justification drops its mention of DI wiring, which is no longer there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The logo line was one character over the 140-character limit. Naming the message brings it back under, and the interpolation holes are compile-time constants, so the local is one too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A test that drives a release needs more of a repository than TempGitRepo offered: something to push to, a tag to collide with, and a way to read back what was committed. AddBareRemote creates a bare repository of its own and makes the current branch track it, so a push is a real push over libgit2's local transport and its outcome can be read back with GetRemoteTipSha. GetCommits reports messages, committers, and changed files; CreateTag, TagNames, and SetCommitterIdentity cover the rest. Two things worth their comments in the code. GetCommits sorts topologically, because commits made within the same second — the norm here — carry the same timestamp, and libgit2's default time sort then returns them in an arbitrary order: a release commit could come back after the commit it descends from. And a type initializer points libgit2's configuration search paths at an empty directory, so that no repository sees the machine's global, XDG, or system Git configuration: tests would otherwise find a committer identity on a developer laptop and none on a bare CI runner, and take different code paths on each. Doing that per instance instead mutates a libgit2 global underneath repositories other threads are already using, which the runner reports as a corrupted heap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ReleaseCommand is sequencing and policy, and neither survives extraction: what is left after issue Tenacom#320 can only be tested by running it. The harness runs it over a real Git repository in a temporary directory, with only the boundaries faked — child processes, the hook's file-based app, and the server — while the service graph, the versioning, the changelog, the commits, and the push are the real ones. Two things follow from running the real composition. The home directory provider is the anchoring one, so the current directory moves to the repository exactly as it does in a real run, which is what makes the command's relative paths (the changelog, the artifacts directory) resolve where they should. And the faked pack leaves behind the artifacts a real one would, because the command reads them back to discover the produced packages and to gather release assets. Ordering is recorded as effects rather than calls: every step carries the repository's state at the moment it happened, so that "the release commit existed before the artifacts were packed" and "the branch was pushed before the packages were" are read off the repository instead of asserted against a mock. The fake adapter deliberately carries no logic, and its knobs are grouped by whether they will belong to the CI platform or to the Git host: the adapter is to be split in two, and this one should not need rewriting when it happens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
35 tests over the harness, covering ReleaseCommand's line coverage from 0/154 and, more to the point, pinning the order it does things in: the verification pass runs before the release commit exists and the artifact pass after it, the tag check gates the artifact pass, the hook runs after pack and its changes join the post-release commit alongside the dogfood rewrites, and the branch reaches the remote before any package reaches NuGet. The changelog suite walks the none/stable/all policy across prerelease and stable releases, including the empty-section substitute and the failure when none is configured. The failure suite covers what the command refuses and what it undoes: a tag collision, a local build, a missing committer identity, a failing hook, and a publication that fails after the repository has been pushed — which resets the branch and force pushes it back to where it started. Two of the tests document behavior that surprised me, each with the mechanism in a comment. A release that can never succeed is refused only after the whole solution has been built and tested, because the preliminary checks run after the verification pass. And an additive public API forces a minor bump, which moves the version onto a prerelease line before the public-API step reads it, so the release that introduces the API does not ship it. Every one of them takes over the process's current directory and an environment variable, so all three suites are [NotInParallel]. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The preliminary checks — cloud build, current branch, public-release branch, committer identity — ran after the verification pass, as did the initial versioning consistency check and the version spec computation. None of them reads anything the build produces, so `bv release` would clean, restore, build, and test the whole solution before announcing that a release cannot be created from this branch, or on this machine at all. On a laptop that is minutes for a message that was knowable at once; the version spec computation even carried a comment asking to run "as early as possible", which it then did not. The block moves above the verification pass, which now sits immediately before the draft release is created. Creating the release stays where it was: it is the first step with an effect outside the repository, and it belongs after the build that justifies it. One ordering side effect is worth naming: the CI bot identity is written to the repository's Git configuration before the build rather than after, so a build that fails now leaves it set. That is a local configuration value on a checkout that is about to be released from, and it was already written before any commit was made. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A release that applied a version-spec change tagged and published a version one patch below the one its own artifacts were built with: `--bump minor` on the 2.3 line tagged 2.4.0-preview, while the very commit it tagged built as 2.4.1-preview. UpdateRepository staged the version file into the release commit after EnsureReleaseCommit had already computed the version from that commit. The Git height is computed from committed content — the index and the working tree are invisible to the calculator — so at computation time no commit carried the new version line, and the height came back 0. That is not a height: the calculator counts from 1 at the commit that bumps MAJOR.MINOR and reserves 0 for a line with no committed history, which is why the symptom is an x.y.0 version that no correct computation can produce. The consequences all followed from the artifact pass running after the amend, hence at the correct version: packages were built, attached, and pushed as 2.4.1-preview under a 2.4.0-preview tag, and produced-package discovery, which matches packages by version, found none — so the self-reference (dogfood) updates were silently skipped. The ordering is forced from both ends: the version can only be read once the commit's tree is final, and the message can only be written once the version has been read. NameReleaseCommit now holds that rule in one place, the release commit is always made under a provisional message and named afterwards, and every method that changes its tree ends by calling it. UpdateRepository stages before the commit is made, so what it puts in the commit is part of what the version is computed from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit closed the one path that reached a Git height of 0, but nothing prevented another. Height 0 is not a height: the calculator counts from 1 at the commit that bumps MAJOR.MINOR and returns 0 for a version line no commit carries. A release of such a version tags a commit that does not reproduce it, because a build of that commit counts from 1 as soon as the version file reaches it. At least one path is still open: a repository whose VERSION file has never been committed releases without a version-spec change, so nothing ever stages it, and the release commit does not carry the version line either. The check goes where the version becomes final — the final consistency check, which ReleaseCommand already runs at exactly that point, before the artifact pass and before the tag check, so a refused release costs nothing beyond the verification pass and rolls its commit back. It does not go into the calculator or into VersioningService: a height of 0 is a legitimate answer for a build, which is what the working tree looks like between `bv version advance` and the commit of its result. Only publishing one is wrong. The relaxed check deliberately does not enforce it: it runs before the version-spec change is applied, so it measures the outgoing version line, and rejecting a 0 there would refuse a release that is about to make the line valid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #341 +/- ##
===========================================
+ Coverage 56.48% 73.13% +16.65%
===========================================
Files 160 161 +1
Lines 4458 4590 +132
Branches 782 793 +11
===========================================
+ Hits 2518 3357 +839
+ Misses 1840 1072 -768
- Partials 100 161 +61 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
@claude please review this PR. |
|
Claude finished @rdeago's task in 8m 39s —— View job Review of #341 —
|
`UpdateRepository` stages its files and then calls `EnsureReleaseCommit`, which commits them and names the commit after the version computed from it. When the release commit did not exist yet — the common case, since a version-file bump is usually the first thing to update the repository — the method went on to amend that fresh commit back to the provisional message and name it a second time. Three commit objects and two version computations, where one of each is enough. The result was correct: the index does not change across the redundant amends, so tree and message land where they should. What it was not is readable, in the log or in the code. A single logical operation reported `Creating release commit...` immediately followed by `Amending release commit...`, which is a confusing thing to meet while reading CI output. The amend path now runs only when there is a commit to amend. The XML docs of `EnsureReleaseCommit` follow: it no longer creates an empty commit, it commits whatever is staged, which since the staging move is routinely something. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`GetCommits` and `TagNames` opened a repository handle of their own, so that commits made by the code under test — which holds a handle of its own — were visible as soon as they were made. `HeadSha` and `CurrentBranchName` kept reading through the handle this class holds for its own operations. Nothing depends on the difference today: every test reads `HeadSha` before running the code under test, and compares it afterwards against a `GetCommits` result. But the asymmetry is a trap for the next test that reads `HeadSha` after a release has run, and a stale answer there would look like a rollback bug rather than a stale handle. The rule is now a property of the type rather than of two of its members, so it moves to the type's remarks: members that read open their own handle, the kept handle is for members that change something. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The faked `pack` asked `VersionService` — the very object under test — which version to write into the package file names. A real `dotnet pack` does not: it computes the version from the repository, through the same Git height the release will be tagged with but by way of its own build. The two agreeing is the property worth testing, and the harness was assuming it. It is not a hypothetical, either: the two disagreeing by one patch is exactly the bug this branch fixes, and every test would have kept passing through it, because the fake artifacts followed the command's belief wherever it went. `ComputeVersion` already does the independent computation for the one test that asserts on it. Using it here as well makes "the artifacts carry the version a build of this commit produces" an invariant of every test that packs anything, and would have shown the bug as packages nobody could discover. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every bump scenario ran with dogfooding off, so the tests covered the cause of the version bug — the tag naming a version the artifacts were not built with — and none of its consequences. The first consequence a real release meets is the one this test now pins: self-reference discovery matches package files by name, so a version that is off by one patch matches no package at all, rewrites nothing, reports nothing, and lets the release finish with dogfooding silently skipped. Asserting on the rewritten files rather than on a recomputed version is what makes the case work with dogfooding on: the post-release commit is itself part of the version line, so by the time the assertions run the repository already yields one patch more than the release published. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The configuration search paths pointed at a fixed path under the temp directory, created with `Directory.CreateDirectory`, which adopts an existing directory whoever it belongs to. On Windows the temp directory is per-user, but on Linux it is shared, so a `.gitconfig` planted in `/tmp/bv-test-gitconfig` by anything at all would have been read as global configuration by every test — turning the mechanism that exists to keep the machine's configuration out into the one letting it in, without a word. `Directory.CreateTempSubdirectory` gives us a directory that is ours, unique per process, and empty by construction, which is exactly the input libgit2 needs for the isolation to mean what it says. Its lifetime is the only reason the fixed path was appealing: nothing can delete the directory while a repository may still be opened, because restoring the search paths mid-process is the race the type initializer exists to avoid. Process exit is the first moment it can go and the last at which any code of ours runs, so that is where it goes — leaving nothing behind on a normal test run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Dispose` deleted the repository's own directory before the directories of the bare remotes added to it. Deleting a Git working tree on Windows is the step most likely to fail — a stray lock on a read-only object file is the classic — and when it did, every remote leaked with it. The remotes now go in a `finally`, so that the failure that is likely to happen cannot take with it the cleanup that would otherwise have succeeded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`AddBareRemote` put the remote's path in the dictionary before asking libgit2 to add the remote. A duplicate name — which `Remotes.Add` rejects — therefore left the dictionary holding a path for a remote that was never created, and `GetRemoteTipSha` would happily open it and report a tip for something the repository knows nothing about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The harness had no way to make the server adapter withhold push credentials, so the branch that warns about it never ran. It is the one place in the command where a missing input is deliberately not fatal — the credentials are a fallback, and a checkout that is already authenticated pushes fine without them — which is exactly the kind of decision worth pinning: it is one edit away from becoming a hard failure and nothing would have objected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"The last registration of a service type is the one resolved" is true of `GetRequiredService<T>`, which is how every boundary is consumed today, and false of `IEnumerable<T>`: both registrations are still in the collection, so a consumer asking for all implementations would get the real one alongside the fake. The remarks now say so, because the sentence as it stood is exactly the kind a reader trusts while writing the code that breaks it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Nine commits, tip Findings1 + 2 — three commit objects where one suffices, and Done, as a single commit: they are one change. I had flagged the double amend myself when opening the PR and argued uniformity over a branch; I'm dropping that argument. 3 — mixed cached and fresh handles — Done, and one step further than proposed. The rule is a property of the type rather than of two of its members, so it now lives in the type's remarks: members that read state open a handle of their own, and the handle the class keeps is for members that change something. 4 — the fake Done; the best catch of the review. 5 — no bump-with-dogfooding case — Done, asserting on the rewritten files rather than on a recomputed version, as you suggested, so that the post-release commit moving the height stays out of the way. The case fails on the pre-fix code twice over: the release commit carries the wrong version, and the self-references keep the old one because discovery matched nothing. Smaller notesa — fixed path under the temp directory — Done, and I disagree with "close to a wash" in both directions. The argument for changing it is stronger than stated: b — c — the remote is recorded before it exists — d — no knob for push credentials — f — e — the harness constructor leaks if it throws — not done. The scenario it guards against is "the harness itself is broken", which surfaces as a failing test on the spot, and the environment variable it would leak is read by nothing outside these same tests. A catch-dispose-rethrow block buys little against that, and every reader of the file pays for it. Say so if you think the failure mode is worse than I read it. No changelog entry for any of the above: nothing here changes behaviour beyond log wording, and the two release bugs are already recorded. @claude please review again. |
|
Claude finished @rdeago's task in 4m 13s —— View job Re-review of #341 — nine follow-up commits (
|
The rule the previous commit wrote down had an edge: members that change the repository read it first, to find out what to change. CreateTag tags whatever the handle says HEAD points at, and Checkout, CheckoutDetached, Merge, and AddBareRemote resolve a branch or HEAD the same way. So "the kept handle is for members that change something" was a rule with an exception in it, and the exception is where the stale read would land - as a tag on the wrong commit, which reads like a bug in the code under test rather than in the harness. Keeping no handle at all leaves no exception to remember: every member opens one, disposes it, and sees the repository as the code under test left it. A handle per call costs nothing here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The single WithPushCredentials knob could only say "both" or "neither", which are not the only states an adapter can be in: GitLabServerAdapter returns a fixed protocol username and a null token, so the mixed shape is what a GitLab release runs with today, not something the platform/host split will invent later. The knob is now the two values themselves, and the warning case covers all three incomplete shapes. What that pins is the `&&` in ReleaseCommand: were it an `||`, a username alone would be taken for a credential and libgit2 would be handed a null password. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Two commits on top, tip The soft edge in the Taken, and by deletion rather than documentation. You are right that "members that change state read what they need through the kept handle" is the missing clause, but writing it down leaves a rule with an exception in it, and the exception is exactly where the stale read lands: a
Taken, and the case is stronger than "it gets interesting after the split". It is interesting now: The option is now the two values themselves, and the warning test runs all three incomplete shapes. What that pins is the The correction on note c — recorded, no commit. Agreed, and thanks for going back to it — the reorder traded one unreachable leak for another rather than closing anything. Making it total means a No new review requested: nothing here touches production code, and you'd already called the PR finished. |
Checklist of related issues / discussions
Proposed changes
ReleaseCommand.ExecuteAsyncnow runs end to end under test, over a real Git repository in a temporary directory, with only the boundaries faked: child processes (FakeProcessRunner), the hook's file-based app (FakeFileBasedAppRunner), and the server (a recordingServerAdapter). The service graph, the versioning, the changelog, the commits and the push are the real ones. Line coverage ofReleaseCommandgoes from 0/154; 36 tests were added.Composition.
Program.BuildServiceProviderwas private inside an[ExcludeFromCodeCoverage]class, so the graph every command runs on was unreachable from a test. Its registrations moved verbatim toServiceCollectionExtensions.AddBvServices();Programkeeps the five singletons a host decides for itself. The harness calls the same method and registers its fakes after it, where the last registration of a service type wins — so the composition under test is the real one, not a copy that drifts.Sequencing is asserted from effects, not from calls. Every recorded step carries the repository's state at the moment it happened, so "the release commit existed before the artifacts were packed" and "the branch was pushed before the packages were" are read off the repository. The fake server adapter carries no logic and its knobs are grouped by whether they will belong to the CI platform or to the Git host, so the planned split can cut it in two without rewriting what it answers.
Buildvana.Core.Testing.TempGitRepogained a pushable bare remote, tagging, committer identity, and history reporting. Two defects surfaced while building it, both fixed here: the default log sort is by timestamp, so commits made within the same second came back in arbitrary order (now topological); and pointing libgit2's configuration search paths per instance mutated a global underneath repositories other threads held, which crashed the test host with a corrupted heap (now a type initializer, once per process). The isolation also makes everyTempGitRepo-based test independent of the machine's global Git configuration, which previously decided whether a committer identity existed at all — laptop and CI runner took different code paths.Additional changes
The harness found three things the issue did not foresee. Each is its own commit.
bv releaserefused an impossible release only after building it. The preliminary checks — cloud build, branch, public-release branch, committer identity — and the version-spec computation ran after the verification pass, so a release that could never succeed cost a full clean, build and test cycle first. They now run before it. (ReleaseCommand)--bump minoron the 2.3 line tagged2.4.0-previewwhile the commit it tagged built as2.4.1-preview:UpdateRepositorystaged the version file into the release commit after the version had been computed from that commit, and the Git height is computed from committed content, so the new version line looked as if it had no committed history — height 0, which is not a height at all. The packages were built, attached and pushed as2.4.1-previewunder a2.4.0-previewtag, and produced-package discovery matched none of them, silently skipping the dogfood rewrites.NameReleaseCommitnow holds the rule in one place: the commit is made under a provisional message and named afterwards, and every method that changes its tree ends by calling it. (ServerRelease, CHANGELOG)VERSIONwas never committed does it without any bump. The final consistency check now rejects it, where the version becomes final and before anything is packed or tagged. Building such a state stays legitimate; only publishing is refused. (VersionService, CHANGELOG)Also: two pre-existing over-long lines in
Program.cs, wrapped in their own commit per the whole-file line-length rule.Types of changes
This pull request introduces the following types of changes:
docsdirectory) update.gitattributes,.gitignore)Breaking changes
This pull request introduces breaking changes:
Checklist