1+ #!/usr/bin/env bun
2+
3+ /**
4+ * Script to convert GitHub Actions workflows from nuget.exe (with Mono) to dotnet CLI
5+ * Assumes workflows are in .github/workflows/*.yml or *.yaml
6+ * Targets nuget.exe push commands and replaces them with dotnet nuget push
7+ * Ensures actions/setup-dotnet is included for .NET SDK setup
8+ */
9+
10+ import { readFileSync , writeFileSync , existsSync , readdirSync , unlinkSync } from 'fs' ;
11+ import { join , dirname } from 'path' ;
12+ import { fileURLToPath } from 'url' ;
13+
14+ const __filename = fileURLToPath ( import . meta. url ) ;
15+ const __dirname = dirname ( __filename ) ;
16+
17+ // Exit on error
18+ process . on ( 'uncaughtException' , ( err ) => {
19+ console . error ( 'Error:' , err . message ) ;
20+ process . exit ( 1 ) ;
21+ } ) ;
22+
23+ // Directory containing GitHub Actions workflows
24+ const WORKFLOW_DIR = '.github/workflows' ;
25+
26+ // Check if workflow directory exists
27+ if ( ! existsSync ( WORKFLOW_DIR ) ) {
28+ console . error ( `Error: Workflow directory ${ WORKFLOW_DIR } not found. Please run this script from the repository root.` ) ;
29+ process . exit ( 1 ) ;
30+ }
31+
32+ // Find all .yml and .yaml files in the workflow directory
33+ const workflowFiles = readdirSync ( WORKFLOW_DIR )
34+ . filter ( file => file . endsWith ( '.yml' ) || file . endsWith ( '.yaml' ) )
35+ . map ( file => join ( WORKFLOW_DIR , file ) ) ;
36+
37+ if ( workflowFiles . length === 0 ) {
38+ console . error ( `Error: No workflow files (.yml or .yaml) found in ${ WORKFLOW_DIR } .` ) ;
39+ process . exit ( 1 ) ;
40+ }
41+
42+ /**
43+ * Simple YAML parser for basic validation
44+ * This is a basic implementation - in production you might want to use a proper YAML library
45+ */
46+ function parseYAML ( yamlString ) {
47+ const lines = yamlString . split ( '\n' ) ;
48+ const result = { } ;
49+ const stack = [ result ] ;
50+ const indentStack = [ 0 ] ;
51+
52+ for ( let i = 0 ; i < lines . length ; i ++ ) {
53+ const line = lines [ i ] ;
54+ const trimmedLine = line . trim ( ) ;
55+
56+ if ( trimmedLine === '' || trimmedLine . startsWith ( '#' ) ) {
57+ continue ;
58+ }
59+
60+ const indent = line . length - line . trimStart ( ) . length ;
61+
62+ // Find the appropriate level in the stack
63+ while ( indent <= indentStack [ indentStack . length - 1 ] && stack . length > 1 ) {
64+ stack . pop ( ) ;
65+ indentStack . pop ( ) ;
66+ }
67+
68+ if ( indent > indentStack [ indentStack . length - 1 ] ) {
69+ // New nested level
70+ const lastKey = Object . keys ( stack [ stack . length - 1 ] ) . pop ( ) ;
71+ if ( lastKey ) {
72+ stack [ stack . length - 1 ] [ lastKey ] = { } ;
73+ stack . push ( stack [ stack . length - 1 ] [ lastKey ] ) ;
74+ indentStack . push ( indent ) ;
75+ }
76+ }
77+
78+ // Parse key-value pair
79+ const colonIndex = trimmedLine . indexOf ( ':' ) ;
80+ if ( colonIndex !== - 1 ) {
81+ const key = trimmedLine . substring ( 0 , colonIndex ) . trim ( ) ;
82+ const value = trimmedLine . substring ( colonIndex + 1 ) . trim ( ) ;
83+
84+ if ( value === '' ) {
85+ // This is a key with nested content
86+ stack [ stack . length - 1 ] [ key ] = { } ;
87+ stack . push ( stack [ stack . length - 1 ] [ key ] ) ;
88+ indentStack . push ( indent ) ;
89+ } else {
90+ // This is a key-value pair
91+ stack [ stack . length - 1 ] [ key ] = value ;
92+ }
93+ }
94+ }
95+
96+ return result ;
97+ }
98+
99+ /**
100+ * Convert YAML back to string
101+ */
102+ function stringifyYAML ( obj , indent = 0 ) {
103+ const spaces = ' ' . repeat ( indent ) ;
104+ let result = '' ;
105+
106+ for ( const [ key , value ] of Object . entries ( obj ) ) {
107+ if ( typeof value === 'object' && value !== null && Object . keys ( value ) . length > 0 ) {
108+ result += `${ spaces } ${ key } :\n` ;
109+ result += stringifyYAML ( value , indent + 1 ) ;
110+ } else {
111+ result += `${ spaces } ${ key } : ${ value } \n` ;
112+ }
113+ }
114+
115+ return result ;
116+ }
117+
118+ /**
119+ * Check if actions/setup-dotnet is already included
120+ */
121+ function hasSetupDotnet ( content ) {
122+ return content . includes ( 'uses: actions/setup-dotnet@v' ) ;
123+ }
124+
125+ /**
126+ * Add actions/setup-dotnet step if missing
127+ */
128+ function addSetupDotnet ( content ) {
129+ const setupDotnetStep = ` - name: Setup .NET
130+ uses: actions/setup-dotnet@v4
131+ with:
132+ dotnet-version: '8.0.x'
133+ ` ;
134+
135+ // Find the first steps section and add setup-dotnet after it
136+ const stepsIndex = content . indexOf ( 'steps:' ) ;
137+ if ( stepsIndex !== - 1 ) {
138+ const beforeSteps = content . substring ( 0 , stepsIndex ) ;
139+ const afterSteps = content . substring ( stepsIndex ) ;
140+
141+ // Find the first step after 'steps:'
142+ const firstStepIndex = afterSteps . indexOf ( '\n -' ) ;
143+ if ( firstStepIndex !== - 1 ) {
144+ return beforeSteps + afterSteps . substring ( 0 , firstStepIndex ) + '\n' + setupDotnetStep + afterSteps . substring ( firstStepIndex ) ;
145+ } else {
146+ // No steps found, add after 'steps:'
147+ return beforeSteps + afterSteps + '\n' + setupDotnetStep ;
148+ }
149+ }
150+
151+ return content ;
152+ }
153+
154+ /**
155+ * Process a workflow file
156+ */
157+ async function processWorkflow ( filePath ) {
158+ console . log ( `Processing workflow file: ${ filePath } ` ) ;
159+
160+ // Read the file
161+ let content = readFileSync ( filePath , 'utf8' ) ;
162+ const originalContent = content ;
163+
164+ // Create backup
165+ writeFileSync ( `${ filePath } .bak` , content ) ;
166+
167+ // Replace nuget source Add with dotnet nuget add source
168+ content = content . replace (
169+ / n u g e t s o u r c e A d d - N a m e " G i t H u b " - S o u r c e " h t t p s : \/ \/ n u g e t \. p k g \. g i t h u b \. c o m \/ l i n k s p l a t f o r m \/ i n d e x \. j s o n " - U s e r N a m e l i n k s p l a t f o r m - P a s s w o r d \$ { { s e c r e t s \. G I T H U B _ T O K E N } } / g,
170+ 'dotnet nuget add source https://nuget.pkg.github.com/linksplatform/index.json --name GitHub --username linksplatform --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text'
171+ ) ;
172+
173+ // Replace nuget push with dotnet nuget push
174+ content = content . replace (
175+ / n u g e t p u s h ( [ ^ ] + ) - S o u r c e " ( [ ^ " ] + ) " - S k i p D u p l i c a t e / g,
176+ 'dotnet nuget push $1 --source $2 --skip-duplicate'
177+ ) ;
178+
179+ // Replace nuget.exe push with dotnet nuget push (more general pattern)
180+ content = content . replace (
181+ / r u n : \s * ( m o n o \s + ) ? \/ ? ( [ a - z A - Z 0 - 9 \/ . _ - ] * \/ ) ? n u g e t ( \. e x e ) ? \s + p u s h \s + ( [ ^ ] .* n u p k g ) ( \s + - - a p i - k e y \s + \$ \{ \{ [ ^ } ] + \} \} ) ? ( \s + - - s o u r c e \s + [ a - z A - Z 0 - 9 : \/ . _ - ] + ) ? / g,
182+ 'run: dotnet nuget push $4 --api-key $5 --source $6 --skip-duplicate'
183+ ) ;
184+
185+ // Replace nuget.exe restore with dotnet restore (if present)
186+ content = content . replace (
187+ / r u n : \s * ( m o n o \s + ) ? \/ ? ( [ a - z A - Z 0 - 9 \/ . _ - ] * \/ ) ? n u g e t ( \. e x e ) ? \s + r e s t o r e \s + ( [ ^ ] .* ) / g,
188+ 'run: dotnet restore $4'
189+ ) ;
190+
191+ // Remove nuget/setup-nuget@v1 action as it's no longer needed
192+ content = content . replace ( / - u s e s : n u g e t \/ s e t u p - n u g e t @ v 1 \n / g, '' ) ;
193+
194+ // Validate YAML syntax
195+ try {
196+ parseYAML ( content ) ;
197+ console . log ( '✅ YAML syntax validation passed' ) ;
198+ } catch ( error ) {
199+ console . error ( '❌ YAML syntax validation failed:' , error . message ) ;
200+ // Restore from backup
201+ content = readFileSync ( `${ filePath } .bak` , 'utf8' ) ;
202+ console . log ( 'Restored original content due to YAML syntax error' ) ;
203+ }
204+
205+ // Check for changes
206+ if ( content !== originalContent ) {
207+ console . log ( `Modified ${ filePath } to use dotnet CLI instead of nuget.exe` ) ;
208+ writeFileSync ( filePath , content ) ;
209+ } else {
210+ console . log ( `No nuget.exe commands found in ${ filePath } ; no changes made` ) ;
211+ // Remove backup if no changes
212+ try {
213+ unlinkSync ( `${ filePath } .bak` ) ;
214+ } catch ( error ) {
215+ // Backup file might not exist, ignore error
216+ }
217+ }
218+
219+ // Add actions/setup-dotnet if not present
220+ if ( ! hasSetupDotnet ( content ) ) {
221+ content = addSetupDotnet ( content ) ;
222+
223+ // Validate YAML syntax again after adding setup-dotnet
224+ try {
225+ parseYAML ( content ) ;
226+ console . log ( '✅ YAML syntax validation passed after adding setup-dotnet' ) ;
227+ writeFileSync ( filePath , content ) ;
228+ console . log ( `Added actions/setup-dotnet@v4 to ${ filePath } ` ) ;
229+ } catch ( error ) {
230+ console . error ( '❌ YAML syntax validation failed after adding setup-dotnet:' , error . message ) ;
231+ console . log ( 'Skipping setup-dotnet addition due to YAML syntax error' ) ;
232+ }
233+ }
234+ }
235+
236+ /**
237+ * Main function
238+ */
239+ async function main ( ) {
240+ console . log ( 'Starting nuget.exe to dotnet CLI conversion...\n' ) ;
241+
242+ // Process each workflow file
243+ for ( const file of workflowFiles ) {
244+ await processWorkflow ( file ) ;
245+ console . log ( '' ) ;
246+ }
247+
248+ // Clean up any remaining backup files
249+ console . log ( 'Cleaning up backup files...' ) ;
250+ for ( const file of workflowFiles ) {
251+ const backupFile = `${ file } .bak` ;
252+ if ( existsSync ( backupFile ) ) {
253+ try {
254+ unlinkSync ( backupFile ) ;
255+ } catch ( error ) {
256+ console . log ( `Warning: Could not remove backup file ${ backupFile } : ${ error . message } ` ) ;
257+ }
258+ }
259+ }
260+
261+ console . log ( '\n✅ Conversion complete! Please review changes in .github/workflows and test the updated workflows.' ) ;
262+ console . log ( 'If publishing to nuget.org, ensure NUGET_API_KEY is set in GitHub Secrets.' ) ;
263+ console . log ( 'If targeting a different NuGet feed (e.g., GitHub Packages), update the --source URL and authentication as needed.' ) ;
264+ }
265+
266+ // Run the script
267+ main ( ) . catch ( error => {
268+ console . error ( 'Script failed:' , error ) ;
269+ process . exit ( 1 ) ;
270+ } ) ;
0 commit comments