cistack 3.2.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/tests/run.js ADDED
@@ -0,0 +1,934 @@
1
+ 'use strict';
2
+
3
+ const assert = require('assert');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { execFileSync } = require('child_process');
8
+ const yaml = require('js-yaml');
9
+
10
+ const CodebaseAnalyzer = require('../src/analyzers/codebase');
11
+ const ConfigLoader = require('../src/config/loader');
12
+ const DependabotGenerator = require('../src/generators/dependabot');
13
+ const FrameworkDetector = require('../src/detectors/framework');
14
+ const HostingDetector = require('../src/detectors/hosting');
15
+ const LanguageDetector = require('../src/detectors/language');
16
+ const ReleaseDetector = require('../src/detectors/release');
17
+ const TestingDetector = require('../src/detectors/testing');
18
+ const WorkflowGenerator = require('../src/generators/workflow');
19
+ const ReleaseGenerator = require('../src/generators/release');
20
+ const combineWorkflows = require('../src/utils/workflow-combiner');
21
+ const { smartMergeWorkflow } = require('../src/utils/helpers');
22
+
23
+ const repoRoot = path.resolve(__dirname, '..');
24
+ const tempDirs = [];
25
+ const tests = [];
26
+
27
+ function test(name, fn) {
28
+ tests.push({ name, fn });
29
+ }
30
+
31
+ function json(value) {
32
+ return JSON.stringify(value, null, 2);
33
+ }
34
+
35
+ function makeTempDir(prefix = 'cistack-test-') {
36
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
37
+ tempDirs.push(dir);
38
+ return dir;
39
+ }
40
+
41
+ function writeFiles(root, files) {
42
+ for (const [relativePath, content] of Object.entries(files)) {
43
+ const fullPath = path.join(root, relativePath);
44
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
45
+ fs.writeFileSync(fullPath, content, 'utf8');
46
+ }
47
+ }
48
+
49
+ function stripHeader(content) {
50
+ return content.replace(/^(?:#[^\n]*\n)+\n?/, '');
51
+ }
52
+
53
+ function parseWorkflow(content) {
54
+ return yaml.load(stripHeader(content));
55
+ }
56
+
57
+ function runGit(cwd, args) {
58
+ return execFileSync('git', args, {
59
+ cwd,
60
+ encoding: 'utf8',
61
+ stdio: ['ignore', 'pipe', 'pipe'],
62
+ }).trim();
63
+ }
64
+
65
+ function makeJsProject(extra = {}) {
66
+ return {
67
+ hosting: extra.hosting || [],
68
+ frameworks: extra.frameworks || [],
69
+ languages: extra.languages || [{ name: 'JavaScript', packageManager: 'npm', nodeVersion: '20' }],
70
+ testing: extra.testing || [],
71
+ envVars: extra.envVars || { secrets: [], public: [], all: [], sourceFile: null },
72
+ monorepoPackages: extra.monorepoPackages || [],
73
+ lockFiles: extra.lockFiles || [],
74
+ defaultBranch: extra.defaultBranch || null,
75
+ currentBranch: extra.currentBranch || null,
76
+ _config: extra._config || {},
77
+ };
78
+ }
79
+
80
+ test('ConfigLoader applies testing overrides using the overridden package manager', () => {
81
+ const result = ConfigLoader.applyToStack(
82
+ { packageManager: 'pnpm', testing: ['Vitest'] },
83
+ {
84
+ hosting: [],
85
+ frameworks: [],
86
+ languages: [{ name: 'JavaScript', packageManager: 'npm', nodeVersion: '20' }],
87
+ testing: [],
88
+ envVars: { secrets: [], public: [], all: [], sourceFile: null },
89
+ monorepoPackages: [],
90
+ }
91
+ );
92
+
93
+ assert.equal(result.testing[0].command, 'pnpm run test');
94
+ assert.equal(result.languages[0].packageManager, 'pnpm');
95
+ });
96
+
97
+ test('ConfigLoader merges release overrides with detected release info and documents extra secrets', () => {
98
+ const result = ConfigLoader.applyToStack(
99
+ {
100
+ release: 'semantic-release',
101
+ secrets: ['MY_EXTRA_SECRET'],
102
+ },
103
+ {
104
+ hosting: [],
105
+ frameworks: [],
106
+ languages: [{ name: 'JavaScript', packageManager: 'npm', nodeVersion: '20' }],
107
+ testing: [],
108
+ releaseInfo: {
109
+ tool: 'semantic-release',
110
+ publishToNpm: true,
111
+ requiresNpmToken: true,
112
+ },
113
+ envVars: { secrets: ['BASE_SECRET'], public: [], all: [], sourceFile: null },
114
+ monorepoPackages: [],
115
+ }
116
+ );
117
+
118
+ assert.deepEqual(result.releaseInfo, {
119
+ tool: 'semantic-release',
120
+ publishToNpm: true,
121
+ requiresNpmToken: true,
122
+ });
123
+ assert.deepEqual(result.envVars.secrets.sort(), ['BASE_SECRET', 'MY_EXTRA_SECRET'].sort());
124
+ });
125
+
126
+ test('LanguageDetector treats Kotlin projects as Gradle-based JVM builds', async () => {
127
+ const projectDir = makeTempDir();
128
+ writeFiles(projectDir, {
129
+ 'build.gradle.kts': "plugins { kotlin(\"jvm\") version \"1.9.0\" }\n",
130
+ 'src/main/kotlin/App.kt': 'fun main() = println("hi")\n',
131
+ });
132
+
133
+ const info = await new CodebaseAnalyzer(projectDir).analyse();
134
+ const languages = await new LanguageDetector(projectDir, info).detect();
135
+ const kotlin = languages.find((lang) => lang.name === 'Kotlin');
136
+
137
+ assert(kotlin);
138
+ assert.equal(kotlin.packageManager, 'gradle');
139
+ });
140
+
141
+ test('FrameworkDetector detects Spring Boot from build.gradle.kts', async () => {
142
+ const projectDir = makeTempDir();
143
+ writeFiles(projectDir, {
144
+ 'build.gradle.kts': "plugins { id(\"org.springframework.boot\") version \"3.3.0\" }\n",
145
+ 'src/main/kotlin/App.kt': 'fun main() = println("hi")\n',
146
+ });
147
+
148
+ const info = await new CodebaseAnalyzer(projectDir).analyse();
149
+ const frameworks = await new FrameworkDetector(projectDir, info).detect();
150
+
151
+ assert(frameworks.some((framework) => framework.name === 'Spring Boot'));
152
+ });
153
+
154
+ test('HostingDetector recognizes Azure pipelines in azure/pipelines.yml', async () => {
155
+ const projectDir = makeTempDir();
156
+ writeFiles(projectDir, {
157
+ 'azure/pipelines.yml': 'trigger:\n- main\n',
158
+ });
159
+
160
+ const info = await new CodebaseAnalyzer(projectDir).analyse();
161
+ const hosting = await new HostingDetector(projectDir, info).detect();
162
+
163
+ assert(hosting.some((provider) => provider.name === 'Azure'));
164
+ });
165
+
166
+ test('Docker detection and Dependabot both honor Dockerfile.prod', async () => {
167
+ const projectDir = makeTempDir();
168
+ writeFiles(projectDir, {
169
+ 'Dockerfile.prod': 'FROM node:20-alpine\n',
170
+ });
171
+
172
+ const info = await new CodebaseAnalyzer(projectDir).analyse();
173
+ const hosting = await new HostingDetector(projectDir, info).detect();
174
+ const dependabot = parseWorkflow(new DependabotGenerator(info).generate().content);
175
+
176
+ assert(hosting.some((provider) => provider.name === 'Docker'));
177
+ assert(dependabot.updates.some((entry) => entry['package-ecosystem'] === 'docker'));
178
+ });
179
+
180
+ test('Dependabot skips empty npm manifests and uses the bun ecosystem for bun.lock', async () => {
181
+ const emptyJsProject = makeTempDir();
182
+ writeFiles(emptyJsProject, {
183
+ 'package.json': json({
184
+ name: 'empty-js-app',
185
+ version: '1.0.0',
186
+ dependencies: {},
187
+ devDependencies: {},
188
+ }),
189
+ });
190
+
191
+ const emptyInfo = await new CodebaseAnalyzer(emptyJsProject).analyse();
192
+ const emptyDependabot = parseWorkflow(new DependabotGenerator(emptyInfo).generate().content);
193
+ assert.deepEqual(
194
+ emptyDependabot.updates.map((entry) => entry['package-ecosystem']),
195
+ ['github-actions']
196
+ );
197
+
198
+ const bunProject = makeTempDir();
199
+ writeFiles(bunProject, {
200
+ 'package.json': json({
201
+ name: 'bun-app',
202
+ version: '1.0.0',
203
+ dependencies: {
204
+ react: '^18.3.1',
205
+ },
206
+ }),
207
+ 'bun.lock': '# bun lockfile v1\n',
208
+ });
209
+
210
+ const bunInfo = await new CodebaseAnalyzer(bunProject).analyse();
211
+ const bunDependabot = parseWorkflow(new DependabotGenerator(bunInfo).generate().content);
212
+ assert(bunDependabot.updates.some((entry) => entry['package-ecosystem'] === 'bun'));
213
+ assert(!bunDependabot.updates.some((entry) => entry['package-ecosystem'] === 'npm'));
214
+ });
215
+
216
+ test('GCP App Engine detection documents only the secrets the generated deploy flow uses', async () => {
217
+ const projectDir = makeTempDir();
218
+ writeFiles(projectDir, {
219
+ 'app.yaml': 'runtime: nodejs20\n',
220
+ });
221
+
222
+ const info = await new CodebaseAnalyzer(projectDir).analyse();
223
+ const hosting = await new HostingDetector(projectDir, info).detect();
224
+ const gcp = hosting.find((provider) => provider.name === 'GCP App Engine');
225
+
226
+ assert.deepEqual(gcp.secrets, ['GCP_SA_KEY']);
227
+ });
228
+
229
+ test('ReleaseDetector reads release-it CJS config and honors npm.publish false', async () => {
230
+ const projectDir = makeTempDir();
231
+ writeFiles(projectDir, {
232
+ 'package.json': json({
233
+ name: 'release-it-fixture',
234
+ version: '1.0.0',
235
+ devDependencies: {
236
+ 'release-it': '^17.0.0',
237
+ },
238
+ }),
239
+ '.release-it.cjs': "module.exports = { npm: { publish: false } };\n",
240
+ });
241
+
242
+ const info = await new CodebaseAnalyzer(projectDir).analyse();
243
+ const release = await new ReleaseDetector(projectDir, info).detect();
244
+
245
+ assert.equal(release.tool, 'release-it');
246
+ assert.equal(release.publishToNpm, false);
247
+ assert.equal(release.requiresNpmToken, false);
248
+ });
249
+
250
+ test('bun.lock is recognized as Bun across codebase, testing, and release detection', async () => {
251
+ const projectDir = makeTempDir();
252
+ writeFiles(projectDir, {
253
+ 'package.json': json({
254
+ name: 'bun-lock-fixture',
255
+ version: '1.0.0',
256
+ dependencies: {
257
+ react: '^18.3.1',
258
+ },
259
+ devDependencies: {
260
+ vitest: '^3.0.0',
261
+ },
262
+ scripts: {
263
+ test: 'vitest run',
264
+ release: 'bun run release',
265
+ },
266
+ }),
267
+ 'bun.lock': '# bun lockfile v1\n',
268
+ });
269
+
270
+ const info = await new CodebaseAnalyzer(projectDir).analyse();
271
+ const languages = await new LanguageDetector(projectDir, info).detect();
272
+ const testing = await new TestingDetector(projectDir, info).detect();
273
+ const release = await new ReleaseDetector(projectDir, info).detect();
274
+
275
+ assert(info.lockFiles.includes('bun.lock'));
276
+ assert.equal(languages[0].packageManager, 'bun');
277
+ assert.equal(testing[0].command, 'bun run test');
278
+ assert.equal(release.command, 'bun run release');
279
+ });
280
+
281
+ test('CodebaseAnalyzer detects the current git branch as the default branch fallback', async () => {
282
+ const projectDir = makeTempDir();
283
+ writeFiles(projectDir, {
284
+ 'package.json': json({ name: 'branch-fixture', version: '1.0.0' }),
285
+ });
286
+
287
+ try {
288
+ runGit(projectDir, ['init', '-b', 'release']);
289
+ } catch (_) {
290
+ runGit(projectDir, ['init']);
291
+ runGit(projectDir, ['checkout', '-b', 'release']);
292
+ }
293
+
294
+ const info = await new CodebaseAnalyzer(projectDir).analyse();
295
+ assert.equal(info.currentBranch, 'release');
296
+ assert.equal(info.defaultBranch, 'release');
297
+ });
298
+
299
+ test('WorkflowGenerator uses the detected default branch across CI, deploy, and security workflows', () => {
300
+ const projectDir = makeTempDir();
301
+ writeFiles(projectDir, {
302
+ 'package.json': json({
303
+ name: 'branch-aware-app',
304
+ version: '1.0.0',
305
+ scripts: {
306
+ test: 'echo ok',
307
+ },
308
+ }),
309
+ });
310
+
311
+ const generator = new WorkflowGenerator(
312
+ makeJsProject({
313
+ hosting: [{ name: 'Vercel', secrets: ['VERCEL_TOKEN', 'VERCEL_ORG_ID', 'VERCEL_PROJECT_ID'] }],
314
+ testing: [{ name: 'Vitest', type: 'unit', confidence: 1, command: 'npm run test' }],
315
+ defaultBranch: 'release',
316
+ }),
317
+ projectDir
318
+ );
319
+
320
+ const workflows = generator.generate();
321
+ const byName = Object.fromEntries(workflows.map((workflow) => [workflow.filename, parseWorkflow(workflow.content)]));
322
+
323
+ assert.deepEqual(byName['ci.yml'].on.push.branches, ['release']);
324
+ assert.deepEqual(byName['deploy.yml'].on.push.branches, ['release']);
325
+ assert.deepEqual(byName['security.yml'].on.push.branches, ['release']);
326
+ });
327
+
328
+ test('combineWorkflows collapses generated workflows into a single pipeline file', () => {
329
+ const projectDir = makeTempDir();
330
+ writeFiles(projectDir, {
331
+ 'package.json': json({
332
+ name: 'combined-pipeline-app',
333
+ version: '1.0.0',
334
+ scripts: {
335
+ test: 'echo ok',
336
+ },
337
+ }),
338
+ });
339
+
340
+ const config = makeJsProject({
341
+ hosting: [{ name: 'Vercel', secrets: ['VERCEL_TOKEN', 'VERCEL_ORG_ID', 'VERCEL_PROJECT_ID'] }],
342
+ testing: [{ name: 'Vitest', type: 'unit', confidence: 1, command: 'npm run test' }],
343
+ });
344
+ config.releaseInfo = { tool: 'custom', command: 'npm run release', publishToNpm: false, requiresNpmToken: false };
345
+ const workflows = new WorkflowGenerator(config, projectDir).generate();
346
+ workflows.push(new ReleaseGenerator(config.releaseInfo, config, projectDir).generate());
347
+
348
+ const combined = combineWorkflows(workflows, { config, releaseInfo: config.releaseInfo });
349
+ const parsed = parseWorkflow(combined.content);
350
+
351
+ assert.equal(combined.filename, 'pipeline.yml');
352
+ assert(parsed.jobs.ci_lint);
353
+ assert(parsed.jobs.deploy_deploy);
354
+ assert(parsed.jobs.security_security);
355
+ assert(parsed.jobs.release_release);
356
+ });
357
+
358
+ test('combineWorkflows preserves workflow-specific trigger scoping in the unified pipeline', () => {
359
+ const projectDir = makeTempDir();
360
+ writeFiles(projectDir, {
361
+ 'package.json': json({
362
+ name: 'combined-trigger-app',
363
+ version: '1.0.0',
364
+ scripts: {
365
+ test: 'echo ok',
366
+ build: 'echo build',
367
+ release: 'echo release',
368
+ },
369
+ }),
370
+ });
371
+
372
+ const config = makeJsProject({
373
+ hosting: [{ name: 'Vercel', secrets: ['VERCEL_TOKEN', 'VERCEL_ORG_ID', 'VERCEL_PROJECT_ID'] }],
374
+ frameworks: [{ name: 'Next.js', confidence: 1, buildDir: '.next' }],
375
+ testing: [{ name: 'Vitest', type: 'unit', confidence: 1, command: 'npm run test' }],
376
+ });
377
+ config.releaseInfo = { tool: 'custom', command: 'npm run release', publishToNpm: false, requiresNpmToken: false };
378
+
379
+ const workflows = new WorkflowGenerator(config, projectDir).generate();
380
+ workflows.push(new ReleaseGenerator(config.releaseInfo, config, projectDir).generate());
381
+
382
+ const combined = parseWorkflow(combineWorkflows(workflows, { config, releaseInfo: config.releaseInfo }).content);
383
+
384
+ assert.deepEqual(combined.on.push.branches, ['main', 'master', 'develop']);
385
+ assert(combined.jobs.ci_lint.if.includes("github.event_name == 'push'"));
386
+ assert(combined.jobs.ci_lint.if.includes("github.event_name == 'pull_request'"));
387
+ assert(!combined.jobs.ci_lint.if.includes("github.event_name == 'schedule'"));
388
+ assert(combined.jobs.security_security.if.includes("github.event_name == 'schedule'"));
389
+ assert(combined.jobs.deploy_deploy.if.includes("github.ref_name == 'main'"));
390
+ assert(combined.jobs.deploy_deploy.if.includes("github.ref_name == 'master'"));
391
+ assert(!combined.jobs.deploy_deploy.if.includes("github.ref_name == 'develop'"));
392
+ });
393
+
394
+ test('Single-layout monorepos still generate the root workspace matrix CI', () => {
395
+ const projectDir = makeTempDir();
396
+ const packages = [
397
+ {
398
+ name: 'app',
399
+ relativePath: 'packages/app',
400
+ packageJson: {
401
+ name: 'app',
402
+ scripts: {
403
+ lint: 'echo lint',
404
+ test: 'echo test',
405
+ build: 'echo build',
406
+ },
407
+ },
408
+ },
409
+ ];
410
+
411
+ const generator = new WorkflowGenerator(
412
+ makeJsProject({
413
+ frameworks: [{ name: 'React', confidence: 1, buildDir: 'dist' }],
414
+ testing: [{ name: 'Vitest', type: 'unit', confidence: 1, command: 'npm run test' }],
415
+ monorepoPackages: packages,
416
+ _config: { monorepo: { perPackage: true } },
417
+ }),
418
+ projectDir
419
+ );
420
+
421
+ const ciWorkflow = parseWorkflow(generator.generate().find((workflow) => workflow.filename === 'ci.yml').content);
422
+
423
+ assert(ciWorkflow.jobs.ci);
424
+ assert(ciWorkflow.jobs.ci.strategy);
425
+ assert.equal(ciWorkflow.jobs.ci.steps.find((step) => step.name === 'Lint').if, '${{ matrix.lintScript != \'\' }}');
426
+ });
427
+
428
+ test('Frontend Lighthouse is omitted when no build job exists', () => {
429
+ const projectDir = makeTempDir();
430
+ const generator = new WorkflowGenerator(
431
+ makeJsProject({
432
+ frameworks: [{ name: 'React', confidence: 1 }],
433
+ }),
434
+ projectDir
435
+ );
436
+
437
+ const parsed = parseWorkflow(generator._buildCIWorkflow());
438
+
439
+ assert(!parsed.jobs.lighthouse);
440
+ });
441
+
442
+ test('E2E jobs fall back to existing jobs instead of depending on a missing build job', () => {
443
+ const projectDir = makeTempDir();
444
+ const generator = new WorkflowGenerator(
445
+ makeJsProject({
446
+ testing: [{ name: 'Cypress', type: 'e2e', confidence: 1, command: 'npx cypress run' }],
447
+ }),
448
+ projectDir
449
+ );
450
+
451
+ const parsed = parseWorkflow(generator._buildCIWorkflow());
452
+
453
+ assert.deepEqual(parsed.jobs.e2e.needs, ['lint']);
454
+ });
455
+
456
+ test('Deploy workflow keeps develop when it is the only configured branch', () => {
457
+ const projectDir = makeTempDir();
458
+ writeFiles(projectDir, {
459
+ 'package.json': json({
460
+ name: 'develop-only-app',
461
+ version: '1.0.0',
462
+ }),
463
+ });
464
+
465
+ const generator = new WorkflowGenerator(
466
+ makeJsProject({
467
+ hosting: [{ name: 'Vercel', secrets: ['VERCEL_TOKEN', 'VERCEL_ORG_ID', 'VERCEL_PROJECT_ID'] }],
468
+ _config: { branches: ['develop'] },
469
+ }),
470
+ projectDir
471
+ );
472
+
473
+ const deploy = generator.generate().find((workflow) => workflow.filename === 'deploy.yml');
474
+ const parsed = parseWorkflow(deploy.content);
475
+
476
+ assert.deepEqual(parsed.on.push.branches, ['develop']);
477
+ });
478
+
479
+ test('Netlify preview configuration uses the detected production branch instead of hardcoding main', () => {
480
+ const projectDir = makeTempDir();
481
+ writeFiles(projectDir, {
482
+ 'package.json': json({
483
+ name: 'netlify-app',
484
+ version: '1.0.0',
485
+ scripts: {
486
+ build: 'echo build',
487
+ },
488
+ }),
489
+ });
490
+
491
+ const generator = new WorkflowGenerator(
492
+ makeJsProject({
493
+ hosting: [{ name: 'Netlify', secrets: ['NETLIFY_AUTH_TOKEN', 'NETLIFY_SITE_ID'] }],
494
+ defaultBranch: 'release',
495
+ }),
496
+ projectDir
497
+ );
498
+
499
+ const deploy = generator.generate().find((workflow) => workflow.filename === 'deploy.yml');
500
+ const parsed = parseWorkflow(deploy.content);
501
+ const previewStep = parsed.jobs.preview.steps.find(
502
+ (step) => step.name === 'Deploy Preview' && step.uses === 'nwtgck/actions-netlify@v3.0'
503
+ );
504
+
505
+ assert.equal(previewStep.with['production-branch'], 'release');
506
+ });
507
+
508
+ test('Generic JavaScript builds no longer upload a fake dist artifact when no build directory is known', () => {
509
+ const projectDir = makeTempDir();
510
+ writeFiles(projectDir, {
511
+ 'package.json': json({
512
+ name: 'generic-build-app',
513
+ version: '1.0.0',
514
+ scripts: {
515
+ build: 'node -e "console.log(\'build\')"',
516
+ },
517
+ }),
518
+ });
519
+
520
+ const generator = new WorkflowGenerator(
521
+ makeJsProject({
522
+ frameworks: [{ name: 'React', confidence: 1 }],
523
+ }),
524
+ projectDir
525
+ );
526
+
527
+ const workflow = parseWorkflow(generator._buildCIWorkflow());
528
+ const stepNames = workflow.jobs.build.steps.map((step) => step.name);
529
+
530
+ assert(stepNames.includes('Build'));
531
+ assert(!stepNames.includes('Upload build artifact'));
532
+ });
533
+
534
+ test('JavaScript workflows without a package lock file use npm install instead of npm ci', () => {
535
+ const projectDir = makeTempDir();
536
+ writeFiles(projectDir, {
537
+ 'package.json': json({
538
+ name: 'no-lock-app',
539
+ version: '1.0.0',
540
+ scripts: {
541
+ build: 'echo build',
542
+ },
543
+ }),
544
+ });
545
+
546
+ const generator = new WorkflowGenerator(makeJsProject(), projectDir);
547
+ const workflow = parseWorkflow(generator._buildCIWorkflow());
548
+ const installStep = workflow.jobs.lint.steps.find((step) => step.name === 'Install dependencies');
549
+
550
+ assert.equal(installStep.run, 'npm install');
551
+ });
552
+
553
+ test('Bun workflows set up Bun before installing dependencies', () => {
554
+ const projectDir = makeTempDir();
555
+ writeFiles(projectDir, {
556
+ 'package.json': json({
557
+ name: 'bun-app',
558
+ version: '1.0.0',
559
+ scripts: {
560
+ lint: 'bun lint',
561
+ build: 'bun run build',
562
+ },
563
+ }),
564
+ });
565
+
566
+ const generator = new WorkflowGenerator(
567
+ makeJsProject({
568
+ languages: [{ name: 'JavaScript', packageManager: 'bun', nodeVersion: '20' }],
569
+ hosting: [{ name: 'Netlify', secrets: ['NETLIFY_AUTH_TOKEN', 'NETLIFY_SITE_ID'] }],
570
+ }),
571
+ projectDir
572
+ );
573
+
574
+ const workflow = parseWorkflow(generator._buildCIWorkflow());
575
+ const setupStepNames = workflow.jobs.lint.steps.map((step) => step.name);
576
+ const installStep = workflow.jobs.lint.steps.find((step) => step.name === 'Install dependencies');
577
+ const lintStep = workflow.jobs.lint.steps.find((step) => step.name === 'Lint');
578
+ const buildStep = workflow.jobs.build.steps.find((step) => step.name === 'Build');
579
+ const deploy = parseWorkflow(generator.generate().find((entry) => entry.filename === 'deploy.yml').content);
580
+ const deployBuildStep = deploy.jobs.deploy.steps.find((step) => step.name === 'Build');
581
+
582
+ assert(setupStepNames.includes('Set up Bun'));
583
+ assert.equal(installStep.run, 'bun install');
584
+ assert.equal(lintStep.run, 'bun run lint');
585
+ assert.equal(buildStep.run, 'bun run build');
586
+ assert.equal(deployBuildStep.run, 'bun run build');
587
+ });
588
+
589
+ test('Python security workflow honors the detected Python version', () => {
590
+ const projectDir = makeTempDir();
591
+ const generator = new WorkflowGenerator(
592
+ {
593
+ hosting: [],
594
+ frameworks: [],
595
+ languages: [{ name: 'Python', packageManager: 'pip', pythonVersion: '3.12' }],
596
+ testing: [],
597
+ envVars: { secrets: [], public: [], all: [], sourceFile: null },
598
+ monorepoPackages: [],
599
+ lockFiles: [],
600
+ _config: {},
601
+ },
602
+ projectDir
603
+ );
604
+
605
+ const workflow = parseWorkflow(generator._buildSecurityWorkflow());
606
+ const pythonSetup = workflow.jobs.security.steps.find((step) => step.name === 'Set up Python');
607
+
608
+ assert.equal(pythonSetup.with['python-version'], '3.12');
609
+ });
610
+
611
+ test('smartMergeWorkflow preserves existing custom steps that cistack does not regenerate', () => {
612
+ const existing = [
613
+ 'name: CI',
614
+ 'jobs:',
615
+ ' build:',
616
+ ' runs-on: ubuntu-latest',
617
+ ' steps:',
618
+ ' - name: Checkout code',
619
+ ' uses: actions/checkout@v4',
620
+ ' - name: Custom',
621
+ ' run: echo custom',
622
+ '',
623
+ ].join('\n');
624
+
625
+ const generated = [
626
+ '# Generated by cistack',
627
+ '',
628
+ 'name: CI',
629
+ 'jobs:',
630
+ ' build:',
631
+ ' runs-on: ubuntu-latest',
632
+ ' steps:',
633
+ ' - name: Checkout code',
634
+ ' uses: actions/checkout@v4',
635
+ ' - name: Build',
636
+ ' run: npm run build',
637
+ '',
638
+ ].join('\n');
639
+
640
+ const result = smartMergeWorkflow(existing, generated);
641
+ const merged = yaml.load(stripHeader(result.content));
642
+ const stepNames = merged.jobs.build.steps.map((step) => step.name);
643
+
644
+ assert(stepNames.includes('Custom'));
645
+ assert(stepNames.includes('Build'));
646
+ });
647
+
648
+ test('Per-package CI picks workspace build scripts instead of only reading the root package.json', () => {
649
+ const projectDir = makeTempDir();
650
+ writeFiles(projectDir, {
651
+ 'package.json': json({
652
+ name: 'monorepo-root',
653
+ private: true,
654
+ workspaces: ['packages/*'],
655
+ scripts: {
656
+ test: 'echo root',
657
+ },
658
+ }),
659
+ 'packages/pkg-a/package.json': json({
660
+ name: 'pkg-a',
661
+ version: '1.0.0',
662
+ scripts: {
663
+ lint: 'echo lint',
664
+ test: 'echo test',
665
+ build: 'echo build',
666
+ },
667
+ }),
668
+ });
669
+
670
+ const pkg = {
671
+ name: 'pkg-a',
672
+ relativePath: 'packages/pkg-a',
673
+ packageJson: JSON.parse(fs.readFileSync(path.join(projectDir, 'packages/pkg-a/package.json'), 'utf8')),
674
+ };
675
+
676
+ const generator = new WorkflowGenerator(
677
+ makeJsProject({
678
+ testing: [{ name: 'Vitest', type: 'unit', confidence: 1, command: 'npm run test' }],
679
+ monorepoPackages: [pkg],
680
+ _config: { workflowLayout: 'split', monorepo: { perPackage: true } },
681
+ }),
682
+ projectDir
683
+ );
684
+
685
+ const ciWorkflow = generator.generate().find((workflow) => workflow.filename === 'ci-pkg-a.yml');
686
+ const parsed = parseWorkflow(ciWorkflow.content);
687
+ const lintStep = parsed.jobs.lint.steps.find((step) => step.name === 'Lint');
688
+ const testStep = parsed.jobs.test.steps.find((step) => step.name === 'Run tests');
689
+ const buildStep = parsed.jobs.build.steps.find((step) => step.name === 'Build');
690
+
691
+ assert.equal(lintStep.run, 'npm run lint --workspace=pkg-a');
692
+ assert.equal(testStep.run, 'npm run test --workspace=pkg-a');
693
+ assert.equal(buildStep.run, 'npm run build --workspace=pkg-a');
694
+ });
695
+
696
+ test('Monorepo root CI installs dependencies at the repo root and does not hide workspace failures', () => {
697
+ const projectDir = makeTempDir();
698
+ writeFiles(projectDir, {
699
+ 'package.json': json({
700
+ name: 'workspace-root',
701
+ private: true,
702
+ workspaces: ['packages/*'],
703
+ }),
704
+ 'yarn.lock': '',
705
+ 'packages/app/package.json': json({
706
+ name: 'app',
707
+ version: '1.0.0',
708
+ scripts: {
709
+ lint: 'echo lint',
710
+ test: 'echo test',
711
+ build: 'echo build',
712
+ },
713
+ }),
714
+ 'packages/docs/package.json': json({
715
+ name: 'docs',
716
+ version: '1.0.0',
717
+ }),
718
+ });
719
+
720
+ const packages = [
721
+ {
722
+ name: 'app',
723
+ relativePath: 'packages/app',
724
+ packageJson: JSON.parse(fs.readFileSync(path.join(projectDir, 'packages/app/package.json'), 'utf8')),
725
+ },
726
+ {
727
+ name: 'docs',
728
+ relativePath: 'packages/docs',
729
+ packageJson: JSON.parse(fs.readFileSync(path.join(projectDir, 'packages/docs/package.json'), 'utf8')),
730
+ },
731
+ ];
732
+
733
+ const generator = new WorkflowGenerator(
734
+ makeJsProject({
735
+ frameworks: [{ name: 'React', confidence: 1, buildDir: 'dist' }],
736
+ languages: [{ name: 'JavaScript', packageManager: 'yarn', nodeVersion: '20' }],
737
+ monorepoPackages: packages,
738
+ lockFiles: ['yarn.lock'],
739
+ _config: { workflowLayout: 'split', monorepo: { perPackage: true } },
740
+ }),
741
+ projectDir
742
+ );
743
+
744
+ const rootWorkflow = parseWorkflow(generator.generate().find((workflow) => workflow.filename === 'ci.yml').content);
745
+ const ciSteps = rootWorkflow.jobs.ci.steps;
746
+ const installStep = ciSteps.find((step) => step.name === 'Install dependencies');
747
+ const lintStep = ciSteps.find((step) => step.name === 'Lint');
748
+ const testStep = ciSteps.find((step) => step.name === 'Test');
749
+ const buildStep = ciSteps.find((step) => step.name === 'Build');
750
+ const lighthouseBuildStep = rootWorkflow.jobs.lighthouse.steps.find((step) => step.name === 'Build workspace');
751
+
752
+ assert.equal(installStep.run, 'yarn install --frozen-lockfile');
753
+ assert.equal(lintStep.if, '${{ matrix.lintScript != \'\' }}');
754
+ assert.equal(testStep.if, '${{ matrix.testScript != \'\' }}');
755
+ assert.equal(buildStep.if, '${{ matrix.buildScript != \'\' }}');
756
+ assert(!lintStep.run.includes('|| true'));
757
+ assert(!testStep.run.includes('|| true'));
758
+ assert(!buildStep.run.includes('|| true'));
759
+ assert.equal(lighthouseBuildStep.run, 'yarn workspace ${{ matrix.name }} run ${{ matrix.buildScript }}');
760
+ });
761
+
762
+ test('Bun monorepo matrix commands are scoped to the workspace path', () => {
763
+ const projectDir = makeTempDir();
764
+ const packages = [
765
+ {
766
+ name: 'pkg-a',
767
+ relativePath: 'packages/pkg-a',
768
+ packageJson: {
769
+ name: 'pkg-a',
770
+ scripts: {
771
+ lint: 'bun lint',
772
+ test: 'bun test',
773
+ build: 'bun run build',
774
+ },
775
+ },
776
+ },
777
+ ];
778
+
779
+ const generator = new WorkflowGenerator(
780
+ makeJsProject({
781
+ frameworks: [{ name: 'React', confidence: 1, buildDir: 'dist' }],
782
+ languages: [{ name: 'JavaScript', packageManager: 'bun', nodeVersion: '20' }],
783
+ monorepoPackages: packages,
784
+ _config: { workflowLayout: 'split', monorepo: { perPackage: true } },
785
+ }),
786
+ projectDir
787
+ );
788
+
789
+ const parsed = parseWorkflow(generator.generate().find((workflow) => workflow.filename === 'ci.yml').content);
790
+ const lintStep = parsed.jobs.ci.steps.find((step) => step.name === 'Lint');
791
+ const testStep = parsed.jobs.ci.steps.find((step) => step.name === 'Test');
792
+ const buildStep = parsed.jobs.ci.steps.find((step) => step.name === 'Build');
793
+
794
+ assert.equal(lintStep.run, "bun run --filter './${{ matrix.package }}' ${{ matrix.lintScript }}");
795
+ assert.equal(testStep.run, "bun run --filter './${{ matrix.package }}' ${{ matrix.testScript }}");
796
+ assert.equal(buildStep.run, "bun run --filter './${{ matrix.package }}' ${{ matrix.buildScript }}");
797
+ });
798
+
799
+ test('ReleaseGenerator respects package manager and default branch for custom release flows', () => {
800
+ const projectDir = makeTempDir();
801
+ writeFiles(projectDir, {
802
+ 'package.json': json({
803
+ name: 'release-generator-fixture',
804
+ version: '1.0.0',
805
+ scripts: {
806
+ release: 'echo release',
807
+ },
808
+ }),
809
+ });
810
+
811
+ const generator = new ReleaseGenerator(
812
+ { tool: 'custom', command: null, publishToNpm: false },
813
+ makeJsProject({
814
+ languages: [{ name: 'JavaScript', packageManager: 'pnpm', nodeVersion: '20' }],
815
+ defaultBranch: 'release',
816
+ }),
817
+ projectDir
818
+ );
819
+
820
+ const parsed = parseWorkflow(generator.generate().content);
821
+ const releaseStep = parsed.jobs.release.steps.find((step) => step.run === 'pnpm run release');
822
+
823
+ assert.deepEqual(parsed.on.push.branches, ['release']);
824
+ assert.equal(releaseStep.run, 'pnpm run release');
825
+ });
826
+
827
+ test('ReleaseGenerator sets up Bun for bun-based release flows', () => {
828
+ const projectDir = makeTempDir();
829
+ writeFiles(projectDir, {
830
+ 'package.json': json({
831
+ name: 'bun-release',
832
+ version: '1.0.0',
833
+ scripts: {
834
+ release: 'echo release',
835
+ },
836
+ }),
837
+ });
838
+
839
+ const generator = new ReleaseGenerator(
840
+ { tool: 'custom', command: null, publishToNpm: false },
841
+ {
842
+ hosting: [],
843
+ frameworks: [],
844
+ languages: [{ name: 'JavaScript', packageManager: 'bun', nodeVersion: '20' }],
845
+ testing: [],
846
+ envVars: { secrets: [], public: [], all: [], sourceFile: null },
847
+ monorepoPackages: [],
848
+ lockFiles: [],
849
+ _config: {},
850
+ },
851
+ projectDir
852
+ );
853
+
854
+ const parsed = parseWorkflow(generator.generate().content);
855
+ const stepNames = parsed.jobs.release.steps.map((step) => step.name);
856
+ const installStep = parsed.jobs.release.steps.find((step) => step.name === 'Install dependencies');
857
+
858
+ assert(stepNames.includes('Set up Bun'));
859
+ assert.equal(installStep.run, 'bun install');
860
+ });
861
+
862
+ test('CLI smoke test still generates workflows in dry-run mode', () => {
863
+ const projectDir = makeTempDir();
864
+ writeFiles(projectDir, {
865
+ 'package.json': json({
866
+ name: 'cli-smoke-app',
867
+ version: '1.0.0',
868
+ scripts: {
869
+ test: 'echo ok',
870
+ },
871
+ }),
872
+ });
873
+
874
+ const output = execFileSync(process.execPath, ['bin/ciflow.js', 'generate', '--path', projectDir, '--dry-run', '--no-prompt'], {
875
+ cwd: repoRoot,
876
+ encoding: 'utf8',
877
+ stdio: ['ignore', 'pipe', 'pipe'],
878
+ });
879
+
880
+ assert(output.includes('.github/workflows/pipeline.yml'));
881
+ assert(output.includes('.github/dependabot.yml'));
882
+ assert(output.includes('Unified Pipeline'));
883
+ assert(output.includes('Done! Your GitHub Actions pipeline is ready.'));
884
+ });
885
+
886
+ test('CLI supports opting back into split workflow files', () => {
887
+ const projectDir = makeTempDir();
888
+ writeFiles(projectDir, {
889
+ 'package.json': json({
890
+ name: 'cli-split-app',
891
+ version: '1.0.0',
892
+ scripts: {
893
+ test: 'echo ok',
894
+ },
895
+ }),
896
+ 'cistack.config.js': `module.exports = { workflowLayout: 'split' };\n`,
897
+ });
898
+
899
+ const output = execFileSync(process.execPath, ['bin/ciflow.js', 'generate', '--path', projectDir, '--dry-run', '--no-prompt'], {
900
+ cwd: repoRoot,
901
+ encoding: 'utf8',
902
+ stdio: ['ignore', 'pipe', 'pipe'],
903
+ });
904
+
905
+ assert(output.includes('.github/workflows/ci.yml'));
906
+ assert(output.includes('.github/workflows/security.yml'));
907
+ assert(output.includes('Done! Your GitHub Actions pipeline is ready.'));
908
+ });
909
+
910
+ async function main() {
911
+ let passed = 0;
912
+
913
+ try {
914
+ for (const { name, fn } of tests) {
915
+ await fn();
916
+ passed++;
917
+ console.log(`ok - ${name}`);
918
+ }
919
+
920
+ console.log(`\n${passed} tests passed`);
921
+ } finally {
922
+ for (const dir of tempDirs) {
923
+ fs.rmSync(dir, { recursive: true, force: true });
924
+ }
925
+ }
926
+ }
927
+
928
+ main().catch((error) => {
929
+ console.error(`not ok - ${error.message}`);
930
+ if (error && error.stack) {
931
+ console.error(error.stack);
932
+ }
933
+ process.exit(1);
934
+ });