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
1013namespace DevCli . Commands ;
1114
12- [ NuruRoute ( "workflow" , Description = "Execute full CI workflow " ) ]
15+ [ NuruRoute ( "workflow" , Description = "Execute full CI/CD pipeline " ) ]
1316internal 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 ( "\n Workflow 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 ( "\n Workflow 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 ( $ "\n Published { PackageId } { version } to NuGet.org!". Green ( ) ) ;
69199 }
70200 }
71201}
202+
203+ internal enum CiMode
204+ {
205+ Pr ,
206+ Merge ,
207+ Release
208+ }
0 commit comments