cistack 3.1.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +70 -0
- package/README.md +202 -130
- package/bin/ciflow.js +1 -1
- package/index.d.ts +90 -0
- package/package.json +4 -2
- package/src/analyzers/codebase.js +43 -6
- package/src/analyzers/monorepo.js +7 -7
- package/src/analyzers/workflow.js +10 -2
- package/src/config/loader.js +65 -10
- package/src/detectors/framework.js +29 -25
- package/src/detectors/hosting.js +25 -10
- package/src/detectors/language.js +2 -2
- package/src/detectors/release.js +29 -8
- package/src/detectors/testing.js +18 -2
- package/src/generators/dependabot.js +24 -3
- package/src/generators/release.js +25 -10
- package/src/generators/workflow.js +348 -92
- package/src/index.js +34 -17
- package/src/utils/helpers.js +21 -7
- package/tests/run.js +808 -0
|
@@ -20,7 +20,9 @@ class WorkflowGenerator {
|
|
|
20
20
|
this.projectPath = projectPath;
|
|
21
21
|
this.envVars = config.envVars || { secrets: [], public: [], all: [], sourceFile: null };
|
|
22
22
|
this.monorepoPackages = config.monorepoPackages || [];
|
|
23
|
+
this.lockFiles = new Set(config.lockFiles || []);
|
|
23
24
|
this.extraConfig = config._config || {}; // raw cistack.config.js
|
|
25
|
+
this.defaultBranch = config.defaultBranch || config.currentBranch || null;
|
|
24
26
|
|
|
25
27
|
// Convenient accessors
|
|
26
28
|
this.primaryLang = this.languages[0] || { name: 'JavaScript', packageManager: 'npm', nodeVersion: '20' };
|
|
@@ -35,6 +37,41 @@ class WorkflowGenerator {
|
|
|
35
37
|
(this.extraConfig.monorepo && this.extraConfig.monorepo.perPackage) ||
|
|
36
38
|
this.monorepoPackages.length > 1
|
|
37
39
|
);
|
|
40
|
+
|
|
41
|
+
// Initial runtime detection
|
|
42
|
+
this._detectRuntimeVersions();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_detectRuntimeVersions() {
|
|
46
|
+
const fs = require('fs');
|
|
47
|
+
const path = require('path');
|
|
48
|
+
|
|
49
|
+
// 1. Node.js
|
|
50
|
+
if (!this.primaryLang.nodeVersion) {
|
|
51
|
+
const nvmrcPath = path.join(this.projectPath, '.nvmrc');
|
|
52
|
+
if (fs.existsSync(nvmrcPath)) {
|
|
53
|
+
this.primaryLang.nodeVersion = fs.readFileSync(nvmrcPath, 'utf8').trim().replace('v', '');
|
|
54
|
+
} else {
|
|
55
|
+
try {
|
|
56
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(this.projectPath, 'package.json'), 'utf8'));
|
|
57
|
+
if (pkg.engines && pkg.engines.node) {
|
|
58
|
+
const match = pkg.engines.node.match(/(\d+)/);
|
|
59
|
+
if (match) this.primaryLang.nodeVersion = match[1];
|
|
60
|
+
}
|
|
61
|
+
} catch (_) {}
|
|
62
|
+
}
|
|
63
|
+
this.primaryLang.nodeVersion = this.primaryLang.nodeVersion || '20';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 2. Python
|
|
67
|
+
if (this.primaryLang.name === 'Python' && !this.primaryLang.pythonVersion) {
|
|
68
|
+
const pythonVersionPath = path.join(this.projectPath, '.python-version');
|
|
69
|
+
if (fs.existsSync(pythonVersionPath)) {
|
|
70
|
+
this.primaryLang.pythonVersion = fs.readFileSync(pythonVersionPath, 'utf8').trim();
|
|
71
|
+
} else {
|
|
72
|
+
this.primaryLang.pythonVersion = '3.11';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
38
75
|
}
|
|
39
76
|
|
|
40
77
|
generate() {
|
|
@@ -94,7 +131,7 @@ class WorkflowGenerator {
|
|
|
94
131
|
const lang = this._langForPackage(pkg);
|
|
95
132
|
const jobs = {};
|
|
96
133
|
|
|
97
|
-
const branches = this.
|
|
134
|
+
const branches = this._resolveBranches(['main', 'master', 'develop']);
|
|
98
135
|
|
|
99
136
|
// ── lint job ──────────────────────────────────────────────────────────
|
|
100
137
|
jobs.lint = {
|
|
@@ -104,8 +141,12 @@ class WorkflowGenerator {
|
|
|
104
141
|
this._stepCheckout(),
|
|
105
142
|
...this._setupSteps(lang),
|
|
106
143
|
this._stepInstallDeps(lang),
|
|
107
|
-
this._stepLint(lang),
|
|
144
|
+
this._stepLint(lang, pkg),
|
|
108
145
|
].filter(Boolean),
|
|
146
|
+
env: {
|
|
147
|
+
CI: 'true',
|
|
148
|
+
...this._getPublicEnv(),
|
|
149
|
+
},
|
|
109
150
|
};
|
|
110
151
|
|
|
111
152
|
// ── test job ──────────────────────────────────────────────────────────
|
|
@@ -117,16 +158,20 @@ class WorkflowGenerator {
|
|
|
117
158
|
...(testMatrix ? { strategy: testMatrix } : {}),
|
|
118
159
|
steps: [
|
|
119
160
|
this._stepCheckout(),
|
|
120
|
-
...this._setupSteps(lang),
|
|
161
|
+
...this._setupSteps(lang, !!testMatrix),
|
|
121
162
|
this._stepInstallDeps(lang),
|
|
122
|
-
...this._unitTestSteps(lang),
|
|
163
|
+
...this._unitTestSteps(lang, pkg),
|
|
123
164
|
this._stepUploadCoverage(),
|
|
124
165
|
].filter(Boolean),
|
|
166
|
+
env: {
|
|
167
|
+
CI: 'true',
|
|
168
|
+
...this._getPublicEnv(),
|
|
169
|
+
},
|
|
125
170
|
};
|
|
126
171
|
}
|
|
127
172
|
|
|
128
173
|
// ── build job ─────────────────────────────────────────────────────────
|
|
129
|
-
const buildSteps = this._buildSteps(lang);
|
|
174
|
+
const buildSteps = this._buildSteps(lang, pkg);
|
|
130
175
|
if (buildSteps.length > 0) {
|
|
131
176
|
jobs.build = {
|
|
132
177
|
name: '🏗️ Build',
|
|
@@ -137,18 +182,46 @@ class WorkflowGenerator {
|
|
|
137
182
|
...this._setupSteps(lang),
|
|
138
183
|
this._stepInstallDeps(lang),
|
|
139
184
|
...buildSteps,
|
|
140
|
-
this._stepUploadArtifact(),
|
|
185
|
+
this._stepUploadArtifact(lang),
|
|
141
186
|
].filter(Boolean),
|
|
187
|
+
env: {
|
|
188
|
+
NODE_ENV: 'production',
|
|
189
|
+
...this._getPublicEnv(),
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── lighthouse job ──────────────────────────────────────────────────
|
|
195
|
+
if (jobs.build && this.frameworks.some(f => ['Next.js', 'React', 'Vue', 'Svelte', 'Nuxt'].includes(f.name))) {
|
|
196
|
+
jobs.lighthouse = {
|
|
197
|
+
name: '⚡ Lighthouse Audit',
|
|
198
|
+
'runs-on': 'ubuntu-latest',
|
|
199
|
+
needs: ['build'],
|
|
200
|
+
if: "github.event_name == 'pull_request'",
|
|
201
|
+
steps: [
|
|
202
|
+
this._stepCheckout(),
|
|
203
|
+
{
|
|
204
|
+
name: 'Run Lighthouse on build output',
|
|
205
|
+
uses: 'treosh/lighthouse-ci-action@v11',
|
|
206
|
+
with: {
|
|
207
|
+
uploadArtifacts: true,
|
|
208
|
+
temporaryPublicStorage: true,
|
|
209
|
+
configPath: './.lighthouserc.json',
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
'continue-on-error': true,
|
|
142
214
|
};
|
|
143
215
|
}
|
|
144
216
|
|
|
145
217
|
// ── e2e job ───────────────────────────────────────────────────────────
|
|
146
218
|
if (this.e2eTests.length > 0) {
|
|
147
219
|
const e2eTest = this.e2eTests[0];
|
|
220
|
+
const e2eNeeds = jobs.build ? ['build'] : ['lint', ...(jobs.test ? ['test'] : [])];
|
|
148
221
|
jobs.e2e = {
|
|
149
222
|
name: '🎭 E2E Tests',
|
|
150
223
|
'runs-on': 'ubuntu-latest',
|
|
151
|
-
needs:
|
|
224
|
+
needs: e2eNeeds,
|
|
152
225
|
steps: [
|
|
153
226
|
this._stepCheckout(),
|
|
154
227
|
...this._setupSteps(lang),
|
|
@@ -191,9 +264,13 @@ class WorkflowGenerator {
|
|
|
191
264
|
};
|
|
192
265
|
|
|
193
266
|
const envComment = this._envComment();
|
|
267
|
+
const pipelineStages = ['lint'];
|
|
268
|
+
if (jobs.test) pipelineStages.push('test');
|
|
269
|
+
if (jobs.build) pipelineStages.push('build');
|
|
270
|
+
if (jobs.e2e) pipelineStages.push('e2e');
|
|
194
271
|
const header =
|
|
195
272
|
`# Generated by cistack v${version} — https://github.com/cistack\n` +
|
|
196
|
-
`# CI Pipeline:
|
|
273
|
+
`# CI Pipeline: ${pipelineStages.join(' → ')}\n` +
|
|
197
274
|
envComment +
|
|
198
275
|
`\n`;
|
|
199
276
|
|
|
@@ -203,8 +280,18 @@ class WorkflowGenerator {
|
|
|
203
280
|
// ── Monorepo root CI (matrix over all workspaces) ────────────────────────
|
|
204
281
|
_buildMonorepoRootCI() {
|
|
205
282
|
const lang = this.primaryLang;
|
|
206
|
-
const branches = this.
|
|
207
|
-
const
|
|
283
|
+
const branches = this._resolveBranches(['main', 'master', 'develop']);
|
|
284
|
+
const matrixEntries = this.monorepoPackages.map((pkg) => {
|
|
285
|
+
const pkgScripts = (pkg.packageJson && pkg.packageJson.scripts) || {};
|
|
286
|
+
return {
|
|
287
|
+
name: pkg.name,
|
|
288
|
+
package: pkg.relativePath,
|
|
289
|
+
lintScript: this._findScript(['lint', 'lint:ci', 'eslint'], pkg) || '',
|
|
290
|
+
testScript: (pkgScripts['test:ci'] && 'test:ci') || (pkgScripts.test && 'test') || '',
|
|
291
|
+
buildScript: this._findScript(['build', 'build:prod', 'compile'], pkg) || '',
|
|
292
|
+
};
|
|
293
|
+
});
|
|
294
|
+
const buildablePackages = matrixEntries.filter((pkg) => pkg.buildScript);
|
|
208
295
|
|
|
209
296
|
const workflow = {
|
|
210
297
|
name: 'CI — Monorepo',
|
|
@@ -222,50 +309,71 @@ class WorkflowGenerator {
|
|
|
222
309
|
'runs-on': 'ubuntu-latest',
|
|
223
310
|
strategy: {
|
|
224
311
|
matrix: {
|
|
225
|
-
|
|
312
|
+
include: matrixEntries,
|
|
226
313
|
},
|
|
227
314
|
'fail-fast': false,
|
|
228
315
|
},
|
|
229
316
|
steps: [
|
|
230
317
|
this._stepCheckout(),
|
|
231
318
|
...this._setupSteps(lang),
|
|
232
|
-
|
|
233
|
-
name: 'Install dependencies',
|
|
234
|
-
run: lang.packageManager === 'pnpm'
|
|
235
|
-
? 'pnpm --filter ${{ matrix.package }} install --frozen-lockfile'
|
|
236
|
-
: lang.packageManager === 'yarn'
|
|
237
|
-
? 'yarn workspace ${{ matrix.package }} install'
|
|
238
|
-
: 'npm ci --workspace=${{ matrix.package }}',
|
|
239
|
-
},
|
|
319
|
+
this._stepInstallDeps(lang),
|
|
240
320
|
{
|
|
241
321
|
name: 'Lint',
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
: lang.packageManager === 'yarn'
|
|
245
|
-
? 'yarn workspace ${{ matrix.package }} run lint || true'
|
|
246
|
-
: 'npm run --workspace=${{ matrix.package }} lint || true',
|
|
322
|
+
if: '${{ matrix.lintScript != \'\' }}',
|
|
323
|
+
run: this._workspaceRunCommand(lang, '${{ matrix.lintScript }}'),
|
|
247
324
|
},
|
|
248
325
|
{
|
|
249
326
|
name: 'Test',
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
: lang.packageManager === 'yarn'
|
|
253
|
-
? 'yarn workspace ${{ matrix.package }} run test || true'
|
|
254
|
-
: 'npm run --workspace=${{ matrix.package }} test || true',
|
|
327
|
+
if: '${{ matrix.testScript != \'\' }}',
|
|
328
|
+
run: this._workspaceRunCommand(lang, '${{ matrix.testScript }}'),
|
|
255
329
|
},
|
|
256
330
|
{
|
|
257
331
|
name: 'Build',
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
: lang.packageManager === 'yarn'
|
|
261
|
-
? 'yarn workspace ${{ matrix.package }} run build || true'
|
|
262
|
-
: 'npm run --workspace=${{ matrix.package }} build || true',
|
|
332
|
+
if: '${{ matrix.buildScript != \'\' }}',
|
|
333
|
+
run: this._workspaceRunCommand(lang, '${{ matrix.buildScript }}'),
|
|
263
334
|
},
|
|
264
335
|
].filter(Boolean),
|
|
336
|
+
env: {
|
|
337
|
+
NODE_ENV: 'test',
|
|
338
|
+
CI: 'true',
|
|
339
|
+
...this._getPublicEnv(),
|
|
340
|
+
},
|
|
265
341
|
},
|
|
266
342
|
},
|
|
267
343
|
};
|
|
268
344
|
|
|
345
|
+
if (buildablePackages.length > 0 && this.frameworks.some(f => ['Next.js', 'React', 'Vue', 'Svelte', 'Nuxt'].includes(f.name))) {
|
|
346
|
+
workflow.jobs.lighthouse = {
|
|
347
|
+
name: '⚡ Lighthouse (Root)',
|
|
348
|
+
'runs-on': 'ubuntu-latest',
|
|
349
|
+
strategy: {
|
|
350
|
+
matrix: {
|
|
351
|
+
include: buildablePackages,
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
steps: [
|
|
355
|
+
this._stepCheckout(),
|
|
356
|
+
...this._setupSteps(lang),
|
|
357
|
+
{
|
|
358
|
+
...this._stepInstallDeps(lang),
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
name: 'Build workspace',
|
|
362
|
+
run: this._workspaceRunCommand(lang, '${{ matrix.buildScript }}'),
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
name: 'Lighthouse',
|
|
366
|
+
uses: 'treosh/lighthouse-ci-action@v11',
|
|
367
|
+
with: {
|
|
368
|
+
uploadArtifacts: true,
|
|
369
|
+
temporaryPublicStorage: true,
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
],
|
|
373
|
+
'continue-on-error': true,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
269
377
|
return this._toYaml(
|
|
270
378
|
workflow,
|
|
271
379
|
`# Generated by cistack v${version} — https://github.com/cistack\n# Monorepo CI — matrix over all workspaces\n\n`
|
|
@@ -279,7 +387,11 @@ class WorkflowGenerator {
|
|
|
279
387
|
_buildDeployWorkflow() {
|
|
280
388
|
const h = this.primaryHosting;
|
|
281
389
|
const lang = this.primaryLang;
|
|
282
|
-
const branches = this.
|
|
390
|
+
const branches = this._resolveBranches(['main', 'master']);
|
|
391
|
+
const productionBranches = branches.filter((b) => b !== 'develop');
|
|
392
|
+
const deployBranches = productionBranches.length > 0 ? productionBranches : branches;
|
|
393
|
+
const isGHPages = h.name === 'GitHub Pages';
|
|
394
|
+
const supportsPreview = ['Firebase', 'Vercel', 'Netlify'].includes(h.name);
|
|
283
395
|
|
|
284
396
|
const preDeploySteps = [
|
|
285
397
|
this._stepCheckout(),
|
|
@@ -287,15 +399,23 @@ class WorkflowGenerator {
|
|
|
287
399
|
this._stepInstallDeps(lang),
|
|
288
400
|
].filter(Boolean);
|
|
289
401
|
|
|
290
|
-
const
|
|
291
|
-
const
|
|
402
|
+
const primaryDeployBranch = deployBranches[0] || this.defaultBranch || 'main';
|
|
403
|
+
const deploySteps = this._hostingDeploySteps(h, lang, false, primaryDeployBranch); // production
|
|
404
|
+
// Only generate PR preview steps for platforms that natively isolate them
|
|
405
|
+
const previewSteps = supportsPreview ? this._hostingDeploySteps(h, lang, true, primaryDeployBranch) : [];
|
|
406
|
+
|
|
407
|
+
// GitHub Pages requires special permissions on the deploy job
|
|
408
|
+
const ghPagesPermissions = isGHPages
|
|
409
|
+
? { pages: 'write', 'id-token': 'write', contents: 'read' }
|
|
410
|
+
: undefined;
|
|
292
411
|
|
|
293
412
|
const jobs = {
|
|
294
413
|
deploy: {
|
|
295
414
|
name: `🚀 Deploy → ${h.name} (Production)`,
|
|
296
415
|
if: "github.event_name == 'push' || github.event_name == 'workflow_dispatch'",
|
|
297
416
|
'runs-on': 'ubuntu-latest',
|
|
298
|
-
environment: 'production',
|
|
417
|
+
environment: isGHPages ? 'github-pages' : 'production',
|
|
418
|
+
...(ghPagesPermissions ? { permissions: ghPagesPermissions } : {}),
|
|
299
419
|
steps: [...preDeploySteps, ...deploySteps].filter(Boolean),
|
|
300
420
|
},
|
|
301
421
|
};
|
|
@@ -307,7 +427,27 @@ class WorkflowGenerator {
|
|
|
307
427
|
if: "github.event_name == 'pull_request'",
|
|
308
428
|
'runs-on': 'ubuntu-latest',
|
|
309
429
|
environment: 'preview',
|
|
310
|
-
steps: [
|
|
430
|
+
steps: [
|
|
431
|
+
...preDeploySteps,
|
|
432
|
+
...previewSteps,
|
|
433
|
+
{
|
|
434
|
+
name: 'Comment PR',
|
|
435
|
+
if: 'always()',
|
|
436
|
+
uses: 'actions/github-script@v7',
|
|
437
|
+
with: {
|
|
438
|
+
script: `
|
|
439
|
+
const deploymentUrl = process.env.DEPLOYMENT_URL;
|
|
440
|
+
if (!deploymentUrl) return;
|
|
441
|
+
github.rest.issues.createComment({
|
|
442
|
+
issue_number: context.issue.number,
|
|
443
|
+
owner: context.repo.owner,
|
|
444
|
+
repo: context.repo.repo,
|
|
445
|
+
body: '🚀 **Deployment Preview Ready!**\\n\\n[View Preview](' + deploymentUrl + ')'
|
|
446
|
+
});
|
|
447
|
+
`
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
].filter(Boolean),
|
|
311
451
|
};
|
|
312
452
|
}
|
|
313
453
|
|
|
@@ -323,12 +463,17 @@ class WorkflowGenerator {
|
|
|
323
463
|
|
|
324
464
|
const envComment = this._envComment();
|
|
325
465
|
|
|
466
|
+
// Only trigger on PR if the platform supports preview deployments
|
|
467
|
+
const onTrigger = supportsPreview
|
|
468
|
+
? { push: { branches: deployBranches }, pull_request: { branches }, workflow_dispatch: {} }
|
|
469
|
+
: { push: { branches: deployBranches }, workflow_dispatch: {} };
|
|
470
|
+
|
|
326
471
|
const workflow = {
|
|
327
472
|
name: `Deploy to ${h.name}`,
|
|
328
|
-
on:
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
473
|
+
on: onTrigger,
|
|
474
|
+
concurrency: {
|
|
475
|
+
group: '${{ github.workflow }}-${{ github.ref }}',
|
|
476
|
+
'cancel-in-progress': true,
|
|
332
477
|
},
|
|
333
478
|
jobs,
|
|
334
479
|
};
|
|
@@ -344,14 +489,15 @@ class WorkflowGenerator {
|
|
|
344
489
|
// ══════════════════════════════════════════════════════════════════════════
|
|
345
490
|
|
|
346
491
|
_buildDockerWorkflow() {
|
|
492
|
+
const branches = this._resolveBranches(['main', 'master']);
|
|
347
493
|
const workflow = {
|
|
348
494
|
name: 'Docker Build & Push',
|
|
349
495
|
on: {
|
|
350
496
|
push: {
|
|
351
|
-
branches
|
|
497
|
+
branches,
|
|
352
498
|
tags: ['v*.*.*'],
|
|
353
499
|
},
|
|
354
|
-
pull_request: { branches
|
|
500
|
+
pull_request: { branches },
|
|
355
501
|
},
|
|
356
502
|
env: {
|
|
357
503
|
REGISTRY: 'ghcr.io',
|
|
@@ -422,6 +568,7 @@ class WorkflowGenerator {
|
|
|
422
568
|
|
|
423
569
|
_buildSecurityWorkflow() {
|
|
424
570
|
const lang = this.primaryLang;
|
|
571
|
+
const branches = this._resolveBranches(['main', 'master']);
|
|
425
572
|
const steps = [this._stepCheckout()];
|
|
426
573
|
|
|
427
574
|
if (['JavaScript', 'TypeScript'].includes(lang.name)) {
|
|
@@ -441,7 +588,7 @@ class WorkflowGenerator {
|
|
|
441
588
|
|
|
442
589
|
if (lang.name === 'Python') {
|
|
443
590
|
steps.push(
|
|
444
|
-
{ name: 'Set up Python', uses: 'actions/setup-python@v5', with: { 'python-version': '3.
|
|
591
|
+
{ name: 'Set up Python', uses: 'actions/setup-python@v5', with: { 'python-version': lang.pythonVersion || '3.11' } },
|
|
445
592
|
{ name: 'Install safety', run: 'pip install safety' },
|
|
446
593
|
{ name: 'Run safety check', run: 'safety check' },
|
|
447
594
|
);
|
|
@@ -467,19 +614,19 @@ class WorkflowGenerator {
|
|
|
467
614
|
const workflow = {
|
|
468
615
|
name: 'Security Audit',
|
|
469
616
|
on: {
|
|
470
|
-
push: { branches
|
|
471
|
-
pull_request: { branches
|
|
617
|
+
push: { branches },
|
|
618
|
+
pull_request: { branches },
|
|
472
619
|
schedule: [{ cron: '0 6 * * 1' }],
|
|
473
620
|
},
|
|
621
|
+
permissions: {
|
|
622
|
+
actions: 'read',
|
|
623
|
+
contents: 'read',
|
|
624
|
+
'security-events': 'write',
|
|
625
|
+
},
|
|
474
626
|
jobs: {
|
|
475
627
|
security: {
|
|
476
628
|
name: '🔒 Security Audit',
|
|
477
629
|
'runs-on': 'ubuntu-latest',
|
|
478
|
-
permissions: {
|
|
479
|
-
actions: 'read',
|
|
480
|
-
contents: 'read',
|
|
481
|
-
'security-events': 'write',
|
|
482
|
-
},
|
|
483
630
|
steps: steps.filter(Boolean),
|
|
484
631
|
},
|
|
485
632
|
},
|
|
@@ -500,12 +647,19 @@ class WorkflowGenerator {
|
|
|
500
647
|
* Returns setup + cache steps for the given language.
|
|
501
648
|
* v2.0.0: added explicit caching for pip, poetry, cargo, maven, gradle, bundler, go, composer.
|
|
502
649
|
*/
|
|
503
|
-
_setupSteps(lang) {
|
|
650
|
+
_setupSteps(lang, useMatrix = false) {
|
|
504
651
|
const steps = [];
|
|
505
652
|
const cacheOverride = this.extraConfig.cache || {};
|
|
506
653
|
|
|
507
654
|
// ── JavaScript / TypeScript ──────────────────────────────────────────
|
|
508
655
|
if (['JavaScript', 'TypeScript'].includes(lang.name)) {
|
|
656
|
+
if (lang.packageManager === 'bun') {
|
|
657
|
+
steps.push({
|
|
658
|
+
name: 'Set up Bun',
|
|
659
|
+
uses: 'oven-sh/setup-bun@v2',
|
|
660
|
+
with: { 'bun-version': 'latest' },
|
|
661
|
+
});
|
|
662
|
+
}
|
|
509
663
|
if (lang.packageManager === 'pnpm') {
|
|
510
664
|
steps.push({ name: 'Install pnpm', uses: 'pnpm/action-setup@v3', with: { version: 'latest' } });
|
|
511
665
|
}
|
|
@@ -513,9 +667,17 @@ class WorkflowGenerator {
|
|
|
513
667
|
name: 'Set up Node.js',
|
|
514
668
|
uses: 'actions/setup-node@v4',
|
|
515
669
|
with: {
|
|
516
|
-
'node-version': lang.nodeVersion || '20',
|
|
670
|
+
'node-version': useMatrix ? '${{ matrix.node-version }}' : (lang.nodeVersion || '20'),
|
|
517
671
|
// Use native caching in setup-node
|
|
518
|
-
cache: cacheOverride.npm !== false
|
|
672
|
+
cache: cacheOverride.npm !== false
|
|
673
|
+
? (lang.packageManager === 'yarn'
|
|
674
|
+
? 'yarn'
|
|
675
|
+
: lang.packageManager === 'pnpm'
|
|
676
|
+
? 'pnpm'
|
|
677
|
+
: lang.packageManager === 'npm'
|
|
678
|
+
? 'npm'
|
|
679
|
+
: undefined)
|
|
680
|
+
: undefined,
|
|
519
681
|
},
|
|
520
682
|
});
|
|
521
683
|
}
|
|
@@ -526,7 +688,7 @@ class WorkflowGenerator {
|
|
|
526
688
|
name: 'Set up Python',
|
|
527
689
|
uses: 'actions/setup-python@v5',
|
|
528
690
|
with: {
|
|
529
|
-
'python-version': '3.
|
|
691
|
+
'python-version': useMatrix ? '${{ matrix.python-version }}' : (lang.pythonVersion || '3.11'),
|
|
530
692
|
// Native caching for pip/poetry
|
|
531
693
|
cache: cacheOverride.pip !== false ? (lang.packageManager === 'poetry' ? 'poetry' : 'pip') : undefined
|
|
532
694
|
},
|
|
@@ -614,9 +776,9 @@ class WorkflowGenerator {
|
|
|
614
776
|
|
|
615
777
|
_stepInstallDeps(lang) {
|
|
616
778
|
const pm = lang.packageManager;
|
|
617
|
-
if (pm === 'npm') return { name: 'Install dependencies', run: 'npm ci' };
|
|
618
|
-
if (pm === 'yarn') return { name: 'Install dependencies', run: 'yarn install --frozen-lockfile' };
|
|
619
|
-
if (pm === 'pnpm') return { name: 'Install dependencies', run: 'pnpm install --frozen-lockfile' };
|
|
779
|
+
if (pm === 'npm') return { name: 'Install dependencies', run: this.lockFiles.has('package-lock.json') ? 'npm ci' : 'npm install' };
|
|
780
|
+
if (pm === 'yarn') return { name: 'Install dependencies', run: this.lockFiles.has('yarn.lock') ? 'yarn install --frozen-lockfile' : 'yarn install' };
|
|
781
|
+
if (pm === 'pnpm') return { name: 'Install dependencies', run: this.lockFiles.has('pnpm-lock.yaml') ? 'pnpm install --frozen-lockfile' : 'pnpm install' };
|
|
620
782
|
if (pm === 'bun') return { name: 'Install dependencies', run: 'bun install' };
|
|
621
783
|
if (pm === 'pip') return { name: 'Install dependencies', run: 'pip install -r requirements.txt' };
|
|
622
784
|
if (pm === 'poetry') return { name: 'Install dependencies', run: 'pip install poetry && poetry install' };
|
|
@@ -630,46 +792,50 @@ class WorkflowGenerator {
|
|
|
630
792
|
return { name: 'Install dependencies', run: 'npm ci' };
|
|
631
793
|
}
|
|
632
794
|
|
|
633
|
-
_stepLint(lang) {
|
|
634
|
-
const pm = lang.packageManager || 'npm';
|
|
635
|
-
|
|
795
|
+
_stepLint(lang, pkg = null) {
|
|
636
796
|
if (['JavaScript', 'TypeScript'].includes(lang.name)) {
|
|
637
|
-
const lintScript = this._findScript(['lint', 'lint:ci', 'eslint']);
|
|
638
|
-
const typeCheck = this._findScript(['type-check', 'typecheck', 'tsc']);
|
|
639
|
-
const format = this._findScript(['format:check', 'prettier:check', 'fmt:check']);
|
|
640
|
-
const runPfx = pm === 'yarn' ? 'yarn' : pm === 'pnpm' ? 'pnpm' : 'npm run';
|
|
797
|
+
const lintScript = this._findScript(['lint', 'lint:ci', 'eslint'], pkg);
|
|
798
|
+
const typeCheck = this._findScript(['type-check', 'typecheck', 'tsc'], pkg);
|
|
799
|
+
const format = this._findScript(['format:check', 'prettier:check', 'fmt:check'], pkg);
|
|
641
800
|
|
|
642
801
|
const cmds = [];
|
|
643
|
-
if (lintScript) cmds.push(
|
|
644
|
-
if (typeCheck) cmds.push(
|
|
645
|
-
if (format) cmds.push(
|
|
646
|
-
if (cmds.length === 0)
|
|
802
|
+
if (lintScript) cmds.push(this._scriptCommand(lang, lintScript, pkg));
|
|
803
|
+
if (typeCheck) cmds.push(this._scriptCommand(lang, typeCheck, pkg));
|
|
804
|
+
if (format) cmds.push(this._scriptCommand(lang, format, pkg));
|
|
805
|
+
if (cmds.length === 0) {
|
|
806
|
+
const lintTarget = pkg ? pkg.relativePath : '.';
|
|
807
|
+
cmds.push(`npx eslint ${lintTarget} --ext .js,.jsx,.ts,.tsx --max-warnings 0`);
|
|
808
|
+
}
|
|
647
809
|
|
|
648
810
|
return { name: 'Lint', run: cmds.join('\n') };
|
|
649
811
|
}
|
|
650
812
|
|
|
651
|
-
if (lang.name === 'Python') return { name: 'Lint', run: 'pip install flake8 && flake8 .' };
|
|
813
|
+
if (lang.name === 'Python') return { name: 'Lint', run: 'pip install flake8 black && flake8 . && black --check .' };
|
|
652
814
|
if (lang.name === 'Go') return { name: 'Lint', run: 'gofmt -d . && go vet ./...' };
|
|
653
815
|
if (lang.name === 'Rust') return { name: 'Lint', run: 'cargo clippy -- -D warnings && cargo fmt --check' };
|
|
654
816
|
if (lang.name === 'Ruby') return { name: 'Lint', run: 'gem install rubocop && rubocop' };
|
|
655
|
-
if (lang.name === 'PHP') return { name: 'Lint', run: 'vendor/bin/phpcs' };
|
|
817
|
+
if (lang.name === 'PHP') return { name: 'Lint', run: 'vendor/bin/phpcs && vendor/bin/phpstan analyze' };
|
|
656
818
|
|
|
657
819
|
return null;
|
|
658
820
|
}
|
|
659
821
|
|
|
660
|
-
_unitTestSteps(lang) {
|
|
822
|
+
_unitTestSteps(lang, pkg = null) {
|
|
823
|
+
if (pkg && ['JavaScript', 'TypeScript'].includes(lang.name)) {
|
|
824
|
+
const pkgTestScript = this._findScript(['test:ci', 'test'], pkg);
|
|
825
|
+
if (pkgTestScript) {
|
|
826
|
+
return [{ name: 'Run tests', run: this._scriptCommand(lang, pkgTestScript, pkg) }];
|
|
827
|
+
}
|
|
828
|
+
}
|
|
661
829
|
return this.unitTests.map((t) => ({ name: `Run ${t.name}`, run: t.command }));
|
|
662
830
|
}
|
|
663
831
|
|
|
664
|
-
_buildSteps(lang) {
|
|
665
|
-
const
|
|
666
|
-
const buildScript = this._findScript(['build', 'build:prod']);
|
|
832
|
+
_buildSteps(lang, pkg = null) {
|
|
833
|
+
const buildScript = this._findScript(['build', 'build:prod', 'compile'], pkg);
|
|
667
834
|
if (!buildScript && !['Go', 'Rust', 'Java', 'Kotlin'].includes(lang.name)) return [];
|
|
668
835
|
|
|
669
836
|
const steps = [];
|
|
670
837
|
if (['JavaScript', 'TypeScript'].includes(lang.name) && buildScript) {
|
|
671
|
-
|
|
672
|
-
steps.push({ name: 'Build', run: `${runPfx} run ${buildScript}`, env: { NODE_ENV: 'production' } });
|
|
838
|
+
steps.push({ name: 'Build', run: this._scriptCommand(lang, buildScript, pkg), env: { NODE_ENV: 'production' } });
|
|
673
839
|
}
|
|
674
840
|
if (lang.name === 'Go') steps.push({ name: 'Build', run: 'go build -v ./...' });
|
|
675
841
|
if (lang.name === 'Rust') steps.push({ name: 'Build', run: 'cargo build --release' });
|
|
@@ -679,8 +845,21 @@ class WorkflowGenerator {
|
|
|
679
845
|
return steps;
|
|
680
846
|
}
|
|
681
847
|
|
|
682
|
-
_stepUploadArtifact() {
|
|
683
|
-
|
|
848
|
+
_stepUploadArtifact(lang) {
|
|
849
|
+
let buildDir = null;
|
|
850
|
+
|
|
851
|
+
if (['JavaScript', 'TypeScript'].includes(lang.name)) {
|
|
852
|
+
buildDir = (this.frameworks[0] && this.frameworks[0].buildDir) || null;
|
|
853
|
+
} else if (lang.name === 'Rust') {
|
|
854
|
+
buildDir = 'target/release';
|
|
855
|
+
} else if (lang.name === 'Java') {
|
|
856
|
+
buildDir = 'target';
|
|
857
|
+
} else if (lang.name === 'Kotlin') {
|
|
858
|
+
buildDir = 'build/libs';
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (!buildDir) return null;
|
|
862
|
+
|
|
684
863
|
return {
|
|
685
864
|
name: 'Upload build artifact',
|
|
686
865
|
uses: 'actions/upload-artifact@v4',
|
|
@@ -710,15 +889,48 @@ class WorkflowGenerator {
|
|
|
710
889
|
return null;
|
|
711
890
|
}
|
|
712
891
|
|
|
892
|
+
_resolveBranches(fallback) {
|
|
893
|
+
if (Array.isArray(this.extraConfig.branches) && this.extraConfig.branches.length > 0) {
|
|
894
|
+
return [...new Set(this.extraConfig.branches)];
|
|
895
|
+
}
|
|
896
|
+
if (this.defaultBranch) {
|
|
897
|
+
return [this.defaultBranch];
|
|
898
|
+
}
|
|
899
|
+
return fallback;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
_workspaceRunCommand(lang, scriptName) {
|
|
903
|
+
const pm = lang.packageManager || 'npm';
|
|
904
|
+
if (pm === 'pnpm') return `pnpm --filter \${{ matrix.package }} run ${scriptName}`;
|
|
905
|
+
if (pm === 'yarn') return `yarn workspace \${{ matrix.name }} run ${scriptName}`;
|
|
906
|
+
if (pm === 'bun') return `bun run --filter './\${{ matrix.package }}' ${scriptName}`;
|
|
907
|
+
return `npm run ${scriptName} --workspace=\${{ matrix.name }}`;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
_scriptCommand(lang, scriptName, pkg = null) {
|
|
911
|
+
const pm = lang.packageManager || 'npm';
|
|
912
|
+
|
|
913
|
+
if (pkg) {
|
|
914
|
+
if (pm === 'pnpm') return `pnpm --filter ${pkg.relativePath} run ${scriptName}`;
|
|
915
|
+
if (pm === 'yarn') return `yarn workspace ${pkg.name} run ${scriptName}`;
|
|
916
|
+
if (pm === 'bun') return `bun run --filter './${pkg.relativePath}' ${scriptName}`;
|
|
917
|
+
return `npm run ${scriptName} --workspace=${pkg.name}`;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if (pm === 'yarn') return `yarn run ${scriptName}`;
|
|
921
|
+
if (pm === 'pnpm') return `pnpm run ${scriptName}`;
|
|
922
|
+
if (pm === 'bun') return `bun run ${scriptName}`;
|
|
923
|
+
return `npm run ${scriptName}`;
|
|
924
|
+
}
|
|
925
|
+
|
|
713
926
|
// ══════════════════════════════════════════════════════════════════════════
|
|
714
927
|
// Hosting-specific deploy steps
|
|
715
928
|
// ══════════════════════════════════════════════════════════════════════════
|
|
716
929
|
|
|
717
|
-
_hostingDeploySteps(h, lang, isPreview = false) {
|
|
930
|
+
_hostingDeploySteps(h, lang, isPreview = false, productionBranch = null) {
|
|
718
931
|
const steps = [];
|
|
719
932
|
const buildScript = this._findScript(['build', 'build:prod']);
|
|
720
|
-
const
|
|
721
|
-
const runCmd = (s) => `${pm === 'yarn' ? 'yarn' : pm === 'pnpm' ? 'pnpm' : 'npm'} run ${s}`;
|
|
933
|
+
const runCmd = (s) => this._scriptCommand(lang, s);
|
|
722
934
|
|
|
723
935
|
switch (h.name) {
|
|
724
936
|
case 'Firebase': {
|
|
@@ -739,11 +951,29 @@ class WorkflowGenerator {
|
|
|
739
951
|
|
|
740
952
|
case 'Vercel': {
|
|
741
953
|
const prodFlag = isPreview ? '' : '--prod';
|
|
954
|
+
const vercelEnv = {
|
|
955
|
+
VERCEL_TOKEN: '${{ secrets.VERCEL_TOKEN }}',
|
|
956
|
+
VERCEL_ORG_ID: '${{ secrets.VERCEL_ORG_ID }}',
|
|
957
|
+
VERCEL_PROJECT_ID: '${{ secrets.VERCEL_PROJECT_ID }}',
|
|
958
|
+
};
|
|
742
959
|
steps.push(
|
|
743
960
|
{ name: 'Install Vercel CLI', run: 'npm install -g vercel' },
|
|
744
|
-
{
|
|
745
|
-
|
|
746
|
-
|
|
961
|
+
{
|
|
962
|
+
name: 'Pull Vercel environment',
|
|
963
|
+
run: `vercel pull --yes --environment=${isPreview ? 'preview' : 'production'} --token=\${{ secrets.VERCEL_TOKEN }}`,
|
|
964
|
+
env: vercelEnv,
|
|
965
|
+
},
|
|
966
|
+
{
|
|
967
|
+
name: 'Build project',
|
|
968
|
+
run: `vercel build${prodFlag ? ' ' + prodFlag : ''} --token=\${{ secrets.VERCEL_TOKEN }}`,
|
|
969
|
+
env: vercelEnv,
|
|
970
|
+
},
|
|
971
|
+
{
|
|
972
|
+
name: 'Deploy to Vercel',
|
|
973
|
+
id: 'deploy',
|
|
974
|
+
run: `vercel deploy --prebuilt${prodFlag ? ' ' + prodFlag : ''} --token=\${{ secrets.VERCEL_TOKEN }} > deployment_url.txt && echo "DEPLOYMENT_URL=$(cat deployment_url.txt)" >> $GITHUB_ENV`,
|
|
975
|
+
env: vercelEnv,
|
|
976
|
+
},
|
|
747
977
|
);
|
|
748
978
|
break;
|
|
749
979
|
}
|
|
@@ -754,10 +984,11 @@ class WorkflowGenerator {
|
|
|
754
984
|
}
|
|
755
985
|
steps.push({
|
|
756
986
|
name: isPreview ? 'Deploy Preview' : 'Deploy to Netlify',
|
|
987
|
+
id: 'netlify_deploy',
|
|
757
988
|
uses: 'nwtgck/actions-netlify@v3.0',
|
|
758
989
|
with: {
|
|
759
|
-
'publish-dir': h.publishDir || 'dist',
|
|
760
|
-
'production-branch': 'main',
|
|
990
|
+
'publish-dir': h.publishDir || (this.frameworks[0] && this.frameworks[0].buildDir) || 'dist',
|
|
991
|
+
'production-branch': productionBranch || this.defaultBranch || 'main',
|
|
761
992
|
'github-token': '${{ secrets.GITHUB_TOKEN }}',
|
|
762
993
|
'deploy-message': isPreview ? 'Preview Deploy – ${{ github.event.number }}' : 'Production Deploy – ${{ github.sha }}',
|
|
763
994
|
'enable-pull-request-comment': true,
|
|
@@ -770,6 +1001,12 @@ class WorkflowGenerator {
|
|
|
770
1001
|
NETLIFY_SITE_ID: '${{ secrets.NETLIFY_SITE_ID }}',
|
|
771
1002
|
},
|
|
772
1003
|
});
|
|
1004
|
+
if (isPreview) {
|
|
1005
|
+
steps.push({
|
|
1006
|
+
name: 'Set Netlify URL',
|
|
1007
|
+
run: 'echo "DEPLOYMENT_URL=${{ steps.netlify_deploy.outputs.deploy-url }}" >> $GITHUB_ENV'
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
773
1010
|
break;
|
|
774
1011
|
}
|
|
775
1012
|
|
|
@@ -832,7 +1069,10 @@ class WorkflowGenerator {
|
|
|
832
1069
|
}
|
|
833
1070
|
|
|
834
1071
|
case 'Render': {
|
|
835
|
-
|
|
1072
|
+
// Render doesn't support PR preview deploys via deploy hook
|
|
1073
|
+
if (!isPreview) {
|
|
1074
|
+
steps.push({ name: 'Trigger Render deploy', run: 'curl -X POST "${{ secrets.RENDER_DEPLOY_HOOK_URL }}"' });
|
|
1075
|
+
}
|
|
836
1076
|
break;
|
|
837
1077
|
}
|
|
838
1078
|
|
|
@@ -901,11 +1141,12 @@ class WorkflowGenerator {
|
|
|
901
1141
|
return lang;
|
|
902
1142
|
}
|
|
903
1143
|
|
|
904
|
-
_findScript(names) {
|
|
1144
|
+
_findScript(names, pkg = null) {
|
|
905
1145
|
const fs = require('fs');
|
|
906
1146
|
const path = require('path');
|
|
907
1147
|
try {
|
|
908
|
-
const
|
|
1148
|
+
const pkgPath = pkg ? path.join(this.projectPath, pkg.relativePath, 'package.json') : path.join(this.projectPath, 'package.json');
|
|
1149
|
+
const raw = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
909
1150
|
const scripts = raw.scripts || {};
|
|
910
1151
|
for (const n of names) {
|
|
911
1152
|
if (scripts[n]) return n;
|
|
@@ -944,6 +1185,21 @@ class WorkflowGenerator {
|
|
|
944
1185
|
});
|
|
945
1186
|
return header + raw;
|
|
946
1187
|
}
|
|
1188
|
+
|
|
1189
|
+
_getPublicEnv() {
|
|
1190
|
+
const env = {};
|
|
1191
|
+
if (this.envVars && this.envVars.public) {
|
|
1192
|
+
for (const p of this.envVars.public) {
|
|
1193
|
+
const parts = p.split('=');
|
|
1194
|
+
if (parts.length >= 2) {
|
|
1195
|
+
env[parts[0]] = parts.slice(1).join('=');
|
|
1196
|
+
} else {
|
|
1197
|
+
env[p] = `\${{ vars.${p} || secrets.${p} }}`;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
return env;
|
|
1202
|
+
}
|
|
947
1203
|
}
|
|
948
1204
|
|
|
949
1205
|
module.exports = WorkflowGenerator;
|