cistack 5.0.0 → 5.2.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 +6 -6
- package/package.json +1 -1
- package/src/analyzers/workflow.js +2 -2
- package/src/generators/dependabot.js +5 -0
- package/src/generators/workflow.js +0 -79
- package/src/index.js +32 -9
- package/src/utils/helpers.js +32 -0
- package/tests/run.js +181 -28
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Generate GitHub Actions CI/CD pipelines by analyzing the codebase you already have.
|
|
4
4
|
|
|
5
|
-
`cistack` scans your project, detects the stack, and writes production-ready GitHub Actions workflows for CI, deployment, Docker,
|
|
5
|
+
`cistack` scans your project, detects the stack, and writes production-ready GitHub Actions workflows for CI, deployment, Docker, and releases. It is designed for real repos, not toy demos: it reads lock files, framework signals, release config, monorepo workspaces, hosting config, and Git branch metadata before generating YAML.
|
|
6
6
|
|
|
7
7
|
## Why cistack
|
|
8
8
|
|
|
@@ -55,7 +55,7 @@ npx cistack generate --no-prompt
|
|
|
55
55
|
npx cistack audit
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
This checks
|
|
58
|
+
This checks your generated workflow directory for issues like missing concurrency blocks, outdated actions, old Node versions, and missing dependency caching. If you set `outputDir` in `cistack.config.js`, `audit` and `upgrade` will use that directory too.
|
|
59
59
|
|
|
60
60
|
### Upgrade workflow actions
|
|
61
61
|
|
|
@@ -78,11 +78,11 @@ This writes `cistack.config.js` with the supported override keys.
|
|
|
78
78
|
|
|
79
79
|
### `pipeline.yml`
|
|
80
80
|
|
|
81
|
-
By default, `cistack` now generates a single GitHub Actions workflow that combines CI, deploy, Docker,
|
|
81
|
+
By default, `cistack` now generates a single GitHub Actions workflow that combines CI, deploy, Docker, and release jobs into one place so teams can track the whole pipeline from one file.
|
|
82
82
|
|
|
83
|
-
- Includes lint, test, build, E2E, deploy, Docker,
|
|
83
|
+
- Includes lint, test, build, E2E, deploy, Docker, and release jobs when those parts of the stack are detected
|
|
84
84
|
- Uses the detected default branch or your configured `branches`
|
|
85
|
-
- Keeps preview deploys
|
|
85
|
+
- Keeps preview deploys and release jobs in the same workflow file
|
|
86
86
|
- Documents required secrets in the file header
|
|
87
87
|
|
|
88
88
|
### `dependabot.yml`
|
|
@@ -99,7 +99,7 @@ module.exports = {
|
|
|
99
99
|
};
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
-
In split mode, `cistack` writes separate `ci.yml`, `deploy.yml`, `docker.yml`,
|
|
102
|
+
In split mode, `cistack` writes separate `ci.yml`, `deploy.yml`, `docker.yml`, and `release.yml` files again.
|
|
103
103
|
|
|
104
104
|
## Supported detection
|
|
105
105
|
|
package/package.json
CHANGED
|
@@ -6,9 +6,9 @@ const yaml = require('js-yaml');
|
|
|
6
6
|
const chalk = require('chalk');
|
|
7
7
|
|
|
8
8
|
class WorkflowAnalyzer {
|
|
9
|
-
constructor(projectPath) {
|
|
9
|
+
constructor(projectPath, options = {}) {
|
|
10
10
|
this.projectPath = projectPath;
|
|
11
|
-
this.workflowsDir = path.join(projectPath, '.github/workflows');
|
|
11
|
+
this.workflowsDir = options.workflowsDir || path.join(projectPath, '.github/workflows');
|
|
12
12
|
|
|
13
13
|
// Latest stable versions for common actions
|
|
14
14
|
this.latestVersions = {
|
|
@@ -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 };
|
|
@@ -118,12 +118,6 @@ class WorkflowGenerator {
|
|
|
118
118
|
});
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
// ── 4. Security audit ────────────────────────────────────────────────
|
|
122
|
-
workflows.push({
|
|
123
|
-
filename: 'security.yml',
|
|
124
|
-
content: this._buildSecurityWorkflow(),
|
|
125
|
-
});
|
|
126
|
-
|
|
127
121
|
return workflows;
|
|
128
122
|
}
|
|
129
123
|
|
|
@@ -566,79 +560,6 @@ class WorkflowGenerator {
|
|
|
566
560
|
return this._toYaml(workflow, `# Generated by cistack v${version}\n# Docker image build and push to GHCR\n\n`);
|
|
567
561
|
}
|
|
568
562
|
|
|
569
|
-
// ══════════════════════════════════════════════════════════════════════════
|
|
570
|
-
// Security Workflow
|
|
571
|
-
// ══════════════════════════════════════════════════════════════════════════
|
|
572
|
-
|
|
573
|
-
_buildSecurityWorkflow() {
|
|
574
|
-
const lang = this.primaryLang;
|
|
575
|
-
const branches = this._resolveBranches(['main', 'master']);
|
|
576
|
-
const steps = [this._stepCheckout()];
|
|
577
|
-
|
|
578
|
-
if (['JavaScript', 'TypeScript'].includes(lang.name)) {
|
|
579
|
-
steps.push(
|
|
580
|
-
...this._setupSteps(lang),
|
|
581
|
-
this._stepInstallDeps(lang),
|
|
582
|
-
{
|
|
583
|
-
name: 'Audit dependencies',
|
|
584
|
-
run:
|
|
585
|
-
lang.packageManager === 'npm' ? 'npm audit --audit-level=high' :
|
|
586
|
-
lang.packageManager === 'yarn' ? 'yarn audit --level high' :
|
|
587
|
-
lang.packageManager === 'pnpm' ? 'pnpm audit --audit-level high' :
|
|
588
|
-
'npm audit --audit-level=high',
|
|
589
|
-
},
|
|
590
|
-
);
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
if (lang.name === 'Python') {
|
|
594
|
-
steps.push(
|
|
595
|
-
{ name: 'Set up Python', uses: 'actions/setup-python@v5', with: { 'python-version': lang.pythonVersion || '3.11' } },
|
|
596
|
-
{ name: 'Install safety', run: 'pip install safety' },
|
|
597
|
-
{ name: 'Run safety check', run: 'safety check' },
|
|
598
|
-
);
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
if (lang.name === 'Rust') {
|
|
602
|
-
steps.push(
|
|
603
|
-
{ name: 'Set up Rust', uses: 'dtolnay/rust-toolchain@stable' },
|
|
604
|
-
{ name: 'Run cargo audit', run: 'cargo install cargo-audit && cargo audit' },
|
|
605
|
-
);
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// CodeQL analysis
|
|
609
|
-
steps.push(
|
|
610
|
-
{
|
|
611
|
-
name: 'Initialize CodeQL',
|
|
612
|
-
uses: 'github/codeql-action/init@v3',
|
|
613
|
-
with: { languages: this._codeQLLanguage(lang.name) },
|
|
614
|
-
},
|
|
615
|
-
{ name: 'Perform CodeQL Analysis', uses: 'github/codeql-action/analyze@v3' },
|
|
616
|
-
);
|
|
617
|
-
|
|
618
|
-
const workflow = {
|
|
619
|
-
name: 'Security Audit',
|
|
620
|
-
on: {
|
|
621
|
-
push: { branches },
|
|
622
|
-
pull_request: { branches },
|
|
623
|
-
schedule: [{ cron: '0 6 * * 1' }],
|
|
624
|
-
},
|
|
625
|
-
permissions: {
|
|
626
|
-
actions: 'read',
|
|
627
|
-
contents: 'read',
|
|
628
|
-
'security-events': 'write',
|
|
629
|
-
},
|
|
630
|
-
jobs: {
|
|
631
|
-
security: {
|
|
632
|
-
name: '🔒 Security Audit',
|
|
633
|
-
'runs-on': 'ubuntu-latest',
|
|
634
|
-
steps: steps.filter(Boolean),
|
|
635
|
-
},
|
|
636
|
-
},
|
|
637
|
-
};
|
|
638
|
-
|
|
639
|
-
return this._toYaml(workflow, `# Generated by cistack v${version}\n# Security: dependency audit + CodeQL analysis (runs weekly)\n\n`);
|
|
640
|
-
}
|
|
641
|
-
|
|
642
563
|
// ══════════════════════════════════════════════════════════════════════════
|
|
643
564
|
// Reusable step builders
|
|
644
565
|
// ══════════════════════════════════════════════════════════════════════════
|
package/src/index.js
CHANGED
|
@@ -124,7 +124,7 @@ class CIFlow {
|
|
|
124
124
|
if (this._workflowLayout(finalConfig) !== 'split') {
|
|
125
125
|
workflows = [combineWorkflows(workflows, { config: finalConfig, releaseInfo: combinedReleaseInfo })];
|
|
126
126
|
}
|
|
127
|
-
spinner.succeed(chalk.green(`Generated ${workflows.length}
|
|
127
|
+
spinner.succeed(chalk.green(`Generated ${this._workflowCountLabel(workflows.length)}`));
|
|
128
128
|
|
|
129
129
|
// ── 10. Write files ────────────────────────────────────────────────
|
|
130
130
|
if (this.dryRun) {
|
|
@@ -136,7 +136,11 @@ class CIFlow {
|
|
|
136
136
|
|
|
137
137
|
console.log('\n' + chalk.bold.green('✅ Done! Your GitHub Actions pipeline is ready.'));
|
|
138
138
|
if (!this.dryRun) {
|
|
139
|
-
|
|
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
|
+
}
|
|
140
144
|
console.log(chalk.dim(` Dependabot → ${path.join(this.projectPath, '.github', 'dependabot.yml')}\n`));
|
|
141
145
|
}
|
|
142
146
|
} catch (err) {
|
|
@@ -151,7 +155,8 @@ class CIFlow {
|
|
|
151
155
|
const spinner = ora({ text: 'Auditing existing workflows...', color: 'cyan' }).start();
|
|
152
156
|
|
|
153
157
|
try {
|
|
154
|
-
const
|
|
158
|
+
const workflowsDir = await this._resolveWorkflowsDir();
|
|
159
|
+
const analyzer = new WorkflowAnalyzer(this.projectPath, { workflowsDir });
|
|
155
160
|
const results = await analyzer.audit();
|
|
156
161
|
spinner.succeed(chalk.green('Audit complete'));
|
|
157
162
|
|
|
@@ -195,7 +200,8 @@ class CIFlow {
|
|
|
195
200
|
const spinner = ora({ text: 'Upgrading actions...', color: 'cyan' }).start();
|
|
196
201
|
|
|
197
202
|
try {
|
|
198
|
-
const
|
|
203
|
+
const workflowsDir = await this._resolveWorkflowsDir();
|
|
204
|
+
const analyzer = new WorkflowAnalyzer(this.projectPath, { workflowsDir });
|
|
199
205
|
const results = await analyzer.upgrade(this.dryRun);
|
|
200
206
|
|
|
201
207
|
if (results.changes === 0) {
|
|
@@ -263,6 +269,23 @@ class CIFlow {
|
|
|
263
269
|
return layout === 'split' ? 'split' : 'single';
|
|
264
270
|
}
|
|
265
271
|
|
|
272
|
+
async _resolveWorkflowsDir() {
|
|
273
|
+
const configLoader = new ConfigLoader(this.projectPath);
|
|
274
|
+
const userConfig = await configLoader.load();
|
|
275
|
+
const relativeOutputDir = userConfig.outputDir || '.github/workflows';
|
|
276
|
+
return path.join(this.projectPath, relativeOutputDir);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
_workflowCountLabel(count) {
|
|
280
|
+
return `${count} workflow file${count === 1 ? '' : 's'}`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
_displayWorkflowPath(filename) {
|
|
284
|
+
const filePath = path.join(this.outputDir, filename);
|
|
285
|
+
const relativePath = path.relative(this.projectPath, filePath) || filename;
|
|
286
|
+
return relativePath.split(path.sep).join(path.posix.sep);
|
|
287
|
+
}
|
|
288
|
+
|
|
266
289
|
async _interactiveConfirm(config) {
|
|
267
290
|
const { confirmed } = await inquirer.prompt([
|
|
268
291
|
{
|
|
@@ -353,7 +376,7 @@ class CIFlow {
|
|
|
353
376
|
console.log('\n' + chalk.yellow('── DRY RUN – files not written ──\n'));
|
|
354
377
|
|
|
355
378
|
for (const wf of workflows) {
|
|
356
|
-
console.log(chalk.bold.cyan(`\n📄 .
|
|
379
|
+
console.log(chalk.bold.cyan(`\n📄 ${this._displayWorkflowPath(wf.filename)}`));
|
|
357
380
|
console.log(chalk.dim('─'.repeat(60)));
|
|
358
381
|
console.log(wf.content);
|
|
359
382
|
}
|
|
@@ -377,11 +400,11 @@ class CIFlow {
|
|
|
377
400
|
const { content: merged, changes } = smartMergeWorkflow(existing, wf.content);
|
|
378
401
|
|
|
379
402
|
if (changes.length === 0) {
|
|
380
|
-
console.log(chalk.dim(` ○ No changes: ${wf.filename}`));
|
|
403
|
+
console.log(chalk.dim(` ○ No changes: ${this._displayWorkflowPath(wf.filename)}`));
|
|
381
404
|
continue;
|
|
382
405
|
}
|
|
383
406
|
|
|
384
|
-
console.log(chalk.yellow(` ↻ Smart-merged: ${wf.filename}`));
|
|
407
|
+
console.log(chalk.yellow(` ↻ Smart-merged: ${this._displayWorkflowPath(wf.filename)}`));
|
|
385
408
|
for (const c of changes) {
|
|
386
409
|
console.log(chalk.dim(` • ${c}`));
|
|
387
410
|
}
|
|
@@ -389,10 +412,10 @@ class CIFlow {
|
|
|
389
412
|
writeFile(filePath, merged);
|
|
390
413
|
} else if (exists && this.force) {
|
|
391
414
|
writeFile(filePath, wf.content);
|
|
392
|
-
console.log(chalk.green(` ✔ Overwritten: ${wf.filename}`));
|
|
415
|
+
console.log(chalk.green(` ✔ Overwritten: ${this._displayWorkflowPath(wf.filename)}`));
|
|
393
416
|
} else {
|
|
394
417
|
writeFile(filePath, wf.content);
|
|
395
|
-
console.log(chalk.green(` ✔ Written: ${wf.filename}`));
|
|
418
|
+
console.log(chalk.green(` ✔ Written: ${this._displayWorkflowPath(wf.filename)}`));
|
|
396
419
|
}
|
|
397
420
|
}
|
|
398
421
|
}
|
package/src/utils/helpers.js
CHANGED
|
@@ -23,6 +23,25 @@ function banner() {
|
|
|
23
23
|
console.log(chalk.dim(' ' + '─'.repeat(24)) + '\n');
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
function _isCistackManaged(content) {
|
|
27
|
+
return typeof content === 'string' && content.startsWith('# Generated by cistack');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function _isManagedJobId(jobId) {
|
|
31
|
+
return [
|
|
32
|
+
'lint',
|
|
33
|
+
'test',
|
|
34
|
+
'build',
|
|
35
|
+
'lighthouse',
|
|
36
|
+
'e2e',
|
|
37
|
+
'ci',
|
|
38
|
+
'deploy',
|
|
39
|
+
'preview',
|
|
40
|
+
'release',
|
|
41
|
+
'security',
|
|
42
|
+
].includes(jobId) || /^(ci|deploy|docker|release|security)_/.test(jobId);
|
|
43
|
+
}
|
|
44
|
+
|
|
26
45
|
/**
|
|
27
46
|
* Smart diff: compare existing workflow YAML with newly generated YAML.
|
|
28
47
|
*
|
|
@@ -39,6 +58,7 @@ function banner() {
|
|
|
39
58
|
*/
|
|
40
59
|
function smartMergeWorkflow(existingContent, newContent) {
|
|
41
60
|
let existing, generated;
|
|
61
|
+
const cistackManaged = _isCistackManaged(existingContent) && _isCistackManaged(newContent);
|
|
42
62
|
|
|
43
63
|
try {
|
|
44
64
|
existing = yaml.load(existingContent);
|
|
@@ -65,6 +85,9 @@ function smartMergeWorkflow(existingContent, newContent) {
|
|
|
65
85
|
merged[key] = generated[key];
|
|
66
86
|
changes.push(`updated top-level "${key}"`);
|
|
67
87
|
}
|
|
88
|
+
} else if (cistackManaged && key in merged) {
|
|
89
|
+
delete merged[key];
|
|
90
|
+
changes.push(`removed top-level "${key}"`);
|
|
68
91
|
}
|
|
69
92
|
}
|
|
70
93
|
|
|
@@ -92,6 +115,15 @@ function smartMergeWorkflow(existingContent, newContent) {
|
|
|
92
115
|
// else — identical, keep existing
|
|
93
116
|
}
|
|
94
117
|
}
|
|
118
|
+
|
|
119
|
+
if (cistackManaged) {
|
|
120
|
+
for (const jobId of Object.keys(existing.jobs || {})) {
|
|
121
|
+
if (!(jobId in generated.jobs) && _isManagedJobId(jobId)) {
|
|
122
|
+
delete merged.jobs[jobId];
|
|
123
|
+
changes.push(`removed job "${jobId}"`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
95
127
|
}
|
|
96
128
|
|
|
97
129
|
// ── re-serialise ──────────────────────────────────────────────────────────
|
package/tests/run.js
CHANGED
|
@@ -213,6 +213,33 @@ test('Dependabot skips empty npm manifests and uses the bun ecosystem for bun.lo
|
|
|
213
213
|
assert(!bunDependabot.updates.some((entry) => entry['package-ecosystem'] === 'npm'));
|
|
214
214
|
});
|
|
215
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
|
+
|
|
216
243
|
test('GCP App Engine detection documents only the secrets the generated deploy flow uses', async () => {
|
|
217
244
|
const projectDir = makeTempDir();
|
|
218
245
|
writeFiles(projectDir, {
|
|
@@ -296,7 +323,7 @@ test('CodebaseAnalyzer detects the current git branch as the default branch fall
|
|
|
296
323
|
assert.equal(info.defaultBranch, 'release');
|
|
297
324
|
});
|
|
298
325
|
|
|
299
|
-
test('WorkflowGenerator uses the detected default branch across CI
|
|
326
|
+
test('WorkflowGenerator uses the detected default branch across CI and deploy workflows', () => {
|
|
300
327
|
const projectDir = makeTempDir();
|
|
301
328
|
writeFiles(projectDir, {
|
|
302
329
|
'package.json': json({
|
|
@@ -322,7 +349,6 @@ test('WorkflowGenerator uses the detected default branch across CI, deploy, and
|
|
|
322
349
|
|
|
323
350
|
assert.deepEqual(byName['ci.yml'].on.push.branches, ['release']);
|
|
324
351
|
assert.deepEqual(byName['deploy.yml'].on.push.branches, ['release']);
|
|
325
|
-
assert.deepEqual(byName['security.yml'].on.push.branches, ['release']);
|
|
326
352
|
});
|
|
327
353
|
|
|
328
354
|
test('combineWorkflows collapses generated workflows into a single pipeline file', () => {
|
|
@@ -351,7 +377,6 @@ test('combineWorkflows collapses generated workflows into a single pipeline file
|
|
|
351
377
|
assert.equal(combined.filename, 'pipeline.yml');
|
|
352
378
|
assert(parsed.jobs.ci_lint);
|
|
353
379
|
assert(parsed.jobs.deploy_deploy);
|
|
354
|
-
assert(parsed.jobs.security_security);
|
|
355
380
|
assert(parsed.jobs.release_release);
|
|
356
381
|
});
|
|
357
382
|
|
|
@@ -384,8 +409,6 @@ test('combineWorkflows preserves workflow-specific trigger scoping in the unifie
|
|
|
384
409
|
assert.deepEqual(combined.on.push.branches, ['main', 'master', 'develop']);
|
|
385
410
|
assert(combined.jobs.ci_lint.if.includes("github.event_name == 'push'"));
|
|
386
411
|
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
412
|
assert(combined.jobs.deploy_deploy.if.includes("github.ref_name == 'main'"));
|
|
390
413
|
assert(combined.jobs.deploy_deploy.if.includes("github.ref_name == 'master'"));
|
|
391
414
|
assert(!combined.jobs.deploy_deploy.if.includes("github.ref_name == 'develop'"));
|
|
@@ -586,28 +609,6 @@ test('Bun workflows set up Bun before installing dependencies', () => {
|
|
|
586
609
|
assert.equal(deployBuildStep.run, 'bun run build');
|
|
587
610
|
});
|
|
588
611
|
|
|
589
|
-
test('Python security workflow honors the detected Python version', () => {
|
|
590
|
-
const projectDir = makeTempDir();
|
|
591
|
-
const generator = new WorkflowGenerator(
|
|
592
|
-
{
|
|
593
|
-
hosting: [],
|
|
594
|
-
frameworks: [],
|
|
595
|
-
languages: [{ name: 'Python', packageManager: 'pip', pythonVersion: '3.12' }],
|
|
596
|
-
testing: [],
|
|
597
|
-
envVars: { secrets: [], public: [], all: [], sourceFile: null },
|
|
598
|
-
monorepoPackages: [],
|
|
599
|
-
lockFiles: [],
|
|
600
|
-
_config: {},
|
|
601
|
-
},
|
|
602
|
-
projectDir
|
|
603
|
-
);
|
|
604
|
-
|
|
605
|
-
const workflow = parseWorkflow(generator._buildSecurityWorkflow());
|
|
606
|
-
const pythonSetup = workflow.jobs.security.steps.find((step) => step.name === 'Set up Python');
|
|
607
|
-
|
|
608
|
-
assert.equal(pythonSetup.with['python-version'], '3.12');
|
|
609
|
-
});
|
|
610
|
-
|
|
611
612
|
test('smartMergeWorkflow preserves existing custom steps that cistack does not regenerate', () => {
|
|
612
613
|
const existing = [
|
|
613
614
|
'name: CI',
|
|
@@ -645,6 +646,66 @@ test('smartMergeWorkflow preserves existing custom steps that cistack does not r
|
|
|
645
646
|
assert(stepNames.includes('Build'));
|
|
646
647
|
});
|
|
647
648
|
|
|
649
|
+
test('smartMergeWorkflow removes stale managed jobs and top-level keys from previous generated workflows', () => {
|
|
650
|
+
const existing = [
|
|
651
|
+
'# Generated by cistack v5.0.0',
|
|
652
|
+
'',
|
|
653
|
+
'name: Pipeline',
|
|
654
|
+
'on:',
|
|
655
|
+
' push:',
|
|
656
|
+
' branches:',
|
|
657
|
+
' - main',
|
|
658
|
+
' schedule:',
|
|
659
|
+
' - cron: 0 6 * * 1',
|
|
660
|
+
'permissions:',
|
|
661
|
+
' contents: read',
|
|
662
|
+
' security-events: write',
|
|
663
|
+
'jobs:',
|
|
664
|
+
' ci_lint:',
|
|
665
|
+
' runs-on: ubuntu-latest',
|
|
666
|
+
' steps:',
|
|
667
|
+
' - name: Checkout code',
|
|
668
|
+
' uses: actions/checkout@v4',
|
|
669
|
+
' security_security:',
|
|
670
|
+
' runs-on: ubuntu-latest',
|
|
671
|
+
' steps:',
|
|
672
|
+
' - name: Security',
|
|
673
|
+
' run: echo audit',
|
|
674
|
+
' custom_check:',
|
|
675
|
+
' runs-on: ubuntu-latest',
|
|
676
|
+
' steps:',
|
|
677
|
+
' - name: Custom',
|
|
678
|
+
' run: echo custom',
|
|
679
|
+
'',
|
|
680
|
+
].join('\n');
|
|
681
|
+
|
|
682
|
+
const generated = [
|
|
683
|
+
'# Generated by cistack v5.1.0',
|
|
684
|
+
'',
|
|
685
|
+
'name: Pipeline',
|
|
686
|
+
'on:',
|
|
687
|
+
' push:',
|
|
688
|
+
' branches:',
|
|
689
|
+
' - main',
|
|
690
|
+
'jobs:',
|
|
691
|
+
' ci_lint:',
|
|
692
|
+
' runs-on: ubuntu-latest',
|
|
693
|
+
' steps:',
|
|
694
|
+
' - name: Checkout code',
|
|
695
|
+
' uses: actions/checkout@v4',
|
|
696
|
+
'',
|
|
697
|
+
].join('\n');
|
|
698
|
+
|
|
699
|
+
const result = smartMergeWorkflow(existing, generated);
|
|
700
|
+
const merged = yaml.load(stripHeader(result.content));
|
|
701
|
+
|
|
702
|
+
assert(!merged.on.schedule);
|
|
703
|
+
assert(!merged.permissions);
|
|
704
|
+
assert(merged.jobs.ci_lint);
|
|
705
|
+
assert(!merged.jobs.security_security);
|
|
706
|
+
assert(merged.jobs.custom_check);
|
|
707
|
+
});
|
|
708
|
+
|
|
648
709
|
test('Per-package CI picks workspace build scripts instead of only reading the root package.json', () => {
|
|
649
710
|
const projectDir = makeTempDir();
|
|
650
711
|
writeFiles(projectDir, {
|
|
@@ -883,6 +944,98 @@ test('CLI smoke test still generates workflows in dry-run mode', () => {
|
|
|
883
944
|
assert(output.includes('Done! Your GitHub Actions pipeline is ready.'));
|
|
884
945
|
});
|
|
885
946
|
|
|
947
|
+
test('CLI write output shows the pipeline file path in single-layout mode', () => {
|
|
948
|
+
const projectDir = makeTempDir();
|
|
949
|
+
writeFiles(projectDir, {
|
|
950
|
+
'package.json': json({
|
|
951
|
+
name: 'cli-write-app',
|
|
952
|
+
version: '1.0.0',
|
|
953
|
+
scripts: {
|
|
954
|
+
test: 'echo ok',
|
|
955
|
+
},
|
|
956
|
+
}),
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
const output = execFileSync(process.execPath, ['bin/ciflow.js', 'generate', '--path', projectDir, '--no-prompt'], {
|
|
960
|
+
cwd: repoRoot,
|
|
961
|
+
encoding: 'utf8',
|
|
962
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
assert(output.includes('✔ Written: .github/workflows/pipeline.yml'));
|
|
966
|
+
assert(output.includes(`Pipeline → ${path.join(projectDir, '.github', 'workflows', 'pipeline.yml')}`));
|
|
967
|
+
assert(output.includes(`Dependabot → ${path.join(projectDir, '.github', 'dependabot.yml')}`));
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
test('CLI output respects custom outputDir in write and dry-run modes', () => {
|
|
971
|
+
const projectDir = makeTempDir();
|
|
972
|
+
writeFiles(projectDir, {
|
|
973
|
+
'package.json': json({
|
|
974
|
+
name: 'cli-custom-output-app',
|
|
975
|
+
version: '1.0.0',
|
|
976
|
+
scripts: {
|
|
977
|
+
test: 'echo ok',
|
|
978
|
+
},
|
|
979
|
+
}),
|
|
980
|
+
'cistack.config.js': `module.exports = { outputDir: 'ci' };\n`,
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
const writeOutput = execFileSync(process.execPath, ['bin/ciflow.js', 'generate', '--path', projectDir, '--no-prompt'], {
|
|
984
|
+
cwd: repoRoot,
|
|
985
|
+
encoding: 'utf8',
|
|
986
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
assert(writeOutput.includes('✔ Written: ci/pipeline.yml'));
|
|
990
|
+
assert(!writeOutput.includes('.github/workflows/pipeline.yml'));
|
|
991
|
+
assert(writeOutput.includes(`Pipeline → ${path.join(projectDir, 'ci', 'pipeline.yml')}`));
|
|
992
|
+
|
|
993
|
+
const dryRunOutput = execFileSync(process.execPath, ['bin/ciflow.js', 'generate', '--path', projectDir, '--dry-run', '--no-prompt'], {
|
|
994
|
+
cwd: repoRoot,
|
|
995
|
+
encoding: 'utf8',
|
|
996
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
assert(dryRunOutput.includes('📄 ci/pipeline.yml'));
|
|
1000
|
+
assert(!dryRunOutput.includes('📄 .github/workflows/pipeline.yml'));
|
|
1001
|
+
assert(dryRunOutput.includes('.github/dependabot.yml'));
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
test('CLI audit and upgrade honor custom outputDir', () => {
|
|
1005
|
+
const projectDir = makeTempDir();
|
|
1006
|
+
writeFiles(projectDir, {
|
|
1007
|
+
'cistack.config.js': `module.exports = { outputDir: 'ci' };\n`,
|
|
1008
|
+
'ci/pipeline.yml': [
|
|
1009
|
+
'name: Pipeline',
|
|
1010
|
+
'on: push',
|
|
1011
|
+
'jobs:',
|
|
1012
|
+
' test:',
|
|
1013
|
+
' runs-on: ubuntu-latest',
|
|
1014
|
+
' steps:',
|
|
1015
|
+
' - uses: actions/checkout@v3',
|
|
1016
|
+
'',
|
|
1017
|
+
].join('\n'),
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
const auditOutput = execFileSync(process.execPath, ['bin/ciflow.js', 'audit', '--path', projectDir], {
|
|
1021
|
+
cwd: repoRoot,
|
|
1022
|
+
encoding: 'utf8',
|
|
1023
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
assert(auditOutput.includes('pipeline.yml'));
|
|
1027
|
+
assert(auditOutput.includes('Outdated action: actions/checkout@v3'));
|
|
1028
|
+
|
|
1029
|
+
const upgradeOutput = execFileSync(process.execPath, ['bin/ciflow.js', 'upgrade', '--path', projectDir], {
|
|
1030
|
+
cwd: repoRoot,
|
|
1031
|
+
encoding: 'utf8',
|
|
1032
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
assert(upgradeOutput.includes('pipeline.yml'));
|
|
1036
|
+
assert(fs.readFileSync(path.join(projectDir, 'ci', 'pipeline.yml'), 'utf8').includes('uses: actions/checkout@v4'));
|
|
1037
|
+
});
|
|
1038
|
+
|
|
886
1039
|
test('CLI supports opting back into split workflow files', () => {
|
|
887
1040
|
const projectDir = makeTempDir();
|
|
888
1041
|
writeFiles(projectDir, {
|
|
@@ -903,7 +1056,7 @@ test('CLI supports opting back into split workflow files', () => {
|
|
|
903
1056
|
});
|
|
904
1057
|
|
|
905
1058
|
assert(output.includes('.github/workflows/ci.yml'));
|
|
906
|
-
assert(output.includes('.github/workflows/
|
|
1059
|
+
assert(output.includes('.github/workflows/deploy.yml') || output.includes('.github/workflows/ci.yml'));
|
|
907
1060
|
assert(output.includes('Done! Your GitHub Actions pipeline is ready.'));
|
|
908
1061
|
});
|
|
909
1062
|
|