cistack 4.0.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/README.md CHANGED
@@ -76,45 +76,30 @@ This writes `cistack.config.js` with the supported override keys.
76
76
 
77
77
  ## What gets generated
78
78
 
79
- ### `ci.yml`
79
+ ### `pipeline.yml`
80
80
 
81
- Continuous integration for linting, tests, builds, coverage upload, and optional E2E jobs.
81
+ By default, `cistack` now generates a single GitHub Actions workflow that combines CI, deploy, Docker, security, and release jobs into one place so teams can track the whole pipeline from one file.
82
82
 
83
- - Runs on pushes and pull requests
83
+ - Includes lint, test, build, E2E, deploy, Docker, security, and release jobs when those parts of the stack are detected
84
84
  - Uses the detected default branch or your configured `branches`
85
- - Uses runtime matrices where appropriate
86
- - Uses package-manager-aware install and script commands
85
+ - Keeps preview deploys, release jobs, and scheduled security scans in the same workflow file
86
+ - Documents required secrets in the file header
87
87
 
88
- ### `deploy.yml`
88
+ ### `dependabot.yml`
89
89
 
90
- Continuous deployment for the primary hosting provider.
90
+ Dependabot remains a separate file in `.github/dependabot.yml`, because it is not a GitHub Actions workflow.
91
91
 
92
- - Runs on production pushes plus manual dispatch
93
- - Uses preview deployments on pull requests for providers that support them cleanly
94
- - Documents the required secrets in the file header
95
- - Avoids empty push branch lists when a repo is `develop`-only
92
+ ### Split mode
96
93
 
97
- ### `docker.yml`
94
+ If you prefer the old multi-file layout, set:
98
95
 
99
- Builds and optionally pushes Docker images to GHCR.
100
-
101
- - Runs on configured branches, pull requests, and semantic version tags
102
- - Uses Buildx and GitHub Actions cache
103
-
104
- ### `security.yml`
105
-
106
- Dependency audit plus CodeQL analysis.
107
-
108
- - Runs on push, pull request, and a weekly schedule
109
- - Uses the detected default branch or configured branch overrides
110
-
111
- ### `release.yml`
112
-
113
- Generated when `semantic-release`, `changesets`, `release-it`, `standard-version`, or a custom release command is detected or configured.
96
+ ```js
97
+ module.exports = {
98
+ workflowLayout: 'split',
99
+ };
100
+ ```
114
101
 
115
- - Uses the detected package manager for release commands
116
- - Respects configured branches and detected default branch
117
- - Documents additional required secrets such as `NPM_TOKEN`
102
+ In split mode, `cistack` writes separate `ci.yml`, `deploy.yml`, `docker.yml`, `security.yml`, and `release.yml` files again.
118
103
 
119
104
  ## Supported detection
120
105
 
@@ -184,6 +169,7 @@ module.exports = {
184
169
  nodeVersion: '20',
185
170
  packageManager: 'pnpm',
186
171
  branches: ['main', 'staging'],
172
+ workflowLayout: 'single',
187
173
  hosting: ['Vercel'],
188
174
  outputDir: '.github/workflows',
189
175
 
@@ -211,6 +197,7 @@ Supported top-level config keys:
211
197
  - `frameworks`
212
198
  - `testing`
213
199
  - `branches`
200
+ - `workflowLayout`
214
201
  - `cache`
215
202
  - `monorepo`
216
203
  - `release`
package/bin/ciflow.js CHANGED
@@ -88,6 +88,7 @@ module.exports = {
88
88
  // packageManager: 'pnpm', // 'npm' | 'yarn' | 'pnpm' | 'bun'
89
89
  // hosting: ['Firebase'], // Force a specific hosting provider
90
90
  // branches: ['main', 'staging'], // CI branches (default: detected git default branch, then main/master/develop)
91
+ // workflowLayout: 'single', // 'single' (default) or 'split'
91
92
  // outputDir: '.github/workflows', // Where to write workflow files
92
93
 
93
94
  // cache: {
@@ -101,7 +102,7 @@ module.exports = {
101
102
  // },
102
103
 
103
104
  // monorepo: {
104
- // perPackage: true, // Generate one ci-<name>.yml per workspace
105
+ // perPackage: true, // Generate one ci-<name>.yml per workspace (split layout only)
105
106
  // },
106
107
 
107
108
  // release: {
package/index.d.ts CHANGED
@@ -63,6 +63,7 @@ export interface Config {
63
63
  frameworks?: string | string[];
64
64
  testing?: string | string[];
65
65
  branches?: string[];
66
+ workflowLayout?: 'single' | 'split';
66
67
  cache?: CacheConfig;
67
68
  monorepo?: MonorepoConfig;
68
69
  release?: ReleaseTool | ReleaseConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cistack",
3
- "version": "4.0.0",
3
+ "version": "5.0.0",
4
4
  "description": "Automatically generate GitHub Actions CI/CD pipelines by analysing your codebase",
5
5
  "main": "src/index.js",
6
6
  "types": "index.d.ts",
@@ -16,6 +16,7 @@ const chalk = require('chalk');
16
16
  * (default: detected git default branch, then main/master/develop)
17
17
  * cache – { npm: bool, cargo: bool, pip: bool, ... } enable/disable caches
18
18
  * monorepo – { perPackage: bool } generate one file per workspace
19
+ * workflowLayout – 'single' | 'split' (default: 'single')
19
20
  * release – { tool: 'semantic-release'|'changesets'|'standard-version'|'release-it' }
20
21
  * secrets – extra secret names to document in workflow comments
21
22
  * outputDir – override default '.github/workflows'
@@ -23,6 +23,7 @@ class WorkflowGenerator {
23
23
  this.lockFiles = new Set(config.lockFiles || []);
24
24
  this.extraConfig = config._config || {}; // raw cistack.config.js
25
25
  this.defaultBranch = config.defaultBranch || config.currentBranch || null;
26
+ this.workflowLayout = this.extraConfig.workflowLayout === 'split' ? 'split' : 'single';
26
27
 
27
28
  // Convenient accessors
28
29
  this.primaryLang = this.languages[0] || { name: 'JavaScript', packageManager: 'npm', nodeVersion: '20' };
@@ -31,9 +32,10 @@ class WorkflowGenerator {
31
32
  this.hasDocker = this.hosting.some((h) => h.name === 'Docker');
32
33
  this.primaryHosting = this.hosting.filter((h) => h.name !== 'Docker')[0] || null;
33
34
 
34
- // Monorepo mode: per-package workflows if configured or if > 1 package
35
+ // Monorepo mode: always keep the root matrix workflow for monorepos.
36
+ // Split layout may additionally emit one workflow per workspace.
35
37
  this.isMonorepo = this.monorepoPackages.length > 0;
36
- this.perPackageWorkflows = this.isMonorepo && (
38
+ this.perPackageWorkflows = this.workflowLayout === 'split' && this.isMonorepo && (
37
39
  (this.extraConfig.monorepo && this.extraConfig.monorepo.perPackage) ||
38
40
  this.monorepoPackages.length > 1
39
41
  );
@@ -77,15 +79,17 @@ class WorkflowGenerator {
77
79
  generate() {
78
80
  const workflows = [];
79
81
 
80
- if (this.perPackageWorkflows) {
81
- // ── Monorepo: one CI file per workspace ─────────────────────────────
82
- for (const pkg of this.monorepoPackages) {
83
- workflows.push({
84
- filename: `ci-${this._slugify(pkg.name)}.yml`,
85
- content: this._buildCIWorkflow(pkg),
86
- });
82
+ if (this.isMonorepo) {
83
+ if (this.perPackageWorkflows) {
84
+ // ── Monorepo split mode: one CI file per workspace ───────────────
85
+ for (const pkg of this.monorepoPackages) {
86
+ workflows.push({
87
+ filename: `ci-${this._slugify(pkg.name)}.yml`,
88
+ content: this._buildCIWorkflow(pkg),
89
+ });
90
+ }
87
91
  }
88
- // Root-level CI file (matrix over all packages)
92
+ // ── Monorepo root CI file (matrix over all packages) ──────────────
89
93
  workflows.push({
90
94
  filename: 'ci.yml',
91
95
  content: this._buildMonorepoRootCI(),
package/src/index.js CHANGED
@@ -18,6 +18,7 @@ const WorkflowGenerator = require('./generators/workflow');
18
18
  const DependabotGenerator = require('./generators/dependabot');
19
19
  const ReleaseGenerator = require('./generators/release');
20
20
  const ConfigLoader = require('./config/loader');
21
+ const combineWorkflows = require('./utils/workflow-combiner');
21
22
  const { ensureDir, writeFile, banner, smartMergeWorkflow } = require('./utils/helpers');
22
23
 
23
24
  const WorkflowAnalyzer = require('./analyzers/workflow');
@@ -105,8 +106,7 @@ class CIFlow {
105
106
  // ── 7. Generate CI/CD workflow(s) ─────────────────────────────────
106
107
  spinner.start('Generating workflow(s)...');
107
108
  const generator = new WorkflowGenerator(finalConfig, this.projectPath);
108
- const workflows = generator.generate();
109
- spinner.succeed(chalk.green(`Generated ${workflows.length} CI workflow(s)`));
109
+ let workflows = generator.generate();
110
110
 
111
111
  // ── 8. Generate dependabot.yml ────────────────────────────────────
112
112
  const dependabotGen = new DependabotGenerator(codebaseInfo);
@@ -121,6 +121,11 @@ class CIFlow {
121
121
  if (releaseWorkflow) workflows.push(releaseWorkflow);
122
122
  }
123
123
 
124
+ if (this._workflowLayout(finalConfig) !== 'split') {
125
+ workflows = [combineWorkflows(workflows, { config: finalConfig, releaseInfo: combinedReleaseInfo })];
126
+ }
127
+ spinner.succeed(chalk.green(`Generated ${workflows.length} workflow file(s)`));
128
+
124
129
  // ── 10. Write files ────────────────────────────────────────────────
125
130
  if (this.dryRun) {
126
131
  this._dryRunPrint(workflows, dependabotFile);
@@ -253,6 +258,11 @@ class CIFlow {
253
258
  console.log('');
254
259
  }
255
260
 
261
+ _workflowLayout(config) {
262
+ const layout = config && config._config && config._config.workflowLayout;
263
+ return layout === 'split' ? 'split' : 'single';
264
+ }
265
+
256
266
  async _interactiveConfirm(config) {
257
267
  const { confirmed } = await inquirer.prompt([
258
268
  {
@@ -0,0 +1,238 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const yaml = require('js-yaml');
5
+ const { version } = require('../../package.json');
6
+
7
+ function stripHeader(content) {
8
+ return content.replace(/^(?:#[^\n]*\n)+\n?/, '');
9
+ }
10
+
11
+ function isPlainObject(value) {
12
+ return !!value && typeof value === 'object' && !Array.isArray(value);
13
+ }
14
+
15
+ function dedupeArray(values) {
16
+ const seen = new Set();
17
+ const result = [];
18
+
19
+ for (const value of values) {
20
+ const key = isPlainObject(value) || Array.isArray(value)
21
+ ? JSON.stringify(value)
22
+ : String(value);
23
+ if (seen.has(key)) continue;
24
+ seen.add(key);
25
+ result.push(value);
26
+ }
27
+
28
+ return result;
29
+ }
30
+
31
+ function mergeDeep(target, source) {
32
+ if (Array.isArray(target) || Array.isArray(source)) {
33
+ return dedupeArray([...(target || []), ...(source || [])]);
34
+ }
35
+ if (isPlainObject(target) && isPlainObject(source)) {
36
+ const merged = { ...target };
37
+ for (const [key, value] of Object.entries(source)) {
38
+ merged[key] = key in merged ? mergeDeep(merged[key], value) : value;
39
+ }
40
+ return merged;
41
+ }
42
+ return source === undefined ? target : source;
43
+ }
44
+
45
+ function mergePermissions(target, source) {
46
+ const rank = { none: 0, read: 1, write: 2 };
47
+ const merged = { ...(target || {}) };
48
+
49
+ for (const [key, value] of Object.entries(source || {})) {
50
+ const current = merged[key];
51
+ if (!current) {
52
+ merged[key] = value;
53
+ continue;
54
+ }
55
+ merged[key] = (rank[value] || 0) > (rank[current] || 0) ? value : current;
56
+ }
57
+
58
+ return merged;
59
+ }
60
+
61
+ function prefixJobMap(jobs, prefix) {
62
+ const mapping = {};
63
+ for (const jobId of Object.keys(jobs || {})) {
64
+ mapping[jobId] = `${prefix}_${jobId}`;
65
+ }
66
+
67
+ const prefixed = {};
68
+ for (const [jobId, job] of Object.entries(jobs || {})) {
69
+ const nextJob = JSON.parse(JSON.stringify(job));
70
+ if (typeof nextJob.needs === 'string') {
71
+ nextJob.needs = mapping[nextJob.needs] || nextJob.needs;
72
+ } else if (Array.isArray(nextJob.needs)) {
73
+ nextJob.needs = nextJob.needs.map((need) => mapping[need] || need);
74
+ }
75
+ prefixed[mapping[jobId]] = nextJob;
76
+ }
77
+
78
+ return prefixed;
79
+ }
80
+
81
+ function unwrapExpression(expr) {
82
+ if (typeof expr !== 'string') return '';
83
+ return expr.trim().replace(/^\${{\s*/, '').replace(/\s*}}$/, '');
84
+ }
85
+
86
+ function quote(value) {
87
+ return String(value).replace(/'/g, "\\'");
88
+ }
89
+
90
+ function buildBranchCondition(branches, refName) {
91
+ if (!Array.isArray(branches) || branches.length === 0) return '';
92
+ return branches
93
+ .map((branch) => `${refName} == '${quote(branch)}'`)
94
+ .join(' || ');
95
+ }
96
+
97
+ function buildTriggerCondition(onConfig) {
98
+ if (!onConfig) return '';
99
+
100
+ if (typeof onConfig === 'string') {
101
+ return `github.event_name == '${quote(onConfig)}'`;
102
+ }
103
+
104
+ if (Array.isArray(onConfig)) {
105
+ return onConfig
106
+ .map((eventName) => `github.event_name == '${quote(eventName)}'`)
107
+ .join(' || ');
108
+ }
109
+
110
+ const clauses = [];
111
+
112
+ for (const [eventName, eventConfig] of Object.entries(onConfig)) {
113
+ if (eventName === 'push') {
114
+ const branchExpr = buildBranchCondition(eventConfig && eventConfig.branches, 'github.ref_name');
115
+ clauses.push(branchExpr ? `(github.event_name == 'push' && (${branchExpr}))` : "github.event_name == 'push'");
116
+ continue;
117
+ }
118
+
119
+ if (eventName === 'pull_request') {
120
+ const branchExpr = buildBranchCondition(eventConfig && eventConfig.branches, 'github.base_ref');
121
+ clauses.push(branchExpr ? `(github.event_name == 'pull_request' && (${branchExpr}))` : "github.event_name == 'pull_request'");
122
+ continue;
123
+ }
124
+
125
+ if (eventName === 'workflow_dispatch') {
126
+ clauses.push("github.event_name == 'workflow_dispatch'");
127
+ continue;
128
+ }
129
+
130
+ if (eventName === 'schedule') {
131
+ clauses.push("github.event_name == 'schedule'");
132
+ continue;
133
+ }
134
+
135
+ clauses.push(`github.event_name == '${quote(eventName)}'`);
136
+ }
137
+
138
+ return clauses.join(' || ');
139
+ }
140
+
141
+ function applyTriggerCondition(job, triggerCondition) {
142
+ if (!triggerCondition) return job;
143
+
144
+ const nextJob = { ...job };
145
+ const existingIf = unwrapExpression(nextJob.if);
146
+ nextJob.if = existingIf
147
+ ? `(${triggerCondition}) && (${existingIf})`
148
+ : triggerCondition;
149
+ return nextJob;
150
+ }
151
+
152
+ function collectRequiredSecrets(config, releaseInfo) {
153
+ const secrets = new Set();
154
+
155
+ for (const hosting of config.hosting || []) {
156
+ for (const secret of hosting.secrets || []) {
157
+ secrets.add(secret);
158
+ }
159
+ }
160
+
161
+ for (const secret of (config.envVars && config.envVars.secrets) || []) {
162
+ secrets.add(secret);
163
+ }
164
+
165
+ if (releaseInfo && releaseInfo.requiresNpmToken) {
166
+ secrets.add('NPM_TOKEN');
167
+ }
168
+
169
+ return [...secrets];
170
+ }
171
+
172
+ function buildHeader(sections, config, releaseInfo) {
173
+ const sectionList = dedupeArray(sections).join(', ');
174
+ const requiredSecrets = collectRequiredSecrets(config, releaseInfo);
175
+ const secretsDoc = requiredSecrets.length > 0
176
+ ? `# Required secrets: ${requiredSecrets.join(', ')}\n# Add these at: Settings → Secrets and Variables → Actions\n`
177
+ : '';
178
+
179
+ return (
180
+ `# Generated by cistack v${version} — https://github.com/cistack\n` +
181
+ `# Unified Pipeline: ${sectionList}\n` +
182
+ `${secretsDoc}` +
183
+ `# Dependabot remains in .github/dependabot.yml\n\n`
184
+ );
185
+ }
186
+
187
+ function combineWorkflows(workflows, options = {}) {
188
+ const config = options.config || {};
189
+ const releaseInfo = options.releaseInfo || null;
190
+ const combined = {
191
+ name: 'Pipeline',
192
+ on: {},
193
+ concurrency: {
194
+ group: '${{ github.workflow }}-${{ github.ref }}',
195
+ 'cancel-in-progress': true,
196
+ },
197
+ jobs: {},
198
+ };
199
+ const sections = [];
200
+
201
+ for (const workflow of workflows.filter(Boolean)) {
202
+ const parsed = yaml.load(stripHeader(workflow.content));
203
+ const triggerCondition = buildTriggerCondition(parsed.on || {});
204
+ const prefix = path.basename(workflow.filename, path.extname(workflow.filename))
205
+ .replace(/[^a-z0-9]+/gi, '_')
206
+ .replace(/^_+|_+$/g, '')
207
+ .toLowerCase();
208
+
209
+ sections.push(prefix);
210
+ combined.on = mergeDeep(combined.on, parsed.on || {});
211
+ if (parsed.env) {
212
+ combined.env = mergeDeep(combined.env || {}, parsed.env);
213
+ }
214
+ if (parsed.permissions) {
215
+ combined.permissions = mergePermissions(combined.permissions, parsed.permissions);
216
+ }
217
+
218
+ const prefixedJobs = prefixJobMap(parsed.jobs, prefix);
219
+ for (const [jobId, job] of Object.entries(prefixedJobs)) {
220
+ combined.jobs[jobId] = applyTriggerCondition(job, triggerCondition);
221
+ }
222
+ }
223
+
224
+ const raw = yaml.dump(combined, {
225
+ indent: 2,
226
+ lineWidth: 120,
227
+ quotingType: "'",
228
+ forceQuotes: false,
229
+ noRefs: true,
230
+ });
231
+
232
+ return {
233
+ filename: 'pipeline.yml',
234
+ content: buildHeader(sections, config, releaseInfo) + raw,
235
+ };
236
+ }
237
+
238
+ module.exports = combineWorkflows;
package/tests/run.js CHANGED
@@ -17,6 +17,7 @@ const ReleaseDetector = require('../src/detectors/release');
17
17
  const TestingDetector = require('../src/detectors/testing');
18
18
  const WorkflowGenerator = require('../src/generators/workflow');
19
19
  const ReleaseGenerator = require('../src/generators/release');
20
+ const combineWorkflows = require('../src/utils/workflow-combiner');
20
21
  const { smartMergeWorkflow } = require('../src/utils/helpers');
21
22
 
22
23
  const repoRoot = path.resolve(__dirname, '..');
@@ -324,6 +325,106 @@ test('WorkflowGenerator uses the detected default branch across CI, deploy, and
324
325
  assert.deepEqual(byName['security.yml'].on.push.branches, ['release']);
325
326
  });
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
+
327
428
  test('Frontend Lighthouse is omitted when no build job exists', () => {
328
429
  const projectDir = makeTempDir();
329
430
  const generator = new WorkflowGenerator(
@@ -576,7 +677,7 @@ test('Per-package CI picks workspace build scripts instead of only reading the r
576
677
  makeJsProject({
577
678
  testing: [{ name: 'Vitest', type: 'unit', confidence: 1, command: 'npm run test' }],
578
679
  monorepoPackages: [pkg],
579
- _config: { monorepo: { perPackage: true } },
680
+ _config: { workflowLayout: 'split', monorepo: { perPackage: true } },
580
681
  }),
581
682
  projectDir
582
683
  );
@@ -635,7 +736,7 @@ test('Monorepo root CI installs dependencies at the repo root and does not hide
635
736
  languages: [{ name: 'JavaScript', packageManager: 'yarn', nodeVersion: '20' }],
636
737
  monorepoPackages: packages,
637
738
  lockFiles: ['yarn.lock'],
638
- _config: { monorepo: { perPackage: true } },
739
+ _config: { workflowLayout: 'split', monorepo: { perPackage: true } },
639
740
  }),
640
741
  projectDir
641
742
  );
@@ -680,7 +781,7 @@ test('Bun monorepo matrix commands are scoped to the workspace path', () => {
680
781
  frameworks: [{ name: 'React', confidence: 1, buildDir: 'dist' }],
681
782
  languages: [{ name: 'JavaScript', packageManager: 'bun', nodeVersion: '20' }],
682
783
  monorepoPackages: packages,
683
- _config: { monorepo: { perPackage: true } },
784
+ _config: { workflowLayout: 'split', monorepo: { perPackage: true } },
684
785
  }),
685
786
  projectDir
686
787
  );
@@ -776,6 +877,31 @@ test('CLI smoke test still generates workflows in dry-run mode', () => {
776
877
  stdio: ['ignore', 'pipe', 'pipe'],
777
878
  });
778
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
+
779
905
  assert(output.includes('.github/workflows/ci.yml'));
780
906
  assert(output.includes('.github/workflows/security.yml'));
781
907
  assert(output.includes('Done! Your GitHub Actions pipeline is ready.'));