Skip to content

Commit 8a51c02

Browse files
JohnCampionJrclaude
andcommitted
Extend front-door delegation to status/publish; refresh docs and roadmap
- status and publish now delegate to the Node tool when there are no .NET projects (with autoRunNode), matching version; snapshot version passes its tag through when delegating. - The delegation loop guard is set on the Node child process only (via a new IProcessExecutor environment overload), never on net's own process, so it cannot leak between invocations. - docs/config-file-options.md rewritten for the dotnet-nested schema and all keys; ROADMAP.md updated (the GitHub Action already ships). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5c18bdd commit 8a51c02

13 files changed

Lines changed: 289 additions & 56 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"SolarWinds.Changesets": Minor
3+
---
4+
5+
Extend front-door delegation to every release command. With `autoRunNode`, `status` and `publish` now delegate to the Node tool in a repository with no .NET projects (matching `version`), and a snapshot `version --snapshot <tag>` passes its tag through to the Node tool when delegating. The delegation loop guard is now set on the Node child process only (never on net's own environment), so it cannot leak between invocations.

ROADMAP.md

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,27 @@
1-
# Roadmap
1+
# Roadmap
22

3-
**`net-changesets`** is currently in the **MVP stage**, meaning it includes the basic functionality to:
3+
**`net-changesets`** covers the full changesets workflow:
44

5-
- Create changesets
6-
- Bump versions and generates changelogs
7-
- Push packages to a specified NuGet source
5+
- Create changesets (`add`)
6+
- Bump versions and generate changelogs (`version`)
7+
- Show the pending release plan (`status`, `info`)
8+
- Enter/exit prerelease mode and cut snapshot releases (`pre`, `version --snapshot`)
9+
- Tag and publish packages to a NuGet source (`tag`, `publish`)
10+
- Interoperate with the Node [`@changesets`](https://github.com/changesets/changesets) tool in one
11+
repository (`interop`, `autoRunNode`)
812

9-
Unfortunately, these actions must currently be performed **manually** via the CLI tool `net-changeset`.
13+
## ✅ GitHub Action
1014

11-
Several features and improvements are planned for future releases.
15+
A composite GitHub Action ships in [`.github/actions/changesets`](.github/actions/changesets). It mirrors
16+
[`@changesets/action`](https://github.com/changesets/action): when changesets are pending it runs the version
17+
command and opens/updates a "Version Packages" pull request, and otherwise runs the publish command. The
18+
version/publish/status commands are configurable, and in interop mode the tool's own `autoRunNode` chains to
19+
the Node tool so a single action drives both ecosystems.
1220

13-
## 🚧 net-changesets GitHub Action
21+
## Planned features
1422

15-
The main missing component of net-changesets is the GitHub action that will automate the process
16-
of creating a pull request with updated package versions and changelogs based on the changesets in your repository.
17-
For reference, see the original implementation of the [changesets action](https://github.com/changesets/action).
23+
- **Changelog generators.** Beyond the default format, support the `changelog-git` (commit-hash prefix) and
24+
`changelog-github` (PR links, commit links, and author thanks) generators, selected via the `changelog`
25+
config key.
1826

19-
We can use **.NET** to develop a custom GitHub Action — see the official [documentation](https://learn.microsoft.com/en-us/dotnet/devops/create-dotnet-github-action).
20-
21-
The plan is to develop the GitHub Action **within this repository**, allowing us to reuse as much existing code as possible.
22-
23-
## Other features
24-
25-
While no specific features are planned at the moment, there is plenty of room for improving the existing functionality.
26-
If you have an idea or suggestion, feel free to [open a GitHub issue](./issues) to start a discussion.
27+
Have an idea or suggestion? [Open a GitHub issue](./issues) to start a discussion.

docs/config-file-options.md

Lines changed: 108 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,95 @@
1-
# Configuring Changesets
1+
# Configuring net-changesets
22

3-
Changesets has a minimal amount of configuration options. Mostly these are for when you need to change the default workflows. These are stored in `.changeset/config.json`. The default config is:
3+
Configuration lives in `.changeset/config.json`. net-changesets shares the [`@changesets`](https://github.com/changesets/changesets)
4+
config schema so a polyglot repository can keep a single file both tools understand: the standard changesets
5+
keys at the top level, and the .NET-only settings nested under a `dotnet` key (which the Node tool ignores).
6+
7+
`changeset init` writes a minimal config; every key below has a default, so you only set what you need.
48

59
```json
610
{
7-
"sourcePath": "src",
11+
"baseBranch": "main",
12+
"access": "restricted",
13+
"ignore": [],
14+
"fixed": [],
15+
"linked": [],
16+
"updateInternalDependencies": "patch",
17+
"format": false,
18+
"dotnet": {
19+
"sourcePath": ".",
820
"packageSource": "nuget"
21+
}
922
}
1023
```
1124

12-
## `sourcePath`
25+
## Shared keys
1326

14-
Specify the relative path from the changeset command's execution folder to the location of the projects.
27+
These mirror `@changesets`. net-changesets reads the ones below and ignores any others (such as `commit`,
28+
`changelog`, or `access` beyond what it needs), so a stock Node config loads cleanly.
1529

16-
The default value is `.` (the working directory): when `sourcePath` is not set, projects are discovered from the whole repository. The scan always skips `node_modules`, `.git`, `bin`, and `obj`.
30+
### `baseBranch`
1731

18-
## `packageSource`
32+
The branch releases are cut from, used by `status --since` and the release action. Default `main`.
1933

20-
Specify the NuGet package source where the packages will be published.
34+
### `updateInternalDependencies`
2135

22-
Default value is `nuget`. It is official [nuget.org](https://www.nuget.org/) repository.
36+
How a package that depends on a changed package is bumped: `patch` (default) or `minor`. Because a
37+
`ProjectReference` carries no version range, a changed dependency is always "out of range", so dependents are
38+
always **patch**-bumped to match `@changesets` — this option does not change that for .NET projects.
2339

24-
You can define NuGet package sources using `nuget.config`. To create a default `nuget.config`, use the following CLI command:
40+
### `fixed`
2541

26-
```bash
27-
dotnet new nugetconfig
42+
Groups of packages always released together at the same version when any member changes — including members
43+
that had no changes. An array of arrays of project names, e.g. `[["Acme.Core", "Acme.Client"]]`.
44+
45+
### `linked`
46+
47+
Groups of packages coordinated to the same version, but only the members that are actually being released.
48+
Same shape as `fixed`.
49+
50+
### `ignore`
51+
52+
Packages excluded from versioning. An array of project names or globs to skip, e.g. `["Acme.Internal", "Acme.Samples.*"]`.
53+
54+
### `format`
55+
56+
The formatter run over generated changelogs: a formatter name (`prettier`, `dprint`, `biome`, `oxfmt`, `deno`),
57+
`"auto"` to detect one from the repo, or `false` (the default) to skip formatting.
58+
59+
### `snapshot`
60+
61+
Snapshot-release settings, matching `@changesets`:
62+
63+
```json
64+
"snapshot": {
65+
"useCalculatedVersion": false,
66+
"prereleaseTemplate": "{tag}-{datetime}"
67+
}
2868
```
2969

30-
This allows you to specify your own NuGet package sources. For example, you can define the `.\NugetFolderSource` folder as a source.
70+
- `useCalculatedVersion` — when `true`, snapshot versions use the next calculated version as the base instead
71+
of `0.0.0`.
72+
- `prereleaseTemplate` — the suffix template, supporting `{tag}`, `{commit}`, `{datetime}`, and `{timestamp}`.
73+
When unset the suffix is `{tag}-{datetime}` (or just `{datetime}` with no tag).
74+
75+
## `dotnet` keys
76+
77+
The .NET-only settings, nested so the Node tool ignores them.
78+
79+
### `sourcePath`
3180

32-
Using folder sources is the best way to verify the output of your changesets setup!
81+
The relative path from the changeset command's execution folder to the location of your projects. Default
82+
`.` (the whole repository): when unset, projects are discovered from the working directory down. The scan
83+
always skips `node_modules`, `.git`, `bin`, and `obj`. Set it (for example `"src"`) to narrow the scan.
84+
85+
### `packageSource`
86+
87+
The NuGet package source `publish` pushes to. Default `nuget` (the official [nuget.org](https://www.nuget.org/)
88+
feed). Define your own sources in a `nuget.config`:
89+
90+
```bash
91+
dotnet new nugetconfig
92+
```
3393

3494
```xml
3595
<?xml version="1.0" encoding="utf-8"?>
@@ -41,3 +101,37 @@ Using folder sources is the best way to verify the output of your changesets set
41101
</packageSources>
42102
</configuration>
43103
```
104+
105+
Folder sources are the easiest way to verify your changesets setup end to end.
106+
107+
### `versionStrategy`
108+
109+
How a bump is written for projects whose version comes from a shared `Directory.Build.props`: `lockstep`
110+
(default) bumps the shared value so the group moves together, or `independent` writes an inline `<Version>`
111+
per project. The `version --independent` flag overrides this for a single run.
112+
113+
### `interop`
114+
115+
Run net-changesets alongside the Node `@changesets` tool in one `.changeset/` folder. Default `false` (net
116+
owns every `*.md` changeset and deletes them on `version`). When `true`, net also reads
117+
[`changesetExtension`](#changesetextension) files, only versions changesets that name .NET projects, and never
118+
deletes a Node changeset — a shared changeset keeps its Node remainder for the Node tool. See the
119+
["Using alongside @changesets" section of the README](../README.md#using-alongside-changesets-node).
120+
121+
### `changesetExtension`
122+
123+
The extension net-changesets owns when `interop` is on. Must not end in `.md` so the Node reader skips it.
124+
Default `.net.mkd` (editors still highlight it as markdown, and `.net.` marks a .NET changeset).
125+
126+
### `autoRunNode`
127+
128+
Make net-changesets a front door to the Node tool. Default `false`. When `true`, a single `changeset version`
129+
versions the .NET side and then runs the Node tool ([`nodeChangesetCommand`](#nodechangesetcommand)); in a
130+
repository with no .NET projects it delegates to the Node tool entirely. Do not point `nodeChangesetCommand`
131+
back at net (use `npx changeset`); net guards against an accidental loop, but a distinct command is clearer.
132+
133+
### `nodeChangesetCommand`
134+
135+
The command used to invoke the Node tool when `autoRunNode` is on. Default `npx changeset`. Set it to
136+
`pnpm changeset`, `yarn changeset`, or any custom invocation. The subcommand (for example `version`) is
137+
appended.

src/SolarWinds.Changesets/Commands/Publish/PublishChangesetCommand.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ public override async Task<int> ExecuteCommandAsync(CommandContext context, Publ
5151
_console.WriteLine();
5252

5353
CsProject[] csProjects = _csProjectsRepository.GetCsProjects(ChangesetConfig);
54+
55+
// Front door: no .NET projects to publish, so the publish is the Node tool's.
56+
if (csProjects.Length == 0 && ChangesetConfig.AutoRunNode && NodeDelegation.NotAlreadyDelegated)
57+
{
58+
_console.WriteLine("No .NET projects found; delegating to the Node changeset tool.");
59+
return await RunNodePublishAsync();
60+
}
61+
5462
List<CsProject> projectsToPublish = await GetProjectsToPublishAsync(csProjects);
5563

5664
if (projectsToPublish.Count == 0)
@@ -101,7 +109,7 @@ await PackageTagger.CreateMissingTagsAsync(
101109

102110
_console.MarkupLine("[green]Changeset publish finished.[/]");
103111

104-
if (ChangesetConfig.Interop && ChangesetConfig.AutoRunNode)
112+
if (ChangesetConfig.Interop && ChangesetConfig.AutoRunNode && NodeDelegation.NotAlreadyDelegated)
105113
{
106114
return await RunNodePublishAsync();
107115
}

src/SolarWinds.Changesets/Commands/Status/StatusChangesetCommand.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,13 @@ public override async Task<int> ExecuteCommandAsync(CommandContext context, Stat
9999
}
100100

101101
CsProject[] csProjects = _csProjectsRepository.GetCsProjects(ChangesetConfig);
102+
103+
// Front door: no .NET projects, so the status is the Node tool's to report.
104+
if (csProjects.Length == 0 && ChangesetConfig.AutoRunNode && NodeDelegation.NotAlreadyDelegated)
105+
{
106+
return await RunNodeStatusAsync();
107+
}
108+
102109
if (ChangesetConfig.VersionStrategy == VersionStrategy.Independent)
103110
{
104111
csProjects = CsProjectStrategy.ToIndependent(csProjects);
@@ -117,7 +124,7 @@ public override async Task<int> ExecuteCommandAsync(CommandContext context, Stat
117124
WritePackagesToBeBumped(changelogs, nodeBumps, changesetsByModule, settings.Verbose);
118125
}
119126

120-
if (ChangesetConfig.Interop && ChangesetConfig.AutoRunNode)
127+
if (ChangesetConfig.Interop && ChangesetConfig.AutoRunNode && NodeDelegation.NotAlreadyDelegated)
121128
{
122129
return await RunNodeStatusAsync();
123130
}

src/SolarWinds.Changesets/Commands/Version/Helpers/NodeChangesetService.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ public async Task<ProcessOutput> RunAsync(string command, string subcommand, str
2424
string executable = parts[0];
2525
string arguments = string.Join(' ', parts.Skip(1).Append(subcommand));
2626

27-
return await _processExecutor.Execute(executable, arguments, workingDirectoryPath);
27+
// Mark the child as delegated-to, so a nodeChangesetCommand that resolves back to net is caught on the
28+
// next hop instead of recursing forever. Set on the child only - never on net's own environment.
29+
Dictionary<string, string> environment = new(StringComparer.Ordinal) { [NodeDelegation.GuardVariable] = "1" };
30+
31+
return await _processExecutor.Execute(executable, arguments, workingDirectoryPath, environment);
2832
}
2933
}

src/SolarWinds.Changesets/Commands/Version/VersionChangesetCommand.cs

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,12 @@ public override async Task<int> ExecuteCommandAsync(CommandContext context, Vers
9494
// delegate to the Node tool when autoRunNode is set; otherwise guide the user and leave changesets be.
9595
if (csProjects.Length == 0)
9696
{
97-
if (ChangesetConfig.AutoRunNode && NotAlreadyDelegated && !snapshot && !inPreMode && !exiting)
97+
if (ChangesetConfig.AutoRunNode && NodeDelegation.NotAlreadyDelegated)
9898
{
99+
// Front door: nothing .NET to do, so the whole release is the Node tool's. A snapshot run
100+
// passes its tag through; prerelease mode needs no flag (the Node tool reads its own pre.json).
99101
_console.WriteLine("No .NET projects found; delegating to the Node changeset tool.");
100-
return await RunNodeVersionAsync();
102+
return await RunNodeVersionAsync(BuildNodeVersionSubcommand(settings, snapshot));
101103
}
102104

103105
WarnNoDotnetProjects();
@@ -183,20 +185,26 @@ await _changesetsRepository.CleanupProcessedChangesetsAsync(
183185

184186
_console.WriteLine(message);
185187

186-
if (ChangesetConfig.Interop && ChangesetConfig.AutoRunNode && NotAlreadyDelegated && !snapshot && !inPreMode && !exiting)
188+
if (ChangesetConfig.Interop && ChangesetConfig.AutoRunNode && NodeDelegation.NotAlreadyDelegated && !snapshot && !inPreMode && !exiting)
187189
{
188-
return await RunNodeVersionAsync();
190+
return await RunNodeVersionAsync("version");
189191
}
190192

191193
return 0;
192194
}
193195

194-
// Set on the child environment when net delegates to (or auto-runs) the Node tool, so that a
195-
// nodeChangesetCommand mistakenly pointing back at net is caught instead of recursing forever.
196-
private const string DelegationGuardVariable = "NET_CHANGESETS_DELEGATED";
196+
// The Node `version` subcommand, carrying a snapshot tag through when this is a snapshot run.
197+
private static string BuildNodeVersionSubcommand(VersionChangesetCommandSettings settings, bool snapshot)
198+
{
199+
if (!snapshot)
200+
{
201+
return "version";
202+
}
197203

198-
private static bool NotAlreadyDelegated =>
199-
Environment.GetEnvironmentVariable(DelegationGuardVariable) is not "1";
204+
return settings.Snapshot.Value is { Length: > 0 } tag
205+
? $"version --snapshot {tag}"
206+
: "version --snapshot";
207+
}
200208

201209
private async Task ApplySnapshotVersionsAsync(List<ModuleChangelog> changelogs, VersionChangesetCommandSettings settings)
202210
{
@@ -315,15 +323,12 @@ private void DeleteOwnedChangesets(ChangesetFile[] changesets, CsProject[] csPro
315323
_changesetsRepository.DeleteChangesets(Constants.ChangesetDirectoryFullPath, ownedIds);
316324
}
317325

318-
private async Task<int> RunNodeVersionAsync()
326+
private async Task<int> RunNodeVersionAsync(string subcommand)
319327
{
320-
// Inherited by the Node process; breaks the loop if nodeChangesetCommand resolves back to net.
321-
Environment.SetEnvironmentVariable(DelegationGuardVariable, "1");
322-
323-
_console.WriteLine($"Running '{ChangesetConfig.NodeChangesetCommand} version' for the Node packages...");
328+
_console.WriteLine($"Running '{ChangesetConfig.NodeChangesetCommand} {subcommand}' for the Node packages...");
324329

325330
ProcessOutput result = await _nodeChangesetService.RunAsync(
326-
ChangesetConfig.NodeChangesetCommand, "version", Constants.WorkingDirectoryFullPath);
331+
ChangesetConfig.NodeChangesetCommand, subcommand, Constants.WorkingDirectoryFullPath);
327332

328333
foreach (string line in result.Output)
329334
{

src/SolarWinds.Changesets/Shared/IProcessExecutor.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,16 @@ internal interface IProcessExecutor
1717
/// Thrown if the process cannot be started with the specified parameters.
1818
/// </exception>
1919
Task<ProcessOutput> Execute(string executable, string arguments, string workingDirectoryPath);
20+
21+
/// <summary>
22+
/// As <see cref="Execute(string, string, string)"/>, additionally setting environment variables on the
23+
/// child process (on top of the inherited environment).
24+
/// </summary>
25+
/// <param name="executable">The path to the executable to run.</param>
26+
/// <param name="arguments">The arguments to pass to the executable.</param>
27+
/// <param name="workingDirectoryPath">The working directory for the process.</param>
28+
/// <param name="environment">Environment variables to set on the child process.</param>
29+
/// <returns>A <see cref="ProcessOutput"/> object containing the output and exit code of the process.</returns>
30+
Task<ProcessOutput> Execute(
31+
string executable, string arguments, string workingDirectoryPath, IReadOnlyDictionary<string, string>? environment);
2032
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace SolarWinds.Changesets.Shared;
2+
3+
/// <summary>
4+
/// Tracks whether net-changesets has already delegated to (or auto-run) the Node tool in this process tree.
5+
/// The marker is an environment variable, inherited by child processes, so a <c>nodeChangesetCommand</c> that
6+
/// mistakenly resolves back to net is caught on the second hop instead of recursing forever.
7+
/// </summary>
8+
internal static class NodeDelegation
9+
{
10+
/// <summary>The marker set on the Node child process so it does not delegate back to net.</summary>
11+
public const string GuardVariable = "NET_CHANGESETS_DELEGATED";
12+
13+
/// <summary>True when net has not already delegated to the Node tool higher up the process tree.</summary>
14+
public static bool NotAlreadyDelegated => Environment.GetEnvironmentVariable(GuardVariable) is not "1";
15+
}

src/SolarWinds.Changesets/Shared/ProcessExecutor.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ namespace SolarWinds.Changesets.Shared;
66
internal class ProcessExecutor : IProcessExecutor
77
{
88
/// <inheritdoc />
9-
public async Task<ProcessOutput> Execute(string executable, string arguments, string workingDirectoryPath)
9+
public Task<ProcessOutput> Execute(string executable, string arguments, string workingDirectoryPath) =>
10+
Execute(executable, arguments, workingDirectoryPath, environment: null);
11+
12+
/// <inheritdoc />
13+
public async Task<ProcessOutput> Execute(
14+
string executable, string arguments, string workingDirectoryPath, IReadOnlyDictionary<string, string>? environment)
1015
{
1116
var startInfo = new ProcessStartInfo
1217
{
@@ -19,6 +24,14 @@ public async Task<ProcessOutput> Execute(string executable, string arguments, st
1924
CreateNoWindow = true
2025
};
2126

27+
if (environment is not null)
28+
{
29+
foreach ((string key, string value) in environment)
30+
{
31+
startInfo.Environment[key] = value;
32+
}
33+
}
34+
2235
List<string> output = [];
2336

2437
using Process process = new() { StartInfo = startInfo };

0 commit comments

Comments
 (0)