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/.github/workflows/ci.yml +70 -0
- package/README.md +183 -124
- package/bin/ciflow.js +3 -2
- package/index.d.ts +91 -0
- package/package.json +4 -2
- package/src/analyzers/codebase.js +43 -6
- package/src/analyzers/monorepo.js +7 -7
- package/src/analyzers/workflow.js +10 -2
- package/src/config/loader.js +66 -10
- package/src/detectors/framework.js +29 -25
- package/src/detectors/hosting.js +25 -10
- package/src/detectors/language.js +2 -2
- package/src/detectors/release.js +29 -8
- package/src/detectors/testing.js +18 -2
- package/src/generators/dependabot.js +24 -3
- package/src/generators/release.js +25 -10
- package/src/generators/workflow.js +330 -99
- package/src/index.js +33 -18
- package/src/utils/helpers.js +21 -7
- package/src/utils/workflow-combiner.js +238 -0
- package/tests/run.js +934 -0
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');
|
|
@@ -42,17 +43,16 @@ class CIFlow {
|
|
|
42
43
|
try {
|
|
43
44
|
// ── 1. Load cistack.config.js ─────────────────────────────────────
|
|
44
45
|
const configLoader = new ConfigLoader(this.projectPath);
|
|
45
|
-
const userConfig = configLoader.load();
|
|
46
|
+
const userConfig = await configLoader.load();
|
|
46
47
|
if (Object.keys(userConfig).length > 0) {
|
|
47
48
|
spinner.info(chalk.cyan('cistack.config.js loaded'));
|
|
48
|
-
spinner.start('Scanning project...');
|
|
49
|
-
// Allow config to override outputDir
|
|
50
49
|
if (userConfig.outputDir) {
|
|
51
50
|
this.outputDir = path.join(this.projectPath, userConfig.outputDir);
|
|
52
51
|
}
|
|
53
52
|
}
|
|
54
53
|
|
|
55
54
|
// ── 2. Analyse the codebase ───────────────────────────────────────
|
|
55
|
+
if (!spinner.isSpinning) spinner.start('Scanning project...');
|
|
56
56
|
const analyzer = new CodebaseAnalyzer(this.projectPath, { verbose: this.verbose });
|
|
57
57
|
const codebaseInfo = await analyzer.analyse();
|
|
58
58
|
spinner.succeed(chalk.green('Project scanned'));
|
|
@@ -81,13 +81,22 @@ class CIFlow {
|
|
|
81
81
|
frameworks,
|
|
82
82
|
languages,
|
|
83
83
|
testing,
|
|
84
|
+
releaseInfo,
|
|
84
85
|
envVars,
|
|
85
86
|
monorepoPackages,
|
|
87
|
+
lockFiles: codebaseInfo.lockFiles,
|
|
88
|
+
defaultBranch: codebaseInfo.defaultBranch,
|
|
89
|
+
currentBranch: codebaseInfo.currentBranch,
|
|
86
90
|
_config: userConfig,
|
|
87
91
|
});
|
|
88
92
|
|
|
89
93
|
// ── 5. Print summary ───────────────────────────────────────────────
|
|
90
|
-
this._printSummary(
|
|
94
|
+
this._printSummary(
|
|
95
|
+
finalConfig,
|
|
96
|
+
finalConfig.releaseInfo || releaseInfo,
|
|
97
|
+
finalConfig.envVars || envVars,
|
|
98
|
+
finalConfig.monorepoPackages || monorepoPackages
|
|
99
|
+
);
|
|
91
100
|
|
|
92
101
|
// ── 6. Optional interactive confirmation ──────────────────────────
|
|
93
102
|
if (this.prompt) {
|
|
@@ -97,21 +106,26 @@ class CIFlow {
|
|
|
97
106
|
// ── 7. Generate CI/CD workflow(s) ─────────────────────────────────
|
|
98
107
|
spinner.start('Generating workflow(s)...');
|
|
99
108
|
const generator = new WorkflowGenerator(finalConfig, this.projectPath);
|
|
100
|
-
|
|
101
|
-
spinner.succeed(chalk.green(`Generated ${workflows.length} CI workflow(s)`));
|
|
109
|
+
let workflows = generator.generate();
|
|
102
110
|
|
|
103
111
|
// ── 8. Generate dependabot.yml ────────────────────────────────────
|
|
104
112
|
const dependabotGen = new DependabotGenerator(codebaseInfo);
|
|
105
113
|
const dependabotFile = dependabotGen.generate();
|
|
106
114
|
|
|
107
|
-
// ── 9. Generate release.yml (if release tooling detected)
|
|
115
|
+
// ── 9. Generate release.yml (if release tooling detected or configured) ─
|
|
108
116
|
let releaseWorkflow = null;
|
|
109
|
-
|
|
110
|
-
|
|
117
|
+
const combinedReleaseInfo = finalConfig.releaseInfo || releaseInfo;
|
|
118
|
+
if (combinedReleaseInfo) {
|
|
119
|
+
const releaseGen = new ReleaseGenerator(combinedReleaseInfo, finalConfig, this.projectPath);
|
|
111
120
|
releaseWorkflow = releaseGen.generate();
|
|
112
121
|
if (releaseWorkflow) workflows.push(releaseWorkflow);
|
|
113
122
|
}
|
|
114
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
|
+
|
|
115
129
|
// ── 10. Write files ────────────────────────────────────────────────
|
|
116
130
|
if (this.dryRun) {
|
|
117
131
|
this._dryRunPrint(workflows, dependabotFile);
|
|
@@ -244,6 +258,11 @@ class CIFlow {
|
|
|
244
258
|
console.log('');
|
|
245
259
|
}
|
|
246
260
|
|
|
261
|
+
_workflowLayout(config) {
|
|
262
|
+
const layout = config && config._config && config._config.workflowLayout;
|
|
263
|
+
return layout === 'split' ? 'split' : 'single';
|
|
264
|
+
}
|
|
265
|
+
|
|
247
266
|
async _interactiveConfirm(config) {
|
|
248
267
|
const { confirmed } = await inquirer.prompt([
|
|
249
268
|
{
|
|
@@ -388,19 +407,15 @@ class CIFlow {
|
|
|
388
407
|
ensureDir(githubDir);
|
|
389
408
|
|
|
390
409
|
if (exists && !this.force) {
|
|
410
|
+
// dependabot.yml has a fixed schema, simpler to just overwrite or keep if identical
|
|
391
411
|
const existing = fs.readFileSync(filePath, 'utf8');
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
if (changes.length === 0) {
|
|
412
|
+
if (existing.trim() === dependabotFile.content.trim()) {
|
|
395
413
|
console.log(chalk.dim(` ○ No changes: dependabot.yml`));
|
|
396
414
|
return;
|
|
397
415
|
}
|
|
398
|
-
|
|
399
|
-
writeFile(filePath,
|
|
400
|
-
console.log(chalk.yellow(` ↻
|
|
401
|
-
for (const c of changes) {
|
|
402
|
-
console.log(chalk.dim(` • ${c}`));
|
|
403
|
-
}
|
|
416
|
+
|
|
417
|
+
writeFile(filePath, dependabotFile.content);
|
|
418
|
+
console.log(chalk.yellow(` ↻ Updated: dependabot.yml (schema mismatch for smart-merge)`));
|
|
404
419
|
} else {
|
|
405
420
|
writeFile(filePath, dependabotFile.content);
|
|
406
421
|
console.log(chalk.green(` ✔ Written: .github/dependabot.yml`));
|
package/src/utils/helpers.js
CHANGED
|
@@ -129,16 +129,17 @@ function _mergeJob(existJob, genJob, jobId) {
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
// Merge steps
|
|
132
|
+
// Merge steps
|
|
133
133
|
if (genJob.steps) {
|
|
134
|
-
const existStepsByName = {};
|
|
135
|
-
for (const s of (existJob.steps || [])) {
|
|
136
|
-
if (s.name) existStepsByName[s.name] = s;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
134
|
const mergedSteps = [];
|
|
140
135
|
for (const genStep of genJob.steps) {
|
|
141
|
-
|
|
136
|
+
// Find matches by name, uses, or id
|
|
137
|
+
const existStep = (existJob.steps || []).find(s =>
|
|
138
|
+
(genStep.name && s.name === genStep.name) ||
|
|
139
|
+
(genStep.id && s.id === genStep.id) ||
|
|
140
|
+
(genStep.uses && s.uses === genStep.uses)
|
|
141
|
+
);
|
|
142
|
+
|
|
142
143
|
if (!existStep) {
|
|
143
144
|
mergedSteps.push(genStep);
|
|
144
145
|
jobChanges.push(` job "${jobId}" → added step "${genStep.name}"`);
|
|
@@ -153,6 +154,19 @@ function _mergeJob(existJob, genJob, jobId) {
|
|
|
153
154
|
}
|
|
154
155
|
}
|
|
155
156
|
}
|
|
157
|
+
// Append any existing steps that were NOT matched by a generated step
|
|
158
|
+
// This ensures user customizations are preserved.
|
|
159
|
+
for (const existStep of (existJob.steps || [])) {
|
|
160
|
+
const isMatched = genJob.steps.some((genStep) =>
|
|
161
|
+
(genStep.name && existStep.name === genStep.name) ||
|
|
162
|
+
(genStep.id && existStep.id === genStep.id) ||
|
|
163
|
+
(genStep.uses && existStep.uses === genStep.uses)
|
|
164
|
+
);
|
|
165
|
+
if (!isMatched) {
|
|
166
|
+
mergedSteps.push(existStep);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
156
170
|
merged.steps = mergedSteps;
|
|
157
171
|
}
|
|
158
172
|
|
|
@@ -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;
|