cistack 5.1.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 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, security, 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.
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 `.github/workflows` for issues like missing concurrency blocks, outdated actions, old Node versions, and missing dependency caching.
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, security, and release jobs into one place so teams can track the whole pipeline from one file.
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, security, and release jobs when those parts of the stack are detected
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, release jobs, and scheduled security scans in the same workflow file
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`, `security.yml`, and `release.yml` files again.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cistack",
3
- "version": "5.1.0",
3
+ "version": "5.2.0",
4
4
  "description": "Automatically generate GitHub Actions CI/CD pipelines by analysing your codebase",
5
5
  "main": "src/index.js",
6
6
  "types": "index.d.ts",
@@ -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
 
@@ -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
@@ -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 analyzer = new WorkflowAnalyzer(this.projectPath);
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 analyzer = new WorkflowAnalyzer(this.projectPath);
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
- return path.posix.join('.github/workflows', 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);
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📄 .github/workflows/${wf.filename}`));
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
  }
@@ -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, deploy, and security workflows', () => {
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,8 +409,6 @@ 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'"));
@@ -613,28 +609,6 @@ test('Bun workflows set up Bun before installing dependencies', () => {
613
609
  assert.equal(deployBuildStep.run, 'bun run build');
614
610
  });
615
611
 
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
612
  test('smartMergeWorkflow preserves existing custom steps that cistack does not regenerate', () => {
639
613
  const existing = [
640
614
  'name: CI',
@@ -672,6 +646,66 @@ test('smartMergeWorkflow preserves existing custom steps that cistack does not r
672
646
  assert(stepNames.includes('Build'));
673
647
  });
674
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
+
675
709
  test('Per-package CI picks workspace build scripts instead of only reading the root package.json', () => {
676
710
  const projectDir = makeTempDir();
677
711
  writeFiles(projectDir, {
@@ -933,6 +967,75 @@ test('CLI write output shows the pipeline file path in single-layout mode', () =
933
967
  assert(output.includes(`Dependabot → ${path.join(projectDir, '.github', 'dependabot.yml')}`));
934
968
  });
935
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
+
936
1039
  test('CLI supports opting back into split workflow files', () => {
937
1040
  const projectDir = makeTempDir();
938
1041
  writeFiles(projectDir, {
@@ -953,7 +1056,7 @@ test('CLI supports opting back into split workflow files', () => {
953
1056
  });
954
1057
 
955
1058
  assert(output.includes('.github/workflows/ci.yml'));
956
- assert(output.includes('.github/workflows/security.yml'));
1059
+ assert(output.includes('.github/workflows/deploy.yml') || output.includes('.github/workflows/ci.yml'));
957
1060
  assert(output.includes('Done! Your GitHub Actions pipeline is ready.'));
958
1061
  });
959
1062