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 +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/workflow.js +14 -10
- package/src/index.js +12 -2
- package/src/utils/workflow-combiner.js +238 -0
- package/tests/run.js +129 -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'
|
|
@@ -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 ${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.'));
|