Skip to content

Commit a7b7b56

Browse files
StevenTCramerclaude
andcommitted
feat: drive CI/CD pipeline through dev-cli workflow command
Replace the build/test-only GitHub workflow with the timewarp-nuru pattern: a single `dev workflow` invocation that auto-detects mode from GITHUB_EVENT_NAME. PR/merge runs clean -> build -> test; release runs check-version -> clean -> build -> push to NuGet. - workflow-command.cs: add mode detection and --api-key option, invoke sibling handlers in-process (no self-install needed in CI), verify release tag matches Version, push the nupkg from artifacts/packages - workflow.yml: restore release/workflow_dispatch triggers and path filters from ci-cd.yml.bak, pass PUBLISH_TO_NUGET_ORG on releases, upload artifacts with if: always() - Directory.Build.props: rename PackageVersion to Version so ganda repo check-version can read it (org convention; PackageVersion is derived from Version so the nupkg is unchanged) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 26b800b commit a7b7b56

4 files changed

Lines changed: 203 additions & 28 deletions

File tree

.github/workflows/workflow.yml

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,56 @@ name: CI/CD
22

33
on:
44
push:
5-
branches: [ master, main ]
5+
branches:
6+
- master
7+
paths:
8+
- 'source/**'
9+
- 'tests/**'
10+
- 'tools/**'
11+
- '.github/workflows/**'
12+
- 'Directory.Build.props'
13+
- 'Directory.Packages.props'
614
pull_request:
7-
branches: [ master, main ]
15+
branches:
16+
- master
17+
paths:
18+
- 'source/**'
19+
- 'tests/**'
20+
- 'tools/**'
21+
- '.github/workflows/**'
22+
- 'Directory.Build.props'
23+
- 'Directory.Packages.props'
24+
release:
25+
types: [published] # Triggered when a release is published via GitHub Releases UI or gh CLI
26+
workflow_dispatch:
827

928
jobs:
10-
build:
29+
ci:
1130
runs-on: ubuntu-latest
31+
1232
steps:
13-
- uses: actions/checkout@v4
14-
- uses: actions/setup-dotnet@v4
33+
- name: Checkout repository
34+
uses: actions/checkout@v4
35+
with:
36+
fetch-depth: 0
37+
38+
- name: Setup .NET
39+
uses: actions/setup-dotnet@v4
1540
with:
1641
dotnet-version: '10.0.x'
17-
- name: Build
18-
run: dotnet run tools/dev-cli/dev.cs -- build
19-
- name: Test
20-
run: dotnet run tools/dev-cli/dev.cs -- test
42+
43+
- name: Run CI Pipeline
44+
run: |
45+
if [ "${{ github.event_name }}" == "release" ] || [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
46+
dotnet run --file tools/dev-cli/dev.cs -- workflow --api-key "${{ secrets.PUBLISH_TO_NUGET_ORG }}"
47+
else
48+
dotnet run --file tools/dev-cli/dev.cs -- workflow
49+
fi
50+
51+
- name: Upload Artifacts
52+
if: always()
53+
uses: actions/upload-artifact@v4
54+
with:
55+
name: Packages-${{ github.run_number }}
56+
path: artifacts/packages/*.nupkg
57+
if-no-files-found: ignore

source/Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
<!-- Default package metadata (can be overridden in individual projects) -->
66
<PropertyGroup Label="Package Metadata">
7-
<PackageVersion>1.0.0-beta.4</PackageVersion>
7+
<Version>1.0.0-beta.4</Version>
88
<Authors>Steven T. Cramer</Authors>
99
<Company>TimeWarp Enterprises</Company>
1010
<Product>TimeWarp.OptionsValidation</Product>
Lines changed: 155 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,34 @@
11
#region Purpose
2-
// Executes the full CI workflow
2+
// Executes the full CI/CD pipeline with mode detection
33
#endregion
44
#region Design
5-
// Runs clean, build, test sequentially by invoking ./bin/dev subcommands
6-
// Handler stores RepoRoot and DevBin as fields so private methods are zero-parameter
7-
// RunStepAsync DRYs up the identical shell-invoke/exit-code-check pattern
5+
// Auto-detects mode from GITHUB_EVENT_NAME or accepts explicit --mode flag
6+
// pr/merge: clean -> build -> test
7+
// release: check-version -> clean -> build -> push (build packs via GeneratePackageOnBuild)
8+
// Invokes sibling command handlers in-process so CI needs no self-install
9+
// Release version comes from Version in source/Directory.Build.props and
10+
// must match the git tag (GITHUB_REF_NAME) on release events
811
#endregion
912

1013
namespace DevCli.Commands;
1114

12-
[NuruRoute("workflow", Description = "Execute full CI workflow")]
15+
[NuruRoute("workflow", Description = "Execute full CI/CD pipeline")]
1316
internal sealed class WorkflowCommand : ICommand<Unit>
1417
{
18+
[Option("mode", "m", Description = "CI mode: pr, merge, or release (auto-detected from GITHUB_EVENT_NAME if not specified)")]
19+
public string? Mode { get; set; }
20+
21+
[Option("api-key", Description = "NuGet API key for publishing (release mode only)")]
22+
public string? ApiKey { get; set; }
23+
1524
internal sealed class Handler : ICommandHandler<WorkflowCommand, Unit>
1625
{
26+
private const string PackageId = "TimeWarp.OptionsValidation";
27+
private const string NuGetSource = "https://api.nuget.org/v3/index.json";
28+
1729
private readonly ITerminal Terminal;
1830
private CancellationToken Ct;
1931
private string RepoRoot = null!;
20-
private string DevBin = null!;
2132

2233
public Handler(ITerminal terminal)
2334
{
@@ -29,11 +40,22 @@ public async ValueTask<Unit> Handle(WorkflowCommand command, CancellationToken c
2940
Ct = ct;
3041

3142
if (!FindRepoRoot()) return Value;
32-
if (!await RunStepAsync("clean", "Clean failed!")) return Value;
33-
if (!await RunStepAsync("build", "Build failed!")) return Value;
34-
if (!await RunStepAsync("test", "Tests failed!")) return Value;
3543

36-
Terminal.WriteLine("\nWorkflow completed successfully!".Green());
44+
CiMode mode = DetermineMode(command.Mode);
45+
Terminal.WriteLine($"Starting CI workflow (mode: {mode})...");
46+
47+
if (mode == CiMode.Release)
48+
{
49+
await RunReleaseWorkflowAsync(command.ApiKey);
50+
}
51+
else
52+
{
53+
await RunPrWorkflowAsync();
54+
}
55+
56+
if (Environment.ExitCode == 0)
57+
Terminal.WriteLine("\nWorkflow completed successfully!".Green());
58+
3759
return Value;
3860
}
3961

@@ -47,25 +69,140 @@ private bool FindRepoRoot()
4769
return false;
4870
}
4971
RepoRoot = root;
50-
DevBin = Path.Combine(RepoRoot, "bin", "dev");
51-
Terminal.WriteLine("Starting CI workflow...");
5272
return true;
5373
}
5474

55-
private async Task<bool> RunStepAsync(string subcommand, string failureMessage)
75+
private CiMode DetermineMode(string? explicitMode)
76+
{
77+
if (!string.IsNullOrEmpty(explicitMode))
78+
{
79+
return explicitMode.ToLowerInvariant() switch
80+
{
81+
"release" => CiMode.Release,
82+
"merge" => CiMode.Merge,
83+
_ => CiMode.Pr
84+
};
85+
}
86+
87+
string? eventName = Environment.GetEnvironmentVariable("GITHUB_EVENT_NAME");
88+
89+
CiMode mode = eventName switch
90+
{
91+
"push" => CiMode.Merge,
92+
"release" => CiMode.Release,
93+
"workflow_dispatch" => CiMode.Release,
94+
_ => CiMode.Pr // pull_request and local dev
95+
};
96+
97+
Terminal.WriteLine($"Detected GITHUB_EVENT_NAME: {eventName ?? "(not set)"} -> Mode: {mode}");
98+
return mode;
99+
}
100+
101+
private async Task RunPrWorkflowAsync()
102+
{
103+
if (!await RunStepAsync("Clean", () => new CleanCommand.Handler(Terminal).Handle(new CleanCommand(), Ct))) return;
104+
if (!await RunStepAsync("Build", () => new BuildCommand.Handler(Terminal).Handle(new BuildCommand(), Ct))) return;
105+
await RunStepAsync("Test", () => new TestCommand.Handler(Terminal).Handle(new TestCommand(), Ct));
106+
}
107+
108+
private async Task RunReleaseWorkflowAsync(string? apiKey)
56109
{
57-
int exitCode = await Shell.Builder(DevBin)
58-
.WithArguments(subcommand)
110+
string? version = ReadVersion();
111+
if (version is null)
112+
{
113+
Terminal.WriteErrorLine("Error: could not read Version from source/Directory.Build.props.".Red());
114+
Environment.ExitCode = 1;
115+
return;
116+
}
117+
118+
if (!CheckVersionMatchesTag(version)) return;
119+
if (!await RunStepAsync("Clean", () => new CleanCommand.Handler(Terminal).Handle(new CleanCommand(), Ct))) return;
120+
if (!await RunStepAsync("Build", () => new BuildCommand.Handler(Terminal).Handle(new BuildCommand(), Ct))) return;
121+
await PushPackageAsync(version, apiKey);
122+
}
123+
124+
private async Task<bool> RunStepAsync(string title, Func<ValueTask<Unit>> step)
125+
{
126+
Terminal.WriteLine($"\n=== {title} ===");
127+
await step();
128+
129+
if (Environment.ExitCode != 0)
130+
{
131+
Terminal.WriteErrorLine($"{title} failed!".Red());
132+
return false;
133+
}
134+
return true;
135+
}
136+
137+
private string? ReadVersion()
138+
{
139+
string propsPath = Path.Combine(RepoRoot, "source", "Directory.Build.props");
140+
if (!File.Exists(propsPath)) return null;
141+
142+
Match match = Regex.Match(File.ReadAllText(propsPath), "<Version>(.+?)</Version>");
143+
return match.Success ? match.Groups[1].Value : null;
144+
}
145+
146+
private bool CheckVersionMatchesTag(string version)
147+
{
148+
// Only enforce on tag-triggered releases; workflow_dispatch has no tag
149+
if (Environment.GetEnvironmentVariable("GITHUB_EVENT_NAME") != "release") return true;
150+
151+
string? tag = Environment.GetEnvironmentVariable("GITHUB_REF_NAME");
152+
string? tagVersion = tag?.TrimStart('v');
153+
154+
if (tagVersion != version)
155+
{
156+
Terminal.WriteErrorLine($"Error: release tag '{tag}' does not match Version '{version}'.".Red());
157+
Environment.ExitCode = 1;
158+
return false;
159+
}
160+
161+
Terminal.WriteLine($"Release tag '{tag}' matches Version '{version}'.");
162+
return true;
163+
}
164+
165+
private async Task PushPackageAsync(string version, string? apiKey)
166+
{
167+
Terminal.WriteLine("\n=== Push to NuGet ===");
168+
169+
string nupkgPath = Path.Combine(RepoRoot, "artifacts", "packages", $"{PackageId}.{version}.nupkg");
170+
if (!File.Exists(nupkgPath))
171+
{
172+
Terminal.WriteErrorLine($"Error: package not found: {nupkgPath}".Red());
173+
Environment.ExitCode = 1;
174+
return;
175+
}
176+
177+
Terminal.WriteLine($"Pushing {PackageId}.{version}.nupkg...");
178+
179+
List<string> args = ["nuget", "push", nupkgPath, "--source", NuGetSource, "--skip-duplicate"];
180+
if (!string.IsNullOrEmpty(apiKey))
181+
{
182+
args.AddRange(["--api-key", apiKey]);
183+
}
184+
185+
int exitCode = await Shell.Builder("dotnet")
186+
.WithArguments([.. args])
187+
.WithWorkingDirectory(RepoRoot)
59188
.WithNoValidation()
60189
.RunAsync(Ct);
61190

62191
if (exitCode != 0)
63192
{
64-
Terminal.WriteErrorLine(failureMessage.Red());
193+
Terminal.WriteErrorLine("Push failed!".Red());
65194
Environment.ExitCode = exitCode;
66-
return false;
195+
return;
67196
}
68-
return true;
197+
198+
Terminal.WriteLine($"\nPublished {PackageId} {version} to NuGet.org!".Green());
69199
}
70200
}
71201
}
202+
203+
internal enum CiMode
204+
{
205+
Pr,
206+
Merge,
207+
Release
208+
}

tools/dev-cli/global-usings.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
global using System.Diagnostics;
1212
global using System.Runtime.CompilerServices;
1313
global using System.Runtime.InteropServices;
14+
global using System.Text.RegularExpressions;
1415

1516
global using TimeWarp.Nuru;
1617
global using static TimeWarp.Nuru.Unit;

0 commit comments

Comments
 (0)