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 +17 -30
- package/bin/ciflow.js +2 -1
- package/index.d.ts +1 -0
- package/package.json +1 -1
- package/src/config/loader.js +1 -0
- package/src/generators/dependabot.js +5 -0
- package/src/generators/workflow.js +14 -10
- package/src/index.js +29 -7
- package/src/utils/workflow-combiner.js +238 -0
- package/tests/run.js +179 -3
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
|
-
### `
|
|
79
|
+
### `pipeline.yml`
|
|
80
80
|
|
|
81
|
-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
86
|
-
-
|
|
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
|
-
### `
|
|
88
|
+
### `dependabot.yml`
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
Dependabot remains a separate file in `.github/dependabot.yml`, because it is not a GitHub Actions workflow.
|
|
91
91
|
|
|
92
|
-
|
|
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
|
-
|
|
94
|
+
If you prefer the old multi-file layout, set:
|
|
98
95
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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
package/package.json
CHANGED
package/src/config/loader.js
CHANGED
|
@@ -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:
|
|
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.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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.'));
|