cistack 5.1.0 → 5.3.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/workflow.js +13 -83
- package/src/index.js +15 -4
- package/src/utils/helpers.js +32 -0
- package/tests/run.js +192 -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 = {
|
|
@@ -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
|
|
|
@@ -428,7 +422,7 @@ class WorkflowGenerator {
|
|
|
428
422
|
if (previewSteps.length > 0) {
|
|
429
423
|
jobs.preview = {
|
|
430
424
|
name: `✨ Deploy → ${h.name} (Preview)`,
|
|
431
|
-
if: "github.event_name == 'pull_request'",
|
|
425
|
+
if: "github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository",
|
|
432
426
|
'runs-on': 'ubuntu-latest',
|
|
433
427
|
environment: 'preview',
|
|
434
428
|
steps: [
|
|
@@ -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
|
// ══════════════════════════════════════════════════════════════════════════
|
|
@@ -962,20 +883,29 @@ class WorkflowGenerator {
|
|
|
962
883
|
};
|
|
963
884
|
steps.push(
|
|
964
885
|
{ name: 'Install Vercel CLI', run: 'npm install -g vercel' },
|
|
886
|
+
{
|
|
887
|
+
name: 'Validate Vercel credentials',
|
|
888
|
+
run: [
|
|
889
|
+
'test -n "$VERCEL_TOKEN" || (echo "Missing VERCEL_TOKEN secret. Add it in GitHub Actions secrets." && exit 1)',
|
|
890
|
+
'test -n "$VERCEL_ORG_ID" || (echo "Missing VERCEL_ORG_ID secret. Add it in GitHub Actions secrets." && exit 1)',
|
|
891
|
+
'test -n "$VERCEL_PROJECT_ID" || (echo "Missing VERCEL_PROJECT_ID secret. Add it in GitHub Actions secrets." && exit 1)',
|
|
892
|
+
].join('\n'),
|
|
893
|
+
env: vercelEnv,
|
|
894
|
+
},
|
|
965
895
|
{
|
|
966
896
|
name: 'Pull Vercel environment',
|
|
967
|
-
run: `vercel pull --yes --environment=${isPreview ? 'preview' : 'production'} --token
|
|
897
|
+
run: `vercel pull --yes --environment=${isPreview ? 'preview' : 'production'} --token="$VERCEL_TOKEN"`,
|
|
968
898
|
env: vercelEnv,
|
|
969
899
|
},
|
|
970
900
|
{
|
|
971
901
|
name: 'Build project',
|
|
972
|
-
run: `vercel build${prodFlag ? ' ' + prodFlag : ''} --token
|
|
902
|
+
run: `vercel build${prodFlag ? ' ' + prodFlag : ''} --token="$VERCEL_TOKEN"`,
|
|
973
903
|
env: vercelEnv,
|
|
974
904
|
},
|
|
975
905
|
{
|
|
976
906
|
name: 'Deploy to Vercel',
|
|
977
907
|
id: 'deploy',
|
|
978
|
-
run: `vercel deploy --prebuilt${prodFlag ? ' ' + prodFlag : ''} --token
|
|
908
|
+
run: `vercel deploy --prebuilt${prodFlag ? ' ' + prodFlag : ''} --token="$VERCEL_TOKEN" > deployment_url.txt && echo "DEPLOYMENT_URL=$(cat deployment_url.txt)" >> $GITHUB_ENV`,
|
|
979
909
|
env: vercelEnv,
|
|
980
910
|
},
|
|
981
911
|
);
|
package/src/index.js
CHANGED
|
@@ -155,7 +155,8 @@ class CIFlow {
|
|
|
155
155
|
const spinner = ora({ text: 'Auditing existing workflows...', color: 'cyan' }).start();
|
|
156
156
|
|
|
157
157
|
try {
|
|
158
|
-
const
|
|
158
|
+
const workflowsDir = await this._resolveWorkflowsDir();
|
|
159
|
+
const analyzer = new WorkflowAnalyzer(this.projectPath, { workflowsDir });
|
|
159
160
|
const results = await analyzer.audit();
|
|
160
161
|
spinner.succeed(chalk.green('Audit complete'));
|
|
161
162
|
|
|
@@ -199,7 +200,8 @@ class CIFlow {
|
|
|
199
200
|
const spinner = ora({ text: 'Upgrading actions...', color: 'cyan' }).start();
|
|
200
201
|
|
|
201
202
|
try {
|
|
202
|
-
const
|
|
203
|
+
const workflowsDir = await this._resolveWorkflowsDir();
|
|
204
|
+
const analyzer = new WorkflowAnalyzer(this.projectPath, { workflowsDir });
|
|
203
205
|
const results = await analyzer.upgrade(this.dryRun);
|
|
204
206
|
|
|
205
207
|
if (results.changes === 0) {
|
|
@@ -267,12 +269,21 @@ class CIFlow {
|
|
|
267
269
|
return layout === 'split' ? 'split' : 'single';
|
|
268
270
|
}
|
|
269
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
|
+
|
|
270
279
|
_workflowCountLabel(count) {
|
|
271
280
|
return `${count} workflow file${count === 1 ? '' : 's'}`;
|
|
272
281
|
}
|
|
273
282
|
|
|
274
283
|
_displayWorkflowPath(filename) {
|
|
275
|
-
|
|
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);
|
|
276
287
|
}
|
|
277
288
|
|
|
278
289
|
async _interactiveConfirm(config) {
|
|
@@ -365,7 +376,7 @@ class CIFlow {
|
|
|
365
376
|
console.log('\n' + chalk.yellow('── DRY RUN – files not written ──\n'));
|
|
366
377
|
|
|
367
378
|
for (const wf of workflows) {
|
|
368
|
-
console.log(chalk.bold.cyan(`\n📄 .
|
|
379
|
+
console.log(chalk.bold.cyan(`\n📄 ${this._displayWorkflowPath(wf.filename)}`));
|
|
369
380
|
console.log(chalk.dim('─'.repeat(60)));
|
|
370
381
|
console.log(wf.content);
|
|
371
382
|
}
|
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
|
@@ -323,7 +323,7 @@ test('CodebaseAnalyzer detects the current git branch as the default branch fall
|
|
|
323
323
|
assert.equal(info.defaultBranch, 'release');
|
|
324
324
|
});
|
|
325
325
|
|
|
326
|
-
test('WorkflowGenerator uses the detected default branch across CI
|
|
326
|
+
test('WorkflowGenerator uses the detected default branch across CI and deploy workflows', () => {
|
|
327
327
|
const projectDir = makeTempDir();
|
|
328
328
|
writeFiles(projectDir, {
|
|
329
329
|
'package.json': json({
|
|
@@ -349,7 +349,6 @@ test('WorkflowGenerator uses the detected default branch across CI, deploy, and
|
|
|
349
349
|
|
|
350
350
|
assert.deepEqual(byName['ci.yml'].on.push.branches, ['release']);
|
|
351
351
|
assert.deepEqual(byName['deploy.yml'].on.push.branches, ['release']);
|
|
352
|
-
assert.deepEqual(byName['security.yml'].on.push.branches, ['release']);
|
|
353
352
|
});
|
|
354
353
|
|
|
355
354
|
test('combineWorkflows collapses generated workflows into a single pipeline file', () => {
|
|
@@ -378,7 +377,6 @@ test('combineWorkflows collapses generated workflows into a single pipeline file
|
|
|
378
377
|
assert.equal(combined.filename, 'pipeline.yml');
|
|
379
378
|
assert(parsed.jobs.ci_lint);
|
|
380
379
|
assert(parsed.jobs.deploy_deploy);
|
|
381
|
-
assert(parsed.jobs.security_security);
|
|
382
380
|
assert(parsed.jobs.release_release);
|
|
383
381
|
});
|
|
384
382
|
|
|
@@ -411,13 +409,43 @@ test('combineWorkflows preserves workflow-specific trigger scoping in the unifie
|
|
|
411
409
|
assert.deepEqual(combined.on.push.branches, ['main', 'master', 'develop']);
|
|
412
410
|
assert(combined.jobs.ci_lint.if.includes("github.event_name == 'push'"));
|
|
413
411
|
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
412
|
assert(combined.jobs.deploy_deploy.if.includes("github.ref_name == 'main'"));
|
|
417
413
|
assert(combined.jobs.deploy_deploy.if.includes("github.ref_name == 'master'"));
|
|
418
414
|
assert(!combined.jobs.deploy_deploy.if.includes("github.ref_name == 'develop'"));
|
|
419
415
|
});
|
|
420
416
|
|
|
417
|
+
test('Vercel deploy workflows validate secrets before running vercel pull', () => {
|
|
418
|
+
const projectDir = makeTempDir();
|
|
419
|
+
writeFiles(projectDir, {
|
|
420
|
+
'package.json': json({
|
|
421
|
+
name: 'vercel-secrets-app',
|
|
422
|
+
version: '1.0.0',
|
|
423
|
+
scripts: {
|
|
424
|
+
build: 'next build',
|
|
425
|
+
},
|
|
426
|
+
}),
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const workflows = new WorkflowGenerator(
|
|
430
|
+
makeJsProject({
|
|
431
|
+
hosting: [{ name: 'Vercel', secrets: ['VERCEL_TOKEN', 'VERCEL_ORG_ID', 'VERCEL_PROJECT_ID'] }],
|
|
432
|
+
frameworks: [{ name: 'Next.js', confidence: 1, buildDir: '.next' }],
|
|
433
|
+
}),
|
|
434
|
+
projectDir
|
|
435
|
+
).generate();
|
|
436
|
+
|
|
437
|
+
const deploy = parseWorkflow(workflows.find((workflow) => workflow.filename === 'deploy.yml').content);
|
|
438
|
+
const deploySteps = deploy.jobs.deploy.steps;
|
|
439
|
+
const validateStep = deploySteps.find((step) => step.name === 'Validate Vercel credentials');
|
|
440
|
+
const pullStep = deploySteps.find((step) => step.name === 'Pull Vercel environment');
|
|
441
|
+
|
|
442
|
+
assert(validateStep);
|
|
443
|
+
assert(validateStep.run.includes('Missing VERCEL_TOKEN secret'));
|
|
444
|
+
assert(validateStep.run.includes('Missing VERCEL_ORG_ID secret'));
|
|
445
|
+
assert(validateStep.run.includes('Missing VERCEL_PROJECT_ID secret'));
|
|
446
|
+
assert.equal(pullStep.run, 'vercel pull --yes --environment=production --token="$VERCEL_TOKEN"');
|
|
447
|
+
});
|
|
448
|
+
|
|
421
449
|
test('Single-layout monorepos still generate the root workspace matrix CI', () => {
|
|
422
450
|
const projectDir = makeTempDir();
|
|
423
451
|
const packages = [
|
|
@@ -532,6 +560,35 @@ test('Netlify preview configuration uses the detected production branch instead
|
|
|
532
560
|
assert.equal(previewStep.with['production-branch'], 'release');
|
|
533
561
|
});
|
|
534
562
|
|
|
563
|
+
test('Preview deploy jobs only run for same-repo pull requests with secrets access', () => {
|
|
564
|
+
const projectDir = makeTempDir();
|
|
565
|
+
writeFiles(projectDir, {
|
|
566
|
+
'package.json': json({
|
|
567
|
+
name: 'preview-guard-app',
|
|
568
|
+
version: '1.0.0',
|
|
569
|
+
scripts: {
|
|
570
|
+
build: 'next build',
|
|
571
|
+
},
|
|
572
|
+
}),
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const generator = new WorkflowGenerator(
|
|
576
|
+
makeJsProject({
|
|
577
|
+
hosting: [{ name: 'Vercel', secrets: ['VERCEL_TOKEN', 'VERCEL_ORG_ID', 'VERCEL_PROJECT_ID'] }],
|
|
578
|
+
frameworks: [{ name: 'Next.js', confidence: 1, buildDir: '.next' }],
|
|
579
|
+
}),
|
|
580
|
+
projectDir
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
const deploy = generator.generate().find((workflow) => workflow.filename === 'deploy.yml');
|
|
584
|
+
const parsed = parseWorkflow(deploy.content);
|
|
585
|
+
|
|
586
|
+
assert.equal(
|
|
587
|
+
parsed.jobs.preview.if,
|
|
588
|
+
"github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository"
|
|
589
|
+
);
|
|
590
|
+
});
|
|
591
|
+
|
|
535
592
|
test('Generic JavaScript builds no longer upload a fake dist artifact when no build directory is known', () => {
|
|
536
593
|
const projectDir = makeTempDir();
|
|
537
594
|
writeFiles(projectDir, {
|
|
@@ -613,28 +670,6 @@ test('Bun workflows set up Bun before installing dependencies', () => {
|
|
|
613
670
|
assert.equal(deployBuildStep.run, 'bun run build');
|
|
614
671
|
});
|
|
615
672
|
|
|
616
|
-
test('Python security workflow honors the detected Python version', () => {
|
|
617
|
-
const projectDir = makeTempDir();
|
|
618
|
-
const generator = new WorkflowGenerator(
|
|
619
|
-
{
|
|
620
|
-
hosting: [],
|
|
621
|
-
frameworks: [],
|
|
622
|
-
languages: [{ name: 'Python', packageManager: 'pip', pythonVersion: '3.12' }],
|
|
623
|
-
testing: [],
|
|
624
|
-
envVars: { secrets: [], public: [], all: [], sourceFile: null },
|
|
625
|
-
monorepoPackages: [],
|
|
626
|
-
lockFiles: [],
|
|
627
|
-
_config: {},
|
|
628
|
-
},
|
|
629
|
-
projectDir
|
|
630
|
-
);
|
|
631
|
-
|
|
632
|
-
const workflow = parseWorkflow(generator._buildSecurityWorkflow());
|
|
633
|
-
const pythonSetup = workflow.jobs.security.steps.find((step) => step.name === 'Set up Python');
|
|
634
|
-
|
|
635
|
-
assert.equal(pythonSetup.with['python-version'], '3.12');
|
|
636
|
-
});
|
|
637
|
-
|
|
638
673
|
test('smartMergeWorkflow preserves existing custom steps that cistack does not regenerate', () => {
|
|
639
674
|
const existing = [
|
|
640
675
|
'name: CI',
|
|
@@ -672,6 +707,66 @@ test('smartMergeWorkflow preserves existing custom steps that cistack does not r
|
|
|
672
707
|
assert(stepNames.includes('Build'));
|
|
673
708
|
});
|
|
674
709
|
|
|
710
|
+
test('smartMergeWorkflow removes stale managed jobs and top-level keys from previous generated workflows', () => {
|
|
711
|
+
const existing = [
|
|
712
|
+
'# Generated by cistack v5.0.0',
|
|
713
|
+
'',
|
|
714
|
+
'name: Pipeline',
|
|
715
|
+
'on:',
|
|
716
|
+
' push:',
|
|
717
|
+
' branches:',
|
|
718
|
+
' - main',
|
|
719
|
+
' schedule:',
|
|
720
|
+
' - cron: 0 6 * * 1',
|
|
721
|
+
'permissions:',
|
|
722
|
+
' contents: read',
|
|
723
|
+
' security-events: write',
|
|
724
|
+
'jobs:',
|
|
725
|
+
' ci_lint:',
|
|
726
|
+
' runs-on: ubuntu-latest',
|
|
727
|
+
' steps:',
|
|
728
|
+
' - name: Checkout code',
|
|
729
|
+
' uses: actions/checkout@v4',
|
|
730
|
+
' security_security:',
|
|
731
|
+
' runs-on: ubuntu-latest',
|
|
732
|
+
' steps:',
|
|
733
|
+
' - name: Security',
|
|
734
|
+
' run: echo audit',
|
|
735
|
+
' custom_check:',
|
|
736
|
+
' runs-on: ubuntu-latest',
|
|
737
|
+
' steps:',
|
|
738
|
+
' - name: Custom',
|
|
739
|
+
' run: echo custom',
|
|
740
|
+
'',
|
|
741
|
+
].join('\n');
|
|
742
|
+
|
|
743
|
+
const generated = [
|
|
744
|
+
'# Generated by cistack v5.1.0',
|
|
745
|
+
'',
|
|
746
|
+
'name: Pipeline',
|
|
747
|
+
'on:',
|
|
748
|
+
' push:',
|
|
749
|
+
' branches:',
|
|
750
|
+
' - main',
|
|
751
|
+
'jobs:',
|
|
752
|
+
' ci_lint:',
|
|
753
|
+
' runs-on: ubuntu-latest',
|
|
754
|
+
' steps:',
|
|
755
|
+
' - name: Checkout code',
|
|
756
|
+
' uses: actions/checkout@v4',
|
|
757
|
+
'',
|
|
758
|
+
].join('\n');
|
|
759
|
+
|
|
760
|
+
const result = smartMergeWorkflow(existing, generated);
|
|
761
|
+
const merged = yaml.load(stripHeader(result.content));
|
|
762
|
+
|
|
763
|
+
assert(!merged.on.schedule);
|
|
764
|
+
assert(!merged.permissions);
|
|
765
|
+
assert(merged.jobs.ci_lint);
|
|
766
|
+
assert(!merged.jobs.security_security);
|
|
767
|
+
assert(merged.jobs.custom_check);
|
|
768
|
+
});
|
|
769
|
+
|
|
675
770
|
test('Per-package CI picks workspace build scripts instead of only reading the root package.json', () => {
|
|
676
771
|
const projectDir = makeTempDir();
|
|
677
772
|
writeFiles(projectDir, {
|
|
@@ -933,6 +1028,75 @@ test('CLI write output shows the pipeline file path in single-layout mode', () =
|
|
|
933
1028
|
assert(output.includes(`Dependabot → ${path.join(projectDir, '.github', 'dependabot.yml')}`));
|
|
934
1029
|
});
|
|
935
1030
|
|
|
1031
|
+
test('CLI output respects custom outputDir in write and dry-run modes', () => {
|
|
1032
|
+
const projectDir = makeTempDir();
|
|
1033
|
+
writeFiles(projectDir, {
|
|
1034
|
+
'package.json': json({
|
|
1035
|
+
name: 'cli-custom-output-app',
|
|
1036
|
+
version: '1.0.0',
|
|
1037
|
+
scripts: {
|
|
1038
|
+
test: 'echo ok',
|
|
1039
|
+
},
|
|
1040
|
+
}),
|
|
1041
|
+
'cistack.config.js': `module.exports = { outputDir: 'ci' };\n`,
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
const writeOutput = execFileSync(process.execPath, ['bin/ciflow.js', 'generate', '--path', projectDir, '--no-prompt'], {
|
|
1045
|
+
cwd: repoRoot,
|
|
1046
|
+
encoding: 'utf8',
|
|
1047
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
assert(writeOutput.includes('✔ Written: ci/pipeline.yml'));
|
|
1051
|
+
assert(!writeOutput.includes('.github/workflows/pipeline.yml'));
|
|
1052
|
+
assert(writeOutput.includes(`Pipeline → ${path.join(projectDir, 'ci', 'pipeline.yml')}`));
|
|
1053
|
+
|
|
1054
|
+
const dryRunOutput = execFileSync(process.execPath, ['bin/ciflow.js', 'generate', '--path', projectDir, '--dry-run', '--no-prompt'], {
|
|
1055
|
+
cwd: repoRoot,
|
|
1056
|
+
encoding: 'utf8',
|
|
1057
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
assert(dryRunOutput.includes('📄 ci/pipeline.yml'));
|
|
1061
|
+
assert(!dryRunOutput.includes('📄 .github/workflows/pipeline.yml'));
|
|
1062
|
+
assert(dryRunOutput.includes('.github/dependabot.yml'));
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
test('CLI audit and upgrade honor custom outputDir', () => {
|
|
1066
|
+
const projectDir = makeTempDir();
|
|
1067
|
+
writeFiles(projectDir, {
|
|
1068
|
+
'cistack.config.js': `module.exports = { outputDir: 'ci' };\n`,
|
|
1069
|
+
'ci/pipeline.yml': [
|
|
1070
|
+
'name: Pipeline',
|
|
1071
|
+
'on: push',
|
|
1072
|
+
'jobs:',
|
|
1073
|
+
' test:',
|
|
1074
|
+
' runs-on: ubuntu-latest',
|
|
1075
|
+
' steps:',
|
|
1076
|
+
' - uses: actions/checkout@v3',
|
|
1077
|
+
'',
|
|
1078
|
+
].join('\n'),
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
const auditOutput = execFileSync(process.execPath, ['bin/ciflow.js', 'audit', '--path', projectDir], {
|
|
1082
|
+
cwd: repoRoot,
|
|
1083
|
+
encoding: 'utf8',
|
|
1084
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
assert(auditOutput.includes('pipeline.yml'));
|
|
1088
|
+
assert(auditOutput.includes('Outdated action: actions/checkout@v3'));
|
|
1089
|
+
|
|
1090
|
+
const upgradeOutput = execFileSync(process.execPath, ['bin/ciflow.js', 'upgrade', '--path', projectDir], {
|
|
1091
|
+
cwd: repoRoot,
|
|
1092
|
+
encoding: 'utf8',
|
|
1093
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
assert(upgradeOutput.includes('pipeline.yml'));
|
|
1097
|
+
assert(fs.readFileSync(path.join(projectDir, 'ci', 'pipeline.yml'), 'utf8').includes('uses: actions/checkout@v4'));
|
|
1098
|
+
});
|
|
1099
|
+
|
|
936
1100
|
test('CLI supports opting back into split workflow files', () => {
|
|
937
1101
|
const projectDir = makeTempDir();
|
|
938
1102
|
writeFiles(projectDir, {
|
|
@@ -953,7 +1117,7 @@ test('CLI supports opting back into split workflow files', () => {
|
|
|
953
1117
|
});
|
|
954
1118
|
|
|
955
1119
|
assert(output.includes('.github/workflows/ci.yml'));
|
|
956
|
-
assert(output.includes('.github/workflows/
|
|
1120
|
+
assert(output.includes('.github/workflows/deploy.yml') || output.includes('.github/workflows/ci.yml'));
|
|
957
1121
|
assert(output.includes('Done! Your GitHub Actions pipeline is ready.'));
|
|
958
1122
|
});
|
|
959
1123
|
|