cistack 1.0.0 → 3.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.
@@ -4,6 +4,10 @@ const yaml = require('js-yaml');
4
4
 
5
5
  /**
6
6
  * Takes all detected signals and produces one or more complete GitHub Actions workflow YAML files.
7
+ * v2.0.0 additions:
8
+ * - Per-language caching (cargo, pip, poetry, m2, gradle, bundler, go, composer)
9
+ * - Monorepo-aware: wraps jobs in a matrix over workspaces or generates per-package files
10
+ * - Env var documentation from .env.example detection
7
11
  */
8
12
  class WorkflowGenerator {
9
13
  constructor(config, projectPath) {
@@ -12,6 +16,9 @@ class WorkflowGenerator {
12
16
  this.languages = config.languages || [];
13
17
  this.testing = config.testing || [];
14
18
  this.projectPath = projectPath;
19
+ this.envVars = config.envVars || { secrets: [], public: [], all: [], sourceFile: null };
20
+ this.monorepoPackages = config.monorepoPackages || [];
21
+ this.extraConfig = config._config || {}; // raw cistack.config.js
15
22
 
16
23
  // Convenient accessors
17
24
  this.primaryLang = this.languages[0] || { name: 'JavaScript', packageManager: 'npm', nodeVersion: '20' };
@@ -19,18 +26,40 @@ class WorkflowGenerator {
19
26
  this.e2eTests = this.testing.filter((t) => t.type === 'e2e' && t.confidence > 0.3);
20
27
  this.hasDocker = this.hosting.some((h) => h.name === 'Docker');
21
28
  this.primaryHosting = this.hosting.filter((h) => h.name !== 'Docker')[0] || null;
29
+
30
+ // Monorepo mode: per-package workflows if configured or if > 1 package
31
+ this.isMonorepo = this.monorepoPackages.length > 0;
32
+ this.perPackageWorkflows = this.isMonorepo && (
33
+ (this.extraConfig.monorepo && this.extraConfig.monorepo.perPackage) ||
34
+ this.monorepoPackages.length > 1
35
+ );
22
36
  }
23
37
 
24
38
  generate() {
25
39
  const workflows = [];
26
40
 
27
- // ── 1. Main CI workflow (lint + test + build on every push / PR) ──────
28
- workflows.push({
29
- filename: 'ci.yml',
30
- content: this._buildCIWorkflow(),
31
- });
41
+ if (this.perPackageWorkflows) {
42
+ // ── Monorepo: one CI file per workspace ─────────────────────────────
43
+ for (const pkg of this.monorepoPackages) {
44
+ workflows.push({
45
+ filename: `ci-${this._slugify(pkg.name)}.yml`,
46
+ content: this._buildCIWorkflow(pkg),
47
+ });
48
+ }
49
+ // Root-level CI file (matrix over all packages)
50
+ workflows.push({
51
+ filename: 'ci.yml',
52
+ content: this._buildMonorepoRootCI(),
53
+ });
54
+ } else {
55
+ // ── Standard: single CI workflow ────────────────────────────────────
56
+ workflows.push({
57
+ filename: 'ci.yml',
58
+ content: this._buildCIWorkflow(),
59
+ });
60
+ }
32
61
 
33
- // ── 2. Deploy / CD workflow (per hosting platform) ─────────────────────
62
+ // ── 2. Deploy / CD workflow ──────────────────────────────────────────
34
63
  if (this.primaryHosting) {
35
64
  workflows.push({
36
65
  filename: 'deploy.yml',
@@ -38,7 +67,7 @@ class WorkflowGenerator {
38
67
  });
39
68
  }
40
69
 
41
- // ── 3. Docker image build+push (if Docker detected) ──────────────────
70
+ // ── 3. Docker image build+push ───────────────────────────────────────
42
71
  if (this.hasDocker) {
43
72
  workflows.push({
44
73
  filename: 'docker.yml',
@@ -46,7 +75,7 @@ class WorkflowGenerator {
46
75
  });
47
76
  }
48
77
 
49
- // ── 4. Dependency update / security audit ────────────────────────────
78
+ // ── 4. Security audit ────────────────────────────────────────────────
50
79
  workflows.push({
51
80
  filename: 'security.yml',
52
81
  content: this._buildSecurityWorkflow(),
@@ -59,40 +88,38 @@ class WorkflowGenerator {
59
88
  // CI Workflow
60
89
  // ══════════════════════════════════════════════════════════════════════════
61
90
 
62
- _buildCIWorkflow() {
63
- const lang = this.primaryLang;
91
+ _buildCIWorkflow(pkg = null) {
92
+ const lang = this._langForPackage(pkg);
64
93
  const jobs = {};
65
94
 
66
- // ── lint job ──────────────────────────────────────────────────────────
67
- const lintSteps = [
68
- this._stepCheckout(),
69
- ...this._setupSteps(lang),
70
- this._stepInstallDeps(lang),
71
- this._stepLint(lang),
72
- ].filter(Boolean);
95
+ const branches = this.extraConfig.branches || ['main', 'master', 'develop'];
73
96
 
97
+ // ── lint job ──────────────────────────────────────────────────────────
74
98
  jobs.lint = {
75
99
  name: '🔍 Lint & Format',
76
100
  'runs-on': 'ubuntu-latest',
77
- steps: lintSteps,
101
+ steps: [
102
+ this._stepCheckout(),
103
+ ...this._setupSteps(lang),
104
+ this._stepInstallDeps(lang),
105
+ this._stepLint(lang),
106
+ ].filter(Boolean),
78
107
  };
79
108
 
80
109
  // ── test job ──────────────────────────────────────────────────────────
81
110
  if (this.unitTests.length > 0) {
82
111
  const testMatrix = this._testMatrix(lang);
83
- const testSteps = [
84
- this._stepCheckout(),
85
- ...this._setupSteps(lang),
86
- this._stepInstallDeps(lang),
87
- ...this._unitTestSteps(lang),
88
- this._stepUploadCoverage(),
89
- ].filter(Boolean);
90
-
91
112
  jobs.test = {
92
113
  name: '🧪 Unit Tests',
93
114
  'runs-on': 'ubuntu-latest',
94
115
  ...(testMatrix ? { strategy: testMatrix } : {}),
95
- steps: testSteps,
116
+ steps: [
117
+ this._stepCheckout(),
118
+ ...this._setupSteps(lang),
119
+ this._stepInstallDeps(lang),
120
+ ...this._unitTestSteps(lang),
121
+ this._stepUploadCoverage(),
122
+ ].filter(Boolean),
96
123
  };
97
124
  }
98
125
 
@@ -102,10 +129,7 @@ class WorkflowGenerator {
102
129
  jobs.build = {
103
130
  name: '🏗️ Build',
104
131
  'runs-on': 'ubuntu-latest',
105
- needs: [
106
- 'lint',
107
- ...(jobs.test ? ['test'] : []),
108
- ],
132
+ needs: ['lint', ...(jobs.test ? ['test'] : [])],
109
133
  steps: [
110
134
  this._stepCheckout(),
111
135
  ...this._setupSteps(lang),
@@ -119,7 +143,7 @@ class WorkflowGenerator {
119
143
  // ── e2e job ───────────────────────────────────────────────────────────
120
144
  if (this.e2eTests.length > 0) {
121
145
  const e2eTest = this.e2eTests[0];
122
- jobs['e2e'] = {
146
+ jobs.e2e = {
123
147
  name: '🎭 E2E Tests',
124
148
  'runs-on': 'ubuntu-latest',
125
149
  needs: ['build'],
@@ -127,7 +151,9 @@ class WorkflowGenerator {
127
151
  this._stepCheckout(),
128
152
  ...this._setupSteps(lang),
129
153
  this._stepInstallDeps(lang),
130
- ...(e2eTest.name === 'Playwright' ? [{ name: 'Install Playwright browsers', run: 'npx playwright install --with-deps' }] : []),
154
+ ...(e2eTest.name === 'Playwright'
155
+ ? [{ name: 'Install Playwright browsers', run: 'npx playwright install --with-deps' }]
156
+ : []),
131
157
  { name: `Run ${e2eTest.name}`, run: e2eTest.command },
132
158
  {
133
159
  name: 'Upload E2E report',
@@ -144,19 +170,104 @@ class WorkflowGenerator {
144
170
  }
145
171
 
146
172
  const workflow = {
147
- name: 'CI',
173
+ name: pkg ? `CI — ${pkg.name}` : 'CI',
148
174
  on: {
149
- push: { branches: ['main', 'master', 'develop'] },
150
- pull_request: { branches: ['main', 'master', 'develop'] },
175
+ push: {
176
+ branches,
177
+ ...(pkg ? { paths: [`${pkg.relativePath}/**`] } : {}),
178
+ },
179
+ pull_request: {
180
+ branches,
181
+ ...(pkg ? { paths: [`${pkg.relativePath}/**`] } : {}),
182
+ },
151
183
  },
152
- 'concurrency': {
184
+ concurrency: {
153
185
  group: '${{ github.workflow }}-${{ github.ref }}',
154
186
  'cancel-in-progress': true,
155
187
  },
156
188
  jobs,
157
189
  };
158
190
 
159
- return this._toYaml(workflow, `# Generated by cistack — https://github.com/cistack\n# CI Pipeline: lint → test → build${this.e2eTests.length > 0 ? ' → e2e' : ''}\n\n`);
191
+ const envComment = this._envComment();
192
+ const header =
193
+ `# Generated by cistack v2.0.0 — https://github.com/cistack\n` +
194
+ `# CI Pipeline: lint → test → build${this.e2eTests.length > 0 ? ' → e2e' : ''}\n` +
195
+ envComment +
196
+ `\n`;
197
+
198
+ return this._toYaml(workflow, header);
199
+ }
200
+
201
+ // ── Monorepo root CI (matrix over all workspaces) ────────────────────────
202
+ _buildMonorepoRootCI() {
203
+ const lang = this.primaryLang;
204
+ const branches = this.extraConfig.branches || ['main', 'master', 'develop'];
205
+ const pkgPaths = this.monorepoPackages.map((p) => p.relativePath);
206
+
207
+ const workflow = {
208
+ name: 'CI — Monorepo',
209
+ on: {
210
+ push: { branches },
211
+ pull_request: { branches },
212
+ },
213
+ concurrency: {
214
+ group: '${{ github.workflow }}-${{ github.ref }}',
215
+ 'cancel-in-progress': true,
216
+ },
217
+ jobs: {
218
+ ci: {
219
+ name: '🧪 ${{ matrix.package }}',
220
+ 'runs-on': 'ubuntu-latest',
221
+ strategy: {
222
+ matrix: {
223
+ package: pkgPaths,
224
+ },
225
+ 'fail-fast': false,
226
+ },
227
+ steps: [
228
+ this._stepCheckout(),
229
+ ...this._setupSteps(lang),
230
+ {
231
+ name: 'Install dependencies',
232
+ run: lang.packageManager === 'pnpm'
233
+ ? 'pnpm --filter ${{ matrix.package }} install --frozen-lockfile'
234
+ : lang.packageManager === 'yarn'
235
+ ? 'yarn workspace ${{ matrix.package }} install'
236
+ : 'npm ci --workspace=${{ matrix.package }}',
237
+ },
238
+ {
239
+ name: 'Lint',
240
+ run: lang.packageManager === 'pnpm'
241
+ ? 'pnpm --filter ${{ matrix.package }} run lint --if-present'
242
+ : lang.packageManager === 'yarn'
243
+ ? 'yarn workspace ${{ matrix.package }} run lint || true'
244
+ : 'npm run --workspace=${{ matrix.package }} lint || true',
245
+ },
246
+ {
247
+ name: 'Test',
248
+ run: lang.packageManager === 'pnpm'
249
+ ? 'pnpm --filter ${{ matrix.package }} run test --if-present'
250
+ : lang.packageManager === 'yarn'
251
+ ? 'yarn workspace ${{ matrix.package }} run test || true'
252
+ : 'npm run --workspace=${{ matrix.package }} test || true',
253
+ },
254
+ {
255
+ name: 'Build',
256
+ run: lang.packageManager === 'pnpm'
257
+ ? 'pnpm --filter ${{ matrix.package }} run build --if-present'
258
+ : lang.packageManager === 'yarn'
259
+ ? 'yarn workspace ${{ matrix.package }} run build || true'
260
+ : 'npm run --workspace=${{ matrix.package }} build || true',
261
+ },
262
+ ].filter(Boolean),
263
+ },
264
+ },
265
+ };
266
+
267
+ return this._toYaml(
268
+ workflow,
269
+ '# Generated by cistack v2.0.0 — https://github.com/cistack\n# Monorepo CI — matrix over all workspaces\n\n'
270
+ );
160
271
  }
161
272
 
162
273
  // ══════════════════════════════════════════════════════════════════════════
@@ -166,6 +277,7 @@ class WorkflowGenerator {
166
277
  _buildDeployWorkflow() {
167
278
  const h = this.primaryHosting;
168
279
  const lang = this.primaryLang;
280
+ const branches = this.extraConfig.branches || ['main', 'master'];
169
281
 
170
282
  const preDeploySteps = [
171
283
  this._stepCheckout(),
@@ -173,31 +285,56 @@ class WorkflowGenerator {
173
285
  this._stepInstallDeps(lang),
174
286
  ].filter(Boolean);
175
287
 
176
- const deploySteps = this._hostingDeploySteps(h, lang);
288
+ const deploySteps = this._hostingDeploySteps(h, lang, false); // production
289
+ const previewSteps = this._hostingDeploySteps(h, lang, true); // preview
177
290
 
178
291
  const jobs = {
179
292
  deploy: {
180
- name: `🚀 Deploy → ${h.name}`,
293
+ name: `🚀 Deploy → ${h.name} (Production)`,
294
+ if: "github.event_name == 'push' || github.event_name == 'workflow_dispatch'",
181
295
  'runs-on': 'ubuntu-latest',
182
296
  environment: 'production',
183
297
  steps: [...preDeploySteps, ...deploySteps].filter(Boolean),
184
298
  },
185
299
  };
186
300
 
187
- const secretsDoc = h.secrets.length > 0
188
- ? `# Required secrets: ${h.secrets.join(', ')}\n# Add these at: Settings → Secrets and Variables → Actions\n\n`
301
+ // Add preview job if supported
302
+ if (previewSteps.length > 0) {
303
+ jobs.preview = {
304
+ name: `✨ Deploy → ${h.name} (Preview)`,
305
+ if: "github.event_name == 'pull_request'",
306
+ 'runs-on': 'ubuntu-latest',
307
+ environment: 'preview',
308
+ steps: [...preDeploySteps, ...previewSteps].filter(Boolean),
309
+ };
310
+ }
311
+
312
+ const allSecrets = [
313
+ ...(h.secrets || []),
314
+ ...this.envVars.secrets,
315
+ ];
316
+ const uniqueSecrets = [...new Set(allSecrets)];
317
+
318
+ const secretsDoc = uniqueSecrets.length > 0
319
+ ? `# Required secrets: ${uniqueSecrets.join(', ')}\n# Add these at: Settings → Secrets and Variables → Actions\n\n`
189
320
  : '';
190
321
 
322
+ const envComment = this._envComment();
323
+
191
324
  const workflow = {
192
325
  name: `Deploy to ${h.name}`,
193
326
  on: {
194
- push: { branches: ['main', 'master'] },
327
+ push: { branches: branches.filter((b) => b !== 'develop') },
328
+ pull_request: { branches },
195
329
  workflow_dispatch: {},
196
330
  },
197
331
  jobs,
198
332
  };
199
333
 
200
- return this._toYaml(workflow, `# Generated by cistack\n# Deploy Pipeline → ${h.name}\n${secretsDoc}`);
334
+ return this._toYaml(
335
+ workflow,
336
+ `# Generated by cistack v2.0.0\n# Deploy Pipeline → ${h.name}\n${secretsDoc}${envComment}\n`
337
+ );
201
338
  }
202
339
 
203
340
  // ══════════════════════════════════════════════════════════════════════════
@@ -274,7 +411,7 @@ class WorkflowGenerator {
274
411
  },
275
412
  };
276
413
 
277
- return this._toYaml(workflow, '# Generated by cistack\n# Docker image build and push to GHCR\n\n');
414
+ return this._toYaml(workflow, '# Generated by cistack v2.0.0\n# Docker image build and push to GHCR\n\n');
278
415
  }
279
416
 
280
417
  // ══════════════════════════════════════════════════════════════════════════
@@ -289,7 +426,14 @@ class WorkflowGenerator {
289
426
  steps.push(
290
427
  ...this._setupSteps(lang),
291
428
  this._stepInstallDeps(lang),
292
- { name: 'Audit dependencies', run: lang.packageManager === 'npm' ? 'npm audit --audit-level=high' : lang.packageManager === 'yarn' ? 'yarn audit --level high' : 'pnpm audit --audit-level high' },
429
+ {
430
+ name: 'Audit dependencies',
431
+ run:
432
+ lang.packageManager === 'npm' ? 'npm audit --audit-level=high' :
433
+ lang.packageManager === 'yarn' ? 'yarn audit --level high' :
434
+ lang.packageManager === 'pnpm' ? 'pnpm audit --audit-level high' :
435
+ 'npm audit --audit-level=high',
436
+ },
293
437
  );
294
438
  }
295
439
 
@@ -301,19 +445,21 @@ class WorkflowGenerator {
301
445
  );
302
446
  }
303
447
 
448
+ if (lang.name === 'Rust') {
449
+ steps.push(
450
+ { name: 'Set up Rust', uses: 'dtolnay/rust-toolchain@stable' },
451
+ { name: 'Run cargo audit', run: 'cargo install cargo-audit && cargo audit' },
452
+ );
453
+ }
454
+
304
455
  // CodeQL analysis
305
456
  steps.push(
306
457
  {
307
458
  name: 'Initialize CodeQL',
308
459
  uses: 'github/codeql-action/init@v3',
309
- with: {
310
- languages: this._codeQLLanguage(lang.name),
311
- },
312
- },
313
- {
314
- name: 'Perform CodeQL Analysis',
315
- uses: 'github/codeql-action/analyze@v3',
460
+ with: { languages: this._codeQLLanguage(lang.name) },
316
461
  },
462
+ { name: 'Perform CodeQL Analysis', uses: 'github/codeql-action/analyze@v3' },
317
463
  );
318
464
 
319
465
  const workflow = {
@@ -321,7 +467,7 @@ class WorkflowGenerator {
321
467
  on: {
322
468
  push: { branches: ['main', 'master'] },
323
469
  pull_request: { branches: ['main', 'master'] },
324
- schedule: [{ cron: '0 6 * * 1' }], // Every Monday 6am
470
+ schedule: [{ cron: '0 6 * * 1' }],
325
471
  },
326
472
  jobs: {
327
473
  security: {
@@ -337,7 +483,7 @@ class WorkflowGenerator {
337
483
  },
338
484
  };
339
485
 
340
- return this._toYaml(workflow, '# Generated by cistack\n# Security: dependency audit + CodeQL analysis (runs weekly)\n\n');
486
+ return this._toYaml(workflow, '# Generated by cistack v2.0.0\n# Security: dependency audit + CodeQL analysis (runs weekly)\n\n');
341
487
  }
342
488
 
343
489
  // ══════════════════════════════════════════════════════════════════════════
@@ -348,110 +494,185 @@ class WorkflowGenerator {
348
494
  return { name: 'Checkout code', uses: 'actions/checkout@v4', with: { 'fetch-depth': 0 } };
349
495
  }
350
496
 
497
+ /**
498
+ * Returns setup + cache steps for the given language.
499
+ * v2.0.0: added explicit caching for pip, poetry, cargo, maven, gradle, bundler, go, composer.
500
+ */
351
501
  _setupSteps(lang) {
352
502
  const steps = [];
503
+ const cacheOverride = this.extraConfig.cache || {};
504
+
505
+ // ── JavaScript / TypeScript ──────────────────────────────────────────
353
506
  if (['JavaScript', 'TypeScript'].includes(lang.name)) {
507
+ if (lang.packageManager === 'pnpm') {
508
+ steps.push({ name: 'Install pnpm', uses: 'pnpm/action-setup@v3', with: { version: 'latest' } });
509
+ }
354
510
  steps.push({
355
511
  name: 'Set up Node.js',
356
512
  uses: 'actions/setup-node@v4',
357
513
  with: {
358
514
  'node-version': lang.nodeVersion || '20',
359
- cache: lang.packageManager === 'yarn' ? 'yarn' : lang.packageManager === 'pnpm' ? 'pnpm' : 'npm',
515
+ // Use native caching in setup-node
516
+ cache: cacheOverride.npm !== false ? (lang.packageManager === 'yarn' ? 'yarn' : lang.packageManager === 'pnpm' ? 'pnpm' : 'npm') : undefined,
360
517
  },
361
518
  });
362
- if (lang.packageManager === 'pnpm') {
363
- steps.unshift({ name: 'Install pnpm', uses: 'pnpm/action-setup@v3', with: { version: 'latest' } });
364
- }
365
519
  }
520
+
521
+ // ── Python ───────────────────────────────────────────────────────────
366
522
  if (lang.name === 'Python') {
367
- steps.push({ name: 'Set up Python', uses: 'actions/setup-python@v5', with: { 'python-version': '3.x' } });
523
+ steps.push({
524
+ name: 'Set up Python',
525
+ uses: 'actions/setup-python@v5',
526
+ with: {
527
+ 'python-version': '3.x',
528
+ // Native caching for pip/poetry
529
+ cache: cacheOverride.pip !== false ? (lang.packageManager === 'poetry' ? 'poetry' : 'pip') : undefined
530
+ },
531
+ });
368
532
  }
533
+
534
+ // ── Go ───────────────────────────────────────────────────────────────
369
535
  if (lang.name === 'Go') {
370
- steps.push({ name: 'Set up Go', uses: 'actions/setup-go@v5', with: { 'go-version': 'stable' } });
536
+ steps.push({
537
+ name: 'Set up Go',
538
+ uses: 'actions/setup-go@v5',
539
+ with: {
540
+ 'go-version': 'stable',
541
+ cache: cacheOverride.go !== false
542
+ },
543
+ });
371
544
  }
545
+
546
+ // ── Java / Kotlin ─────────────────────────────────────────────────────
372
547
  if (lang.name === 'Java' || lang.name === 'Kotlin') {
373
- steps.push({ name: 'Set up JDK', uses: 'actions/setup-java@v4', with: { 'java-version': '21', distribution: 'temurin' } });
548
+ steps.push({
549
+ name: 'Set up JDK',
550
+ uses: 'actions/setup-java@v4',
551
+ with: {
552
+ 'java-version': '21',
553
+ distribution: 'temurin',
554
+ // Native caching for maven/gradle
555
+ cache: cacheOverride.maven !== false ? (lang.packageManager === 'gradle' ? 'gradle' : 'maven') : undefined
556
+ },
557
+ });
374
558
  }
559
+
560
+ // ── Ruby ─────────────────────────────────────────────────────────────
375
561
  if (lang.name === 'Ruby') {
376
- steps.push({ name: 'Set up Ruby', uses: 'ruby/setup-ruby@v1', with: { 'bundler-cache': true } });
562
+ steps.push({
563
+ name: 'Set up Ruby',
564
+ uses: 'ruby/setup-ruby@v1',
565
+ with: { 'bundler-cache': cacheOverride.bundler !== false },
566
+ });
377
567
  }
568
+
569
+ // ── Rust ─────────────────────────────────────────────────────────────
378
570
  if (lang.name === 'Rust') {
379
571
  steps.push({ name: 'Set up Rust', uses: 'dtolnay/rust-toolchain@stable' });
572
+
573
+ if (cacheOverride.cargo !== false) {
574
+ steps.push({
575
+ name: 'Cache Cargo registry',
576
+ uses: 'actions/cache@v4',
577
+ with: {
578
+ path: [
579
+ '~/.cargo/registry',
580
+ '~/.cargo/git',
581
+ 'target',
582
+ ].join('\n'),
583
+ key: "${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}",
584
+ 'restore-keys': '${{ runner.os }}-cargo-',
585
+ },
586
+ });
587
+ }
588
+ }
589
+
590
+ // ── PHP ───────────────────────────────────────────────────────────────
591
+ if (lang.name === 'PHP') {
592
+ if (cacheOverride.composer !== false) {
593
+ steps.push({
594
+ name: 'Get Composer cache directory',
595
+ id: 'composer-cache',
596
+ run: 'echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT',
597
+ });
598
+ steps.push({
599
+ name: 'Cache Composer packages',
600
+ uses: 'actions/cache@v4',
601
+ with: {
602
+ path: '${{ steps.composer-cache.outputs.dir }}',
603
+ key: "${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}",
604
+ 'restore-keys': '${{ runner.os }}-composer-',
605
+ },
606
+ });
607
+ }
380
608
  }
609
+
381
610
  return steps;
382
611
  }
383
612
 
384
613
  _stepInstallDeps(lang) {
385
614
  const pm = lang.packageManager;
386
- if (pm === 'npm') return { name: 'Install dependencies', run: 'npm ci' };
387
- if (pm === 'yarn') return { name: 'Install dependencies', run: 'yarn install --frozen-lockfile' };
388
- if (pm === 'pnpm') return { name: 'Install dependencies', run: 'pnpm install --frozen-lockfile' };
389
- if (pm === 'bun') return { name: 'Install dependencies', run: 'bun install' };
390
- if (pm === 'pip') return { name: 'Install dependencies', run: 'pip install -r requirements.txt' };
391
- if (pm === 'poetry') return { name: 'Install dependencies', run: 'pip install poetry && poetry install' };
392
- if (pm === 'pipenv') return { name: 'Install dependencies', run: 'pip install pipenv && pipenv install --dev' };
615
+ if (pm === 'npm') return { name: 'Install dependencies', run: 'npm ci' };
616
+ if (pm === 'yarn') return { name: 'Install dependencies', run: 'yarn install --frozen-lockfile' };
617
+ if (pm === 'pnpm') return { name: 'Install dependencies', run: 'pnpm install --frozen-lockfile' };
618
+ if (pm === 'bun') return { name: 'Install dependencies', run: 'bun install' };
619
+ if (pm === 'pip') return { name: 'Install dependencies', run: 'pip install -r requirements.txt' };
620
+ if (pm === 'poetry') return { name: 'Install dependencies', run: 'pip install poetry && poetry install' };
621
+ if (pm === 'pipenv') return { name: 'Install dependencies', run: 'pip install pipenv && pipenv install --dev' };
393
622
  if (pm === 'bundler') return { name: 'Install dependencies', run: 'bundle install' };
394
- if (pm === 'go mod') return { name: 'Download modules', run: 'go mod download' };
395
- if (pm === 'cargo') return null; // Cargo handles deps on build/test
396
- if (pm === 'maven') return { name: 'Cache Maven', uses: 'actions/cache@v4', with: { path: '~/.m2', key: "${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}" } };
623
+ if (pm === 'go mod') return { name: 'Download modules', run: 'go mod download' };
624
+ if (pm === 'cargo') return null; // Cargo handles deps on build/test
625
+ if (pm === 'maven') return { name: 'Install dependencies', run: 'mvn -B dependency:resolve --no-transfer-progress' };
626
+ if (pm === 'gradle') return { name: 'Install dependencies', run: './gradlew dependencies' };
397
627
  if (pm === 'composer') return { name: 'Install dependencies', run: 'composer install --no-interaction --prefer-dist --optimize-autoloader' };
398
628
  return { name: 'Install dependencies', run: 'npm ci' };
399
629
  }
400
630
 
401
631
  _stepLint(lang) {
402
632
  const pm = lang.packageManager || 'npm';
403
- const scripts = (this.languages[0] || {}).scripts || {};
404
633
 
405
634
  if (['JavaScript', 'TypeScript'].includes(lang.name)) {
406
- // Pick the right lint command
407
635
  const lintScript = this._findScript(['lint', 'lint:ci', 'eslint']);
408
- const typeCheck = this._findScript(['type-check', 'typecheck', 'tsc']);
409
- const format = this._findScript(['format:check', 'prettier:check', 'fmt:check']);
636
+ const typeCheck = this._findScript(['type-check', 'typecheck', 'tsc']);
637
+ const format = this._findScript(['format:check', 'prettier:check', 'fmt:check']);
638
+ const runPfx = pm === 'yarn' ? 'yarn' : pm === 'pnpm' ? 'pnpm' : 'npm run';
410
639
 
411
640
  const cmds = [];
412
- if (lintScript) cmds.push(`${pm === 'yarn' ? 'yarn' : pm === 'pnpm' ? 'pnpm' : 'npm run'} ${lintScript}`);
413
- if (typeCheck) cmds.push(`${pm === 'yarn' ? 'yarn' : pm === 'pnpm' ? 'pnpm' : 'npm run'} ${typeCheck}`);
414
- if (format) cmds.push(`${pm === 'yarn' ? 'yarn' : pm === 'pnpm' ? 'pnpm' : 'npm run'} ${format}`);
641
+ if (lintScript) cmds.push(`${runPfx} ${lintScript}`);
642
+ if (typeCheck) cmds.push(`${runPfx} ${typeCheck}`);
643
+ if (format) cmds.push(`${runPfx} ${format}`);
415
644
  if (cmds.length === 0) cmds.push('npx eslint . --ext .js,.jsx,.ts,.tsx --max-warnings 0');
416
645
 
417
646
  return { name: 'Lint', run: cmds.join('\n') };
418
647
  }
419
648
 
420
649
  if (lang.name === 'Python') return { name: 'Lint', run: 'pip install flake8 && flake8 .' };
421
- if (lang.name === 'Go') return { name: 'Lint', run: 'gofmt -d . && go vet ./...' };
422
- if (lang.name === 'Rust') return { name: 'Lint', run: 'cargo clippy -- -D warnings && cargo fmt --check' };
423
- if (lang.name === 'Ruby') return { name: 'Lint', run: 'gem install rubocop && rubocop' };
424
- if (lang.name === 'PHP') return { name: 'Lint', run: 'vendor/bin/phpcs' };
650
+ if (lang.name === 'Go') return { name: 'Lint', run: 'gofmt -d . && go vet ./...' };
651
+ if (lang.name === 'Rust') return { name: 'Lint', run: 'cargo clippy -- -D warnings && cargo fmt --check' };
652
+ if (lang.name === 'Ruby') return { name: 'Lint', run: 'gem install rubocop && rubocop' };
653
+ if (lang.name === 'PHP') return { name: 'Lint', run: 'vendor/bin/phpcs' };
425
654
 
426
655
  return null;
427
656
  }
428
657
 
429
658
  _unitTestSteps(lang) {
430
- return this.unitTests.map((t) => ({
431
- name: `Run ${t.name}`,
432
- run: t.command,
433
- }));
659
+ return this.unitTests.map((t) => ({ name: `Run ${t.name}`, run: t.command }));
434
660
  }
435
661
 
436
662
  _buildSteps(lang) {
437
663
  const pm = lang.packageManager || 'npm';
438
664
  const buildScript = this._findScript(['build', 'build:prod']);
439
-
440
665
  if (!buildScript && !['Go', 'Rust', 'Java', 'Kotlin'].includes(lang.name)) return [];
441
666
 
442
667
  const steps = [];
443
-
444
668
  if (['JavaScript', 'TypeScript'].includes(lang.name) && buildScript) {
445
- steps.push({
446
- name: 'Build',
447
- run: `${pm === 'yarn' ? 'yarn' : pm === 'pnpm' ? 'pnpm' : 'npm'} run ${buildScript}`,
448
- env: { NODE_ENV: 'production' },
449
- });
669
+ const runPfx = pm === 'yarn' ? 'yarn' : pm === 'pnpm' ? 'pnpm' : 'npm';
670
+ steps.push({ name: 'Build', run: `${runPfx} run ${buildScript}`, env: { NODE_ENV: 'production' } });
450
671
  }
451
- if (lang.name === 'Go') steps.push({ name: 'Build', run: 'go build -v ./...' });
452
- if (lang.name === 'Rust') steps.push({ name: 'Build', run: 'cargo build --release' });
453
- if (lang.name === 'Java') steps.push({ name: 'Build', run: 'mvn -B package --no-transfer-progress -DskipTests' });
454
- if (lang.name === 'Python') { /* Python typically doesn't have a build step */ }
672
+ if (lang.name === 'Go') steps.push({ name: 'Build', run: 'go build -v ./...' });
673
+ if (lang.name === 'Rust') steps.push({ name: 'Build', run: 'cargo build --release' });
674
+ if (lang.name === 'Java') steps.push({ name: 'Build', run: 'mvn -B package --no-transfer-progress -DskipTests' });
675
+ if (lang.name === 'Kotlin') steps.push({ name: 'Build', run: './gradlew build -x test' });
455
676
 
456
677
  return steps;
457
678
  }
@@ -461,11 +682,7 @@ class WorkflowGenerator {
461
682
  return {
462
683
  name: 'Upload build artifact',
463
684
  uses: 'actions/upload-artifact@v4',
464
- with: {
465
- name: 'build-output',
466
- path: buildDir,
467
- 'retention-days': 1,
468
- },
685
+ with: { name: 'build-output', path: buildDir, 'retention-days': 1 },
469
686
  };
470
687
  }
471
688
 
@@ -483,12 +700,7 @@ class WorkflowGenerator {
483
700
 
484
701
  _testMatrix(lang) {
485
702
  if (['JavaScript', 'TypeScript'].includes(lang.name)) {
486
- return {
487
- matrix: {
488
- 'node-version': ['18.x', '20.x', '22.x'],
489
- },
490
- 'fail-fast': false,
491
- };
703
+ return { matrix: { 'node-version': ['18.x', '20.x', '22.x'] }, 'fail-fast': false };
492
704
  }
493
705
  if (lang.name === 'Python') {
494
706
  return { matrix: { 'python-version': ['3.10', '3.11', '3.12'] }, 'fail-fast': false };
@@ -500,7 +712,7 @@ class WorkflowGenerator {
500
712
  // Hosting-specific deploy steps
501
713
  // ══════════════════════════════════════════════════════════════════════════
502
714
 
503
- _hostingDeploySteps(h, lang) {
715
+ _hostingDeploySteps(h, lang, isPreview = false) {
504
716
  const steps = [];
505
717
  const buildScript = this._findScript(['build', 'build:prod']);
506
718
  const pm = lang.packageManager || 'npm';
@@ -512,23 +724,24 @@ class WorkflowGenerator {
512
724
  steps.push({ name: 'Build', run: runCmd(buildScript), env: { NODE_ENV: 'production' } });
513
725
  }
514
726
  steps.push({
515
- name: 'Deploy to Firebase',
727
+ name: isPreview ? 'Deploy Preview' : 'Deploy to Firebase',
516
728
  uses: 'FirebaseExtended/action-hosting-deploy@v0',
517
729
  with: {
518
730
  repoToken: '${{ secrets.GITHUB_TOKEN }}',
519
731
  firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT }}',
520
- channelId: 'live',
732
+ channelId: isPreview ? 'preview-${{ github.event.number }}' : 'live',
521
733
  },
522
734
  });
523
735
  break;
524
736
  }
525
737
 
526
738
  case 'Vercel': {
739
+ const prodFlag = isPreview ? '' : '--prod';
527
740
  steps.push(
528
741
  { name: 'Install Vercel CLI', run: 'npm install -g vercel' },
529
- { name: 'Pull Vercel environment', run: 'vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}' },
530
- { name: 'Build project', run: 'vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}' },
531
- { name: 'Deploy to Vercel', run: 'vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}' },
742
+ { name: 'Pull Vercel environment', run: `vercel pull --yes --environment=${isPreview ? 'preview' : 'production'} --token=\${{ secrets.VERCEL_TOKEN }}` },
743
+ { name: 'Build project', run: `vercel build ${prodFlag} --token=\${{ secrets.VERCEL_TOKEN }}` },
744
+ { name: 'Deploy to Vercel', run: `vercel deploy --prebuilt ${prodFlag} --token=\${{ secrets.VERCEL_TOKEN }}` },
532
745
  );
533
746
  break;
534
747
  }
@@ -538,15 +751,17 @@ class WorkflowGenerator {
538
751
  steps.push({ name: 'Build', run: runCmd(buildScript), env: { NODE_ENV: 'production' } });
539
752
  }
540
753
  steps.push({
541
- name: 'Deploy to Netlify',
754
+ name: isPreview ? 'Deploy Preview' : 'Deploy to Netlify',
542
755
  uses: 'nwtgck/actions-netlify@v3.0',
543
756
  with: {
544
757
  'publish-dir': h.publishDir || 'dist',
545
758
  'production-branch': 'main',
546
759
  'github-token': '${{ secrets.GITHUB_TOKEN }}',
547
- 'deploy-message': "Deploy from GitHub Actions – ${{ github.sha }}",
760
+ 'deploy-message': isPreview ? 'Preview Deploy ${{ github.event.number }}' : 'Production Deploy – ${{ github.sha }}',
548
761
  'enable-pull-request-comment': true,
549
762
  'enable-commit-comment': true,
763
+ 'production-deploy': !isPreview,
764
+ alias: isPreview ? 'preview-${{ github.event.number }}' : undefined,
550
765
  },
551
766
  env: {
552
767
  NETLIFY_AUTH_TOKEN: '${{ secrets.NETLIFY_AUTH_TOKEN }}',
@@ -562,7 +777,11 @@ class WorkflowGenerator {
562
777
  }
563
778
  steps.push(
564
779
  { name: 'Setup Pages', uses: 'actions/configure-pages@v4' },
565
- { name: 'Upload Pages artifact', uses: 'actions/upload-pages-artifact@v3', with: { path: (this.frameworks[0] && this.frameworks[0].buildDir) || 'dist' } },
780
+ {
781
+ name: 'Upload Pages artifact',
782
+ uses: 'actions/upload-pages-artifact@v3',
783
+ with: { path: (this.frameworks[0] && this.frameworks[0].buildDir) || 'dist' },
784
+ },
566
785
  { name: 'Deploy to GitHub Pages', id: 'deployment', uses: 'actions/deploy-pages@v4' },
567
786
  );
568
787
  break;
@@ -572,8 +791,16 @@ class WorkflowGenerator {
572
791
  if (buildScript) steps.push({ name: 'Build', run: runCmd(buildScript), env: { NODE_ENV: 'production' } });
573
792
  const awsBuildDir = (this.frameworks[0] && this.frameworks[0].buildDir) || 'dist';
574
793
  steps.push(
575
- { name: 'Configure AWS credentials', uses: 'aws-actions/configure-aws-credentials@v4', with: { 'aws-access-key-id': '${{ secrets.AWS_ACCESS_KEY_ID }}', 'aws-secret-access-key': '${{ secrets.AWS_SECRET_ACCESS_KEY }}', 'aws-region': '${{ secrets.AWS_REGION }}' } },
576
- { name: 'Sync to S3', run: 'aws s3 sync ./' + awsBuildDir + ' s3://${{ secrets.AWS_S3_BUCKET }} --delete' },
794
+ {
795
+ name: 'Configure AWS credentials',
796
+ uses: 'aws-actions/configure-aws-credentials@v4',
797
+ with: {
798
+ 'aws-access-key-id': '${{ secrets.AWS_ACCESS_KEY_ID }}',
799
+ 'aws-secret-access-key': '${{ secrets.AWS_SECRET_ACCESS_KEY }}',
800
+ 'aws-region': '${{ secrets.AWS_REGION }}',
801
+ },
802
+ },
803
+ { name: 'Sync to S3', run: `aws s3 sync ./${awsBuildDir} s3://\${{ secrets.AWS_S3_BUCKET }} --delete` },
577
804
  { name: 'Invalidate CloudFront', run: 'aws cloudfront create-invalidation --distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} --paths "/*"' },
578
805
  );
579
806
  break;
@@ -590,7 +817,15 @@ class WorkflowGenerator {
590
817
 
591
818
  case 'Heroku': {
592
819
  if (buildScript) steps.push({ name: 'Build', run: runCmd(buildScript) });
593
- steps.push({ name: 'Deploy to Heroku', uses: 'akhileshns/heroku-deploy@v3.13.15', with: { heroku_api_key: '${{ secrets.HEROKU_API_KEY }}', heroku_app_name: '${{ secrets.HEROKU_APP_NAME }}', heroku_email: '${{ secrets.HEROKU_EMAIL }}' } });
820
+ steps.push({
821
+ name: 'Deploy to Heroku',
822
+ uses: 'akhileshns/heroku-deploy@v3.13.15',
823
+ with: {
824
+ heroku_api_key: '${{ secrets.HEROKU_API_KEY }}',
825
+ heroku_app_name: '${{ secrets.HEROKU_APP_NAME }}',
826
+ heroku_email: '${{ secrets.HEROKU_EMAIL }}',
827
+ },
828
+ });
594
829
  break;
595
830
  }
596
831
 
@@ -609,7 +844,15 @@ class WorkflowGenerator {
609
844
 
610
845
  case 'Azure': {
611
846
  if (buildScript) steps.push({ name: 'Build', run: runCmd(buildScript) });
612
- steps.push({ name: 'Deploy to Azure Web App', uses: 'azure/webapps-deploy@v3', with: { 'app-name': '${{ secrets.AZURE_APP_NAME }}', 'publish-profile': '${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}', package: (this.frameworks[0] && this.frameworks[0].buildDir) || '.' } });
847
+ steps.push({
848
+ name: 'Deploy to Azure Web App',
849
+ uses: 'azure/webapps-deploy@v3',
850
+ with: {
851
+ 'app-name': '${{ secrets.AZURE_APP_NAME }}',
852
+ 'publish-profile': '${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}',
853
+ package: (this.frameworks[0] && this.frameworks[0].buildDir) || '.',
854
+ },
855
+ });
613
856
  break;
614
857
  }
615
858
 
@@ -620,13 +863,43 @@ class WorkflowGenerator {
620
863
  return steps;
621
864
  }
622
865
 
866
+ // ══════════════════════════════════════════════════════════════════════════
867
+ // Env comment block
868
+ // ══════════════════════════════════════════════════════════════════════════
869
+
870
+ _envComment() {
871
+ const { secrets, public: pub, sourceFile } = this.envVars;
872
+ if (!sourceFile || (secrets.length === 0 && pub.length === 0)) return '';
873
+
874
+ const lines = ['# Environment variables detected from ' + sourceFile + ':'];
875
+ if (secrets.length > 0) {
876
+ lines.push('# Secrets (add to GitHub -> Settings -> Secrets -> Actions):');
877
+ for (const s of secrets) lines.push('# ${{ secrets.' + s + ' }}');
878
+ }
879
+ if (pub.length > 0) {
880
+ lines.push('# Public vars:');
881
+ for (const p of pub) lines.push('# ' + p);
882
+ }
883
+ return lines.join('\n') + '\n';
884
+ }
885
+
623
886
  // ══════════════════════════════════════════════════════════════════════════
624
887
  // Utility helpers
625
888
  // ══════════════════════════════════════════════════════════════════════════
626
889
 
890
+ _langForPackage(pkg) {
891
+ if (!pkg || !pkg.packageJson) return this.primaryLang;
892
+ // If the workspace has its own config, pick up its package manager
893
+ const wsPkg = pkg.packageJson;
894
+ const lang = { ...this.primaryLang };
895
+ if (wsPkg.engines && wsPkg.engines.node) {
896
+ const match = wsPkg.engines.node.match(/(\d+)/);
897
+ if (match) lang.nodeVersion = match[1];
898
+ }
899
+ return lang;
900
+ }
901
+
627
902
  _findScript(names) {
628
- const pkg = this.languages[0];
629
- // Access from the raw packageJson in projectPath
630
903
  const fs = require('fs');
631
904
  const path = require('path');
632
905
  try {
@@ -655,6 +928,10 @@ class WorkflowGenerator {
655
928
  return map[langName] || 'javascript';
656
929
  }
657
930
 
931
+ _slugify(name) {
932
+ return name.replace(/[^a-z0-9]/gi, '-').replace(/-+/g, '-').toLowerCase();
933
+ }
934
+
658
935
  _toYaml(obj, header = '') {
659
936
  const raw = yaml.dump(obj, {
660
937
  indent: 2,