cistack 4.0.0 → 5.1.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.1.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'
@@ -150,6 +150,11 @@ class DependabotGenerator {
150
150
  directory: '/',
151
151
  schedule: { interval: 'weekly', day: 'monday' },
152
152
  'open-pull-requests-limit': 10,
153
+ groups: {
154
+ 'github-actions-updates': {
155
+ patterns: ['*'],
156
+ },
157
+ },
153
158
  });
154
159
 
155
160
  const doc = { version: 2, updates };
@@ -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 ${this._workflowCountLabel(workflows.length)}`));
128
+
124
129
  // ── 10. Write files ────────────────────────────────────────────────
125
130
  if (this.dryRun) {
126
131
  this._dryRunPrint(workflows, dependabotFile);
@@ -131,7 +136,11 @@ class CIFlow {
131
136
 
132
137
  console.log('\n' + chalk.bold.green('✅ Done! Your GitHub Actions pipeline is ready.'));
133
138
  if (!this.dryRun) {
134
- console.log(chalk.dim(` Workflows ${this.outputDir}`));
139
+ if (workflows.length === 1) {
140
+ console.log(chalk.dim(` Pipeline → ${path.join(this.outputDir, workflows[0].filename)}`));
141
+ } else {
142
+ console.log(chalk.dim(` Workflows → ${this.outputDir}`));
143
+ }
135
144
  console.log(chalk.dim(` Dependabot → ${path.join(this.projectPath, '.github', 'dependabot.yml')}\n`));
136
145
  }
137
146
  } catch (err) {
@@ -253,6 +262,19 @@ class CIFlow {
253
262
  console.log('');
254
263
  }
255
264
 
265
+ _workflowLayout(config) {
266
+ const layout = config && config._config && config._config.workflowLayout;
267
+ return layout === 'split' ? 'split' : 'single';
268
+ }
269
+
270
+ _workflowCountLabel(count) {
271
+ return `${count} workflow file${count === 1 ? '' : 's'}`;
272
+ }
273
+
274
+ _displayWorkflowPath(filename) {
275
+ return path.posix.join('.github/workflows', filename);
276
+ }
277
+
256
278
  async _interactiveConfirm(config) {
257
279
  const { confirmed } = await inquirer.prompt([
258
280
  {
@@ -367,11 +389,11 @@ class CIFlow {
367
389
  const { content: merged, changes } = smartMergeWorkflow(existing, wf.content);
368
390
 
369
391
  if (changes.length === 0) {
370
- console.log(chalk.dim(` ○ No changes: ${wf.filename}`));
392
+ console.log(chalk.dim(` ○ No changes: ${this._displayWorkflowPath(wf.filename)}`));
371
393
  continue;
372
394
  }
373
395
 
374
- console.log(chalk.yellow(` ↻ Smart-merged: ${wf.filename}`));
396
+ console.log(chalk.yellow(` ↻ Smart-merged: ${this._displayWorkflowPath(wf.filename)}`));
375
397
  for (const c of changes) {
376
398
  console.log(chalk.dim(` • ${c}`));
377
399
  }
@@ -379,10 +401,10 @@ class CIFlow {
379
401
  writeFile(filePath, merged);
380
402
  } else if (exists && this.force) {
381
403
  writeFile(filePath, wf.content);
382
- console.log(chalk.green(` ✔ Overwritten: ${wf.filename}`));
404
+ console.log(chalk.green(` ✔ Overwritten: ${this._displayWorkflowPath(wf.filename)}`));
383
405
  } else {
384
406
  writeFile(filePath, wf.content);
385
- console.log(chalk.green(` ✔ Written: ${wf.filename}`));
407
+ console.log(chalk.green(` ✔ Written: ${this._displayWorkflowPath(wf.filename)}`));
386
408
  }
387
409
  }
388
410
  }
@@ -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, '..');
@@ -212,6 +213,33 @@ test('Dependabot skips empty npm manifests and uses the bun ecosystem for bun.lo
212
213
  assert(!bunDependabot.updates.some((entry) => entry['package-ecosystem'] === 'npm'));
213
214
  });
214
215
 
216
+ test('Dependabot groups GitHub Actions updates into a single pull request', async () => {
217
+ const projectDir = makeTempDir();
218
+ writeFiles(projectDir, {
219
+ '.github/workflows/ci.yml': [
220
+ 'name: CI',
221
+ 'jobs:',
222
+ ' test:',
223
+ ' runs-on: ubuntu-latest',
224
+ ' steps:',
225
+ ' - uses: actions/checkout@v4',
226
+ ' - uses: actions/setup-node@v4',
227
+ ' - uses: actions/upload-artifact@v4',
228
+ ].join('\n'),
229
+ });
230
+
231
+ const info = await new CodebaseAnalyzer(projectDir).analyse();
232
+ const dependabot = parseWorkflow(new DependabotGenerator(info).generate().content);
233
+ const gha = dependabot.updates.find((entry) => entry['package-ecosystem'] === 'github-actions');
234
+
235
+ assert(gha);
236
+ assert.deepEqual(gha.groups, {
237
+ 'github-actions-updates': {
238
+ patterns: ['*'],
239
+ },
240
+ });
241
+ });
242
+
215
243
  test('GCP App Engine detection documents only the secrets the generated deploy flow uses', async () => {
216
244
  const projectDir = makeTempDir();
217
245
  writeFiles(projectDir, {
@@ -324,6 +352,106 @@ test('WorkflowGenerator uses the detected default branch across CI, deploy, and
324
352
  assert.deepEqual(byName['security.yml'].on.push.branches, ['release']);
325
353
  });
326
354
 
355
+ test('combineWorkflows collapses generated workflows into a single pipeline file', () => {
356
+ const projectDir = makeTempDir();
357
+ writeFiles(projectDir, {
358
+ 'package.json': json({
359
+ name: 'combined-pipeline-app',
360
+ version: '1.0.0',
361
+ scripts: {
362
+ test: 'echo ok',
363
+ },
364
+ }),
365
+ });
366
+
367
+ const config = makeJsProject({
368
+ hosting: [{ name: 'Vercel', secrets: ['VERCEL_TOKEN', 'VERCEL_ORG_ID', 'VERCEL_PROJECT_ID'] }],
369
+ testing: [{ name: 'Vitest', type: 'unit', confidence: 1, command: 'npm run test' }],
370
+ });
371
+ config.releaseInfo = { tool: 'custom', command: 'npm run release', publishToNpm: false, requiresNpmToken: false };
372
+ const workflows = new WorkflowGenerator(config, projectDir).generate();
373
+ workflows.push(new ReleaseGenerator(config.releaseInfo, config, projectDir).generate());
374
+
375
+ const combined = combineWorkflows(workflows, { config, releaseInfo: config.releaseInfo });
376
+ const parsed = parseWorkflow(combined.content);
377
+
378
+ assert.equal(combined.filename, 'pipeline.yml');
379
+ assert(parsed.jobs.ci_lint);
380
+ assert(parsed.jobs.deploy_deploy);
381
+ assert(parsed.jobs.security_security);
382
+ assert(parsed.jobs.release_release);
383
+ });
384
+
385
+ test('combineWorkflows preserves workflow-specific trigger scoping in the unified pipeline', () => {
386
+ const projectDir = makeTempDir();
387
+ writeFiles(projectDir, {
388
+ 'package.json': json({
389
+ name: 'combined-trigger-app',
390
+ version: '1.0.0',
391
+ scripts: {
392
+ test: 'echo ok',
393
+ build: 'echo build',
394
+ release: 'echo release',
395
+ },
396
+ }),
397
+ });
398
+
399
+ const config = makeJsProject({
400
+ hosting: [{ name: 'Vercel', secrets: ['VERCEL_TOKEN', 'VERCEL_ORG_ID', 'VERCEL_PROJECT_ID'] }],
401
+ frameworks: [{ name: 'Next.js', confidence: 1, buildDir: '.next' }],
402
+ testing: [{ name: 'Vitest', type: 'unit', confidence: 1, command: 'npm run test' }],
403
+ });
404
+ config.releaseInfo = { tool: 'custom', command: 'npm run release', publishToNpm: false, requiresNpmToken: false };
405
+
406
+ const workflows = new WorkflowGenerator(config, projectDir).generate();
407
+ workflows.push(new ReleaseGenerator(config.releaseInfo, config, projectDir).generate());
408
+
409
+ const combined = parseWorkflow(combineWorkflows(workflows, { config, releaseInfo: config.releaseInfo }).content);
410
+
411
+ assert.deepEqual(combined.on.push.branches, ['main', 'master', 'develop']);
412
+ assert(combined.jobs.ci_lint.if.includes("github.event_name == 'push'"));
413
+ assert(combined.jobs.ci_lint.if.includes("github.event_name == 'pull_request'"));
414
+ assert(!combined.jobs.ci_lint.if.includes("github.event_name == 'schedule'"));
415
+ assert(combined.jobs.security_security.if.includes("github.event_name == 'schedule'"));
416
+ assert(combined.jobs.deploy_deploy.if.includes("github.ref_name == 'main'"));
417
+ assert(combined.jobs.deploy_deploy.if.includes("github.ref_name == 'master'"));
418
+ assert(!combined.jobs.deploy_deploy.if.includes("github.ref_name == 'develop'"));
419
+ });
420
+
421
+ test('Single-layout monorepos still generate the root workspace matrix CI', () => {
422
+ const projectDir = makeTempDir();
423
+ const packages = [
424
+ {
425
+ name: 'app',
426
+ relativePath: 'packages/app',
427
+ packageJson: {
428
+ name: 'app',
429
+ scripts: {
430
+ lint: 'echo lint',
431
+ test: 'echo test',
432
+ build: 'echo build',
433
+ },
434
+ },
435
+ },
436
+ ];
437
+
438
+ const generator = new WorkflowGenerator(
439
+ makeJsProject({
440
+ frameworks: [{ name: 'React', confidence: 1, buildDir: 'dist' }],
441
+ testing: [{ name: 'Vitest', type: 'unit', confidence: 1, command: 'npm run test' }],
442
+ monorepoPackages: packages,
443
+ _config: { monorepo: { perPackage: true } },
444
+ }),
445
+ projectDir
446
+ );
447
+
448
+ const ciWorkflow = parseWorkflow(generator.generate().find((workflow) => workflow.filename === 'ci.yml').content);
449
+
450
+ assert(ciWorkflow.jobs.ci);
451
+ assert(ciWorkflow.jobs.ci.strategy);
452
+ assert.equal(ciWorkflow.jobs.ci.steps.find((step) => step.name === 'Lint').if, '${{ matrix.lintScript != \'\' }}');
453
+ });
454
+
327
455
  test('Frontend Lighthouse is omitted when no build job exists', () => {
328
456
  const projectDir = makeTempDir();
329
457
  const generator = new WorkflowGenerator(
@@ -576,7 +704,7 @@ test('Per-package CI picks workspace build scripts instead of only reading the r
576
704
  makeJsProject({
577
705
  testing: [{ name: 'Vitest', type: 'unit', confidence: 1, command: 'npm run test' }],
578
706
  monorepoPackages: [pkg],
579
- _config: { monorepo: { perPackage: true } },
707
+ _config: { workflowLayout: 'split', monorepo: { perPackage: true } },
580
708
  }),
581
709
  projectDir
582
710
  );
@@ -635,7 +763,7 @@ test('Monorepo root CI installs dependencies at the repo root and does not hide
635
763
  languages: [{ name: 'JavaScript', packageManager: 'yarn', nodeVersion: '20' }],
636
764
  monorepoPackages: packages,
637
765
  lockFiles: ['yarn.lock'],
638
- _config: { monorepo: { perPackage: true } },
766
+ _config: { workflowLayout: 'split', monorepo: { perPackage: true } },
639
767
  }),
640
768
  projectDir
641
769
  );
@@ -680,7 +808,7 @@ test('Bun monorepo matrix commands are scoped to the workspace path', () => {
680
808
  frameworks: [{ name: 'React', confidence: 1, buildDir: 'dist' }],
681
809
  languages: [{ name: 'JavaScript', packageManager: 'bun', nodeVersion: '20' }],
682
810
  monorepoPackages: packages,
683
- _config: { monorepo: { perPackage: true } },
811
+ _config: { workflowLayout: 'split', monorepo: { perPackage: true } },
684
812
  }),
685
813
  projectDir
686
814
  );
@@ -776,6 +904,54 @@ test('CLI smoke test still generates workflows in dry-run mode', () => {
776
904
  stdio: ['ignore', 'pipe', 'pipe'],
777
905
  });
778
906
 
907
+ assert(output.includes('.github/workflows/pipeline.yml'));
908
+ assert(output.includes('.github/dependabot.yml'));
909
+ assert(output.includes('Unified Pipeline'));
910
+ assert(output.includes('Done! Your GitHub Actions pipeline is ready.'));
911
+ });
912
+
913
+ test('CLI write output shows the pipeline file path in single-layout mode', () => {
914
+ const projectDir = makeTempDir();
915
+ writeFiles(projectDir, {
916
+ 'package.json': json({
917
+ name: 'cli-write-app',
918
+ version: '1.0.0',
919
+ scripts: {
920
+ test: 'echo ok',
921
+ },
922
+ }),
923
+ });
924
+
925
+ const output = execFileSync(process.execPath, ['bin/ciflow.js', 'generate', '--path', projectDir, '--no-prompt'], {
926
+ cwd: repoRoot,
927
+ encoding: 'utf8',
928
+ stdio: ['ignore', 'pipe', 'pipe'],
929
+ });
930
+
931
+ assert(output.includes('✔ Written: .github/workflows/pipeline.yml'));
932
+ assert(output.includes(`Pipeline → ${path.join(projectDir, '.github', 'workflows', 'pipeline.yml')}`));
933
+ assert(output.includes(`Dependabot → ${path.join(projectDir, '.github', 'dependabot.yml')}`));
934
+ });
935
+
936
+ test('CLI supports opting back into split workflow files', () => {
937
+ const projectDir = makeTempDir();
938
+ writeFiles(projectDir, {
939
+ 'package.json': json({
940
+ name: 'cli-split-app',
941
+ version: '1.0.0',
942
+ scripts: {
943
+ test: 'echo ok',
944
+ },
945
+ }),
946
+ 'cistack.config.js': `module.exports = { workflowLayout: 'split' };\n`,
947
+ });
948
+
949
+ const output = execFileSync(process.execPath, ['bin/ciflow.js', 'generate', '--path', projectDir, '--dry-run', '--no-prompt'], {
950
+ cwd: repoRoot,
951
+ encoding: 'utf8',
952
+ stdio: ['ignore', 'pipe', 'pipe'],
953
+ });
954
+
779
955
  assert(output.includes('.github/workflows/ci.yml'));
780
956
  assert(output.includes('.github/workflows/security.yml'));
781
957
  assert(output.includes('Done! Your GitHub Actions pipeline is ready.'));