cistack 3.2.0 → 5.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 +183 -124
- package/bin/ciflow.js +3 -2
- package/index.d.ts +91 -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 +66 -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 +330 -99
- package/src/index.js +33 -18
- package/src/utils/helpers.js +21 -7
- package/src/utils/workflow-combiner.js +238 -0
- package/tests/run.js +934 -0
|
@@ -20,7 +20,10 @@ 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;
|
|
26
|
+
this.workflowLayout = this.extraConfig.workflowLayout === 'split' ? 'split' : 'single';
|
|
24
27
|
|
|
25
28
|
// Convenient accessors
|
|
26
29
|
this.primaryLang = this.languages[0] || { name: 'JavaScript', packageManager: 'npm', nodeVersion: '20' };
|
|
@@ -29,26 +32,64 @@ class WorkflowGenerator {
|
|
|
29
32
|
this.hasDocker = this.hosting.some((h) => h.name === 'Docker');
|
|
30
33
|
this.primaryHosting = this.hosting.filter((h) => h.name !== 'Docker')[0] || null;
|
|
31
34
|
|
|
32
|
-
// Monorepo mode:
|
|
35
|
+
// Monorepo mode: always keep the root matrix workflow for monorepos.
|
|
36
|
+
// Split layout may additionally emit one workflow per workspace.
|
|
33
37
|
this.isMonorepo = this.monorepoPackages.length > 0;
|
|
34
|
-
this.perPackageWorkflows = this.isMonorepo && (
|
|
38
|
+
this.perPackageWorkflows = this.workflowLayout === 'split' && this.isMonorepo && (
|
|
35
39
|
(this.extraConfig.monorepo && this.extraConfig.monorepo.perPackage) ||
|
|
36
40
|
this.monorepoPackages.length > 1
|
|
37
41
|
);
|
|
42
|
+
|
|
43
|
+
// Initial runtime detection
|
|
44
|
+
this._detectRuntimeVersions();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_detectRuntimeVersions() {
|
|
48
|
+
const fs = require('fs');
|
|
49
|
+
const path = require('path');
|
|
50
|
+
|
|
51
|
+
// 1. Node.js
|
|
52
|
+
if (!this.primaryLang.nodeVersion) {
|
|
53
|
+
const nvmrcPath = path.join(this.projectPath, '.nvmrc');
|
|
54
|
+
if (fs.existsSync(nvmrcPath)) {
|
|
55
|
+
this.primaryLang.nodeVersion = fs.readFileSync(nvmrcPath, 'utf8').trim().replace('v', '');
|
|
56
|
+
} else {
|
|
57
|
+
try {
|
|
58
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(this.projectPath, 'package.json'), 'utf8'));
|
|
59
|
+
if (pkg.engines && pkg.engines.node) {
|
|
60
|
+
const match = pkg.engines.node.match(/(\d+)/);
|
|
61
|
+
if (match) this.primaryLang.nodeVersion = match[1];
|
|
62
|
+
}
|
|
63
|
+
} catch (_) {}
|
|
64
|
+
}
|
|
65
|
+
this.primaryLang.nodeVersion = this.primaryLang.nodeVersion || '20';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 2. Python
|
|
69
|
+
if (this.primaryLang.name === 'Python' && !this.primaryLang.pythonVersion) {
|
|
70
|
+
const pythonVersionPath = path.join(this.projectPath, '.python-version');
|
|
71
|
+
if (fs.existsSync(pythonVersionPath)) {
|
|
72
|
+
this.primaryLang.pythonVersion = fs.readFileSync(pythonVersionPath, 'utf8').trim();
|
|
73
|
+
} else {
|
|
74
|
+
this.primaryLang.pythonVersion = '3.11';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
38
77
|
}
|
|
39
78
|
|
|
40
79
|
generate() {
|
|
41
80
|
const workflows = [];
|
|
42
81
|
|
|
43
|
-
if (this.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
82
|
+
if (this.isMonorepo) {
|
|
83
|
+
if (this.perPackageWorkflows) {
|
|
84
|
+
// ── Monorepo split mode: one CI file per workspace ───────────────
|
|
85
|
+
for (const pkg of this.monorepoPackages) {
|
|
86
|
+
workflows.push({
|
|
87
|
+
filename: `ci-${this._slugify(pkg.name)}.yml`,
|
|
88
|
+
content: this._buildCIWorkflow(pkg),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
50
91
|
}
|
|
51
|
-
//
|
|
92
|
+
// ── Monorepo root CI file (matrix over all packages) ──────────────
|
|
52
93
|
workflows.push({
|
|
53
94
|
filename: 'ci.yml',
|
|
54
95
|
content: this._buildMonorepoRootCI(),
|
|
@@ -94,7 +135,7 @@ class WorkflowGenerator {
|
|
|
94
135
|
const lang = this._langForPackage(pkg);
|
|
95
136
|
const jobs = {};
|
|
96
137
|
|
|
97
|
-
const branches = this.
|
|
138
|
+
const branches = this._resolveBranches(['main', 'master', 'develop']);
|
|
98
139
|
|
|
99
140
|
// ── lint job ──────────────────────────────────────────────────────────
|
|
100
141
|
jobs.lint = {
|
|
@@ -104,8 +145,12 @@ class WorkflowGenerator {
|
|
|
104
145
|
this._stepCheckout(),
|
|
105
146
|
...this._setupSteps(lang),
|
|
106
147
|
this._stepInstallDeps(lang),
|
|
107
|
-
this._stepLint(lang),
|
|
148
|
+
this._stepLint(lang, pkg),
|
|
108
149
|
].filter(Boolean),
|
|
150
|
+
env: {
|
|
151
|
+
CI: 'true',
|
|
152
|
+
...this._getPublicEnv(),
|
|
153
|
+
},
|
|
109
154
|
};
|
|
110
155
|
|
|
111
156
|
// ── test job ──────────────────────────────────────────────────────────
|
|
@@ -117,16 +162,20 @@ class WorkflowGenerator {
|
|
|
117
162
|
...(testMatrix ? { strategy: testMatrix } : {}),
|
|
118
163
|
steps: [
|
|
119
164
|
this._stepCheckout(),
|
|
120
|
-
...this._setupSteps(lang),
|
|
165
|
+
...this._setupSteps(lang, !!testMatrix),
|
|
121
166
|
this._stepInstallDeps(lang),
|
|
122
|
-
...this._unitTestSteps(lang),
|
|
167
|
+
...this._unitTestSteps(lang, pkg),
|
|
123
168
|
this._stepUploadCoverage(),
|
|
124
169
|
].filter(Boolean),
|
|
170
|
+
env: {
|
|
171
|
+
CI: 'true',
|
|
172
|
+
...this._getPublicEnv(),
|
|
173
|
+
},
|
|
125
174
|
};
|
|
126
175
|
}
|
|
127
176
|
|
|
128
177
|
// ── build job ─────────────────────────────────────────────────────────
|
|
129
|
-
const buildSteps = this._buildSteps(lang);
|
|
178
|
+
const buildSteps = this._buildSteps(lang, pkg);
|
|
130
179
|
if (buildSteps.length > 0) {
|
|
131
180
|
jobs.build = {
|
|
132
181
|
name: '🏗️ Build',
|
|
@@ -137,18 +186,46 @@ class WorkflowGenerator {
|
|
|
137
186
|
...this._setupSteps(lang),
|
|
138
187
|
this._stepInstallDeps(lang),
|
|
139
188
|
...buildSteps,
|
|
140
|
-
this._stepUploadArtifact(),
|
|
189
|
+
this._stepUploadArtifact(lang),
|
|
141
190
|
].filter(Boolean),
|
|
191
|
+
env: {
|
|
192
|
+
NODE_ENV: 'production',
|
|
193
|
+
...this._getPublicEnv(),
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── lighthouse job ──────────────────────────────────────────────────
|
|
199
|
+
if (jobs.build && this.frameworks.some(f => ['Next.js', 'React', 'Vue', 'Svelte', 'Nuxt'].includes(f.name))) {
|
|
200
|
+
jobs.lighthouse = {
|
|
201
|
+
name: '⚡ Lighthouse Audit',
|
|
202
|
+
'runs-on': 'ubuntu-latest',
|
|
203
|
+
needs: ['build'],
|
|
204
|
+
if: "github.event_name == 'pull_request'",
|
|
205
|
+
steps: [
|
|
206
|
+
this._stepCheckout(),
|
|
207
|
+
{
|
|
208
|
+
name: 'Run Lighthouse on build output',
|
|
209
|
+
uses: 'treosh/lighthouse-ci-action@v11',
|
|
210
|
+
with: {
|
|
211
|
+
uploadArtifacts: true,
|
|
212
|
+
temporaryPublicStorage: true,
|
|
213
|
+
configPath: './.lighthouserc.json',
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
'continue-on-error': true,
|
|
142
218
|
};
|
|
143
219
|
}
|
|
144
220
|
|
|
145
221
|
// ── e2e job ───────────────────────────────────────────────────────────
|
|
146
222
|
if (this.e2eTests.length > 0) {
|
|
147
223
|
const e2eTest = this.e2eTests[0];
|
|
224
|
+
const e2eNeeds = jobs.build ? ['build'] : ['lint', ...(jobs.test ? ['test'] : [])];
|
|
148
225
|
jobs.e2e = {
|
|
149
226
|
name: '🎭 E2E Tests',
|
|
150
227
|
'runs-on': 'ubuntu-latest',
|
|
151
|
-
needs:
|
|
228
|
+
needs: e2eNeeds,
|
|
152
229
|
steps: [
|
|
153
230
|
this._stepCheckout(),
|
|
154
231
|
...this._setupSteps(lang),
|
|
@@ -191,9 +268,13 @@ class WorkflowGenerator {
|
|
|
191
268
|
};
|
|
192
269
|
|
|
193
270
|
const envComment = this._envComment();
|
|
271
|
+
const pipelineStages = ['lint'];
|
|
272
|
+
if (jobs.test) pipelineStages.push('test');
|
|
273
|
+
if (jobs.build) pipelineStages.push('build');
|
|
274
|
+
if (jobs.e2e) pipelineStages.push('e2e');
|
|
194
275
|
const header =
|
|
195
276
|
`# Generated by cistack v${version} — https://github.com/cistack\n` +
|
|
196
|
-
`# CI Pipeline:
|
|
277
|
+
`# CI Pipeline: ${pipelineStages.join(' → ')}\n` +
|
|
197
278
|
envComment +
|
|
198
279
|
`\n`;
|
|
199
280
|
|
|
@@ -203,8 +284,18 @@ class WorkflowGenerator {
|
|
|
203
284
|
// ── Monorepo root CI (matrix over all workspaces) ────────────────────────
|
|
204
285
|
_buildMonorepoRootCI() {
|
|
205
286
|
const lang = this.primaryLang;
|
|
206
|
-
const branches = this.
|
|
207
|
-
const
|
|
287
|
+
const branches = this._resolveBranches(['main', 'master', 'develop']);
|
|
288
|
+
const matrixEntries = this.monorepoPackages.map((pkg) => {
|
|
289
|
+
const pkgScripts = (pkg.packageJson && pkg.packageJson.scripts) || {};
|
|
290
|
+
return {
|
|
291
|
+
name: pkg.name,
|
|
292
|
+
package: pkg.relativePath,
|
|
293
|
+
lintScript: this._findScript(['lint', 'lint:ci', 'eslint'], pkg) || '',
|
|
294
|
+
testScript: (pkgScripts['test:ci'] && 'test:ci') || (pkgScripts.test && 'test') || '',
|
|
295
|
+
buildScript: this._findScript(['build', 'build:prod', 'compile'], pkg) || '',
|
|
296
|
+
};
|
|
297
|
+
});
|
|
298
|
+
const buildablePackages = matrixEntries.filter((pkg) => pkg.buildScript);
|
|
208
299
|
|
|
209
300
|
const workflow = {
|
|
210
301
|
name: 'CI — Monorepo',
|
|
@@ -222,50 +313,71 @@ class WorkflowGenerator {
|
|
|
222
313
|
'runs-on': 'ubuntu-latest',
|
|
223
314
|
strategy: {
|
|
224
315
|
matrix: {
|
|
225
|
-
|
|
316
|
+
include: matrixEntries,
|
|
226
317
|
},
|
|
227
318
|
'fail-fast': false,
|
|
228
319
|
},
|
|
229
320
|
steps: [
|
|
230
321
|
this._stepCheckout(),
|
|
231
322
|
...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
|
-
},
|
|
323
|
+
this._stepInstallDeps(lang),
|
|
240
324
|
{
|
|
241
325
|
name: 'Lint',
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
: lang.packageManager === 'yarn'
|
|
245
|
-
? 'yarn workspace ${{ matrix.package }} run lint || true'
|
|
246
|
-
: 'npm run --workspace=${{ matrix.package }} lint || true',
|
|
326
|
+
if: '${{ matrix.lintScript != \'\' }}',
|
|
327
|
+
run: this._workspaceRunCommand(lang, '${{ matrix.lintScript }}'),
|
|
247
328
|
},
|
|
248
329
|
{
|
|
249
330
|
name: 'Test',
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
: lang.packageManager === 'yarn'
|
|
253
|
-
? 'yarn workspace ${{ matrix.package }} run test || true'
|
|
254
|
-
: 'npm run --workspace=${{ matrix.package }} test || true',
|
|
331
|
+
if: '${{ matrix.testScript != \'\' }}',
|
|
332
|
+
run: this._workspaceRunCommand(lang, '${{ matrix.testScript }}'),
|
|
255
333
|
},
|
|
256
334
|
{
|
|
257
335
|
name: 'Build',
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
: lang.packageManager === 'yarn'
|
|
261
|
-
? 'yarn workspace ${{ matrix.package }} run build || true'
|
|
262
|
-
: 'npm run --workspace=${{ matrix.package }} build || true',
|
|
336
|
+
if: '${{ matrix.buildScript != \'\' }}',
|
|
337
|
+
run: this._workspaceRunCommand(lang, '${{ matrix.buildScript }}'),
|
|
263
338
|
},
|
|
264
339
|
].filter(Boolean),
|
|
340
|
+
env: {
|
|
341
|
+
NODE_ENV: 'test',
|
|
342
|
+
CI: 'true',
|
|
343
|
+
...this._getPublicEnv(),
|
|
344
|
+
},
|
|
265
345
|
},
|
|
266
346
|
},
|
|
267
347
|
};
|
|
268
348
|
|
|
349
|
+
if (buildablePackages.length > 0 && this.frameworks.some(f => ['Next.js', 'React', 'Vue', 'Svelte', 'Nuxt'].includes(f.name))) {
|
|
350
|
+
workflow.jobs.lighthouse = {
|
|
351
|
+
name: '⚡ Lighthouse (Root)',
|
|
352
|
+
'runs-on': 'ubuntu-latest',
|
|
353
|
+
strategy: {
|
|
354
|
+
matrix: {
|
|
355
|
+
include: buildablePackages,
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
steps: [
|
|
359
|
+
this._stepCheckout(),
|
|
360
|
+
...this._setupSteps(lang),
|
|
361
|
+
{
|
|
362
|
+
...this._stepInstallDeps(lang),
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
name: 'Build workspace',
|
|
366
|
+
run: this._workspaceRunCommand(lang, '${{ matrix.buildScript }}'),
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
name: 'Lighthouse',
|
|
370
|
+
uses: 'treosh/lighthouse-ci-action@v11',
|
|
371
|
+
with: {
|
|
372
|
+
uploadArtifacts: true,
|
|
373
|
+
temporaryPublicStorage: true,
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
'continue-on-error': true,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
269
381
|
return this._toYaml(
|
|
270
382
|
workflow,
|
|
271
383
|
`# Generated by cistack v${version} — https://github.com/cistack\n# Monorepo CI — matrix over all workspaces\n\n`
|
|
@@ -279,8 +391,11 @@ class WorkflowGenerator {
|
|
|
279
391
|
_buildDeployWorkflow() {
|
|
280
392
|
const h = this.primaryHosting;
|
|
281
393
|
const lang = this.primaryLang;
|
|
282
|
-
const branches = this.
|
|
394
|
+
const branches = this._resolveBranches(['main', 'master']);
|
|
395
|
+
const productionBranches = branches.filter((b) => b !== 'develop');
|
|
396
|
+
const deployBranches = productionBranches.length > 0 ? productionBranches : branches;
|
|
283
397
|
const isGHPages = h.name === 'GitHub Pages';
|
|
398
|
+
const supportsPreview = ['Firebase', 'Vercel', 'Netlify'].includes(h.name);
|
|
284
399
|
|
|
285
400
|
const preDeploySteps = [
|
|
286
401
|
this._stepCheckout(),
|
|
@@ -288,9 +403,10 @@ class WorkflowGenerator {
|
|
|
288
403
|
this._stepInstallDeps(lang),
|
|
289
404
|
].filter(Boolean);
|
|
290
405
|
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
406
|
+
const primaryDeployBranch = deployBranches[0] || this.defaultBranch || 'main';
|
|
407
|
+
const deploySteps = this._hostingDeploySteps(h, lang, false, primaryDeployBranch); // production
|
|
408
|
+
// Only generate PR preview steps for platforms that natively isolate them
|
|
409
|
+
const previewSteps = supportsPreview ? this._hostingDeploySteps(h, lang, true, primaryDeployBranch) : [];
|
|
294
410
|
|
|
295
411
|
// GitHub Pages requires special permissions on the deploy job
|
|
296
412
|
const ghPagesPermissions = isGHPages
|
|
@@ -315,7 +431,27 @@ class WorkflowGenerator {
|
|
|
315
431
|
if: "github.event_name == 'pull_request'",
|
|
316
432
|
'runs-on': 'ubuntu-latest',
|
|
317
433
|
environment: 'preview',
|
|
318
|
-
steps: [
|
|
434
|
+
steps: [
|
|
435
|
+
...preDeploySteps,
|
|
436
|
+
...previewSteps,
|
|
437
|
+
{
|
|
438
|
+
name: 'Comment PR',
|
|
439
|
+
if: 'always()',
|
|
440
|
+
uses: 'actions/github-script@v7',
|
|
441
|
+
with: {
|
|
442
|
+
script: `
|
|
443
|
+
const deploymentUrl = process.env.DEPLOYMENT_URL;
|
|
444
|
+
if (!deploymentUrl) return;
|
|
445
|
+
github.rest.issues.createComment({
|
|
446
|
+
issue_number: context.issue.number,
|
|
447
|
+
owner: context.repo.owner,
|
|
448
|
+
repo: context.repo.repo,
|
|
449
|
+
body: '🚀 **Deployment Preview Ready!**\\n\\n[View Preview](' + deploymentUrl + ')'
|
|
450
|
+
});
|
|
451
|
+
`
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
].filter(Boolean),
|
|
319
455
|
};
|
|
320
456
|
}
|
|
321
457
|
|
|
@@ -331,14 +467,18 @@ class WorkflowGenerator {
|
|
|
331
467
|
|
|
332
468
|
const envComment = this._envComment();
|
|
333
469
|
|
|
334
|
-
//
|
|
335
|
-
const onTrigger =
|
|
336
|
-
? { push: { branches:
|
|
337
|
-
: { push: { branches:
|
|
470
|
+
// Only trigger on PR if the platform supports preview deployments
|
|
471
|
+
const onTrigger = supportsPreview
|
|
472
|
+
? { push: { branches: deployBranches }, pull_request: { branches }, workflow_dispatch: {} }
|
|
473
|
+
: { push: { branches: deployBranches }, workflow_dispatch: {} };
|
|
338
474
|
|
|
339
475
|
const workflow = {
|
|
340
476
|
name: `Deploy to ${h.name}`,
|
|
341
477
|
on: onTrigger,
|
|
478
|
+
concurrency: {
|
|
479
|
+
group: '${{ github.workflow }}-${{ github.ref }}',
|
|
480
|
+
'cancel-in-progress': true,
|
|
481
|
+
},
|
|
342
482
|
jobs,
|
|
343
483
|
};
|
|
344
484
|
|
|
@@ -353,14 +493,15 @@ class WorkflowGenerator {
|
|
|
353
493
|
// ══════════════════════════════════════════════════════════════════════════
|
|
354
494
|
|
|
355
495
|
_buildDockerWorkflow() {
|
|
496
|
+
const branches = this._resolveBranches(['main', 'master']);
|
|
356
497
|
const workflow = {
|
|
357
498
|
name: 'Docker Build & Push',
|
|
358
499
|
on: {
|
|
359
500
|
push: {
|
|
360
|
-
branches
|
|
501
|
+
branches,
|
|
361
502
|
tags: ['v*.*.*'],
|
|
362
503
|
},
|
|
363
|
-
pull_request: { branches
|
|
504
|
+
pull_request: { branches },
|
|
364
505
|
},
|
|
365
506
|
env: {
|
|
366
507
|
REGISTRY: 'ghcr.io',
|
|
@@ -431,6 +572,7 @@ class WorkflowGenerator {
|
|
|
431
572
|
|
|
432
573
|
_buildSecurityWorkflow() {
|
|
433
574
|
const lang = this.primaryLang;
|
|
575
|
+
const branches = this._resolveBranches(['main', 'master']);
|
|
434
576
|
const steps = [this._stepCheckout()];
|
|
435
577
|
|
|
436
578
|
if (['JavaScript', 'TypeScript'].includes(lang.name)) {
|
|
@@ -450,7 +592,7 @@ class WorkflowGenerator {
|
|
|
450
592
|
|
|
451
593
|
if (lang.name === 'Python') {
|
|
452
594
|
steps.push(
|
|
453
|
-
{ name: 'Set up Python', uses: 'actions/setup-python@v5', with: { 'python-version': '3.
|
|
595
|
+
{ name: 'Set up Python', uses: 'actions/setup-python@v5', with: { 'python-version': lang.pythonVersion || '3.11' } },
|
|
454
596
|
{ name: 'Install safety', run: 'pip install safety' },
|
|
455
597
|
{ name: 'Run safety check', run: 'safety check' },
|
|
456
598
|
);
|
|
@@ -476,19 +618,19 @@ class WorkflowGenerator {
|
|
|
476
618
|
const workflow = {
|
|
477
619
|
name: 'Security Audit',
|
|
478
620
|
on: {
|
|
479
|
-
push: { branches
|
|
480
|
-
pull_request: { branches
|
|
621
|
+
push: { branches },
|
|
622
|
+
pull_request: { branches },
|
|
481
623
|
schedule: [{ cron: '0 6 * * 1' }],
|
|
482
624
|
},
|
|
625
|
+
permissions: {
|
|
626
|
+
actions: 'read',
|
|
627
|
+
contents: 'read',
|
|
628
|
+
'security-events': 'write',
|
|
629
|
+
},
|
|
483
630
|
jobs: {
|
|
484
631
|
security: {
|
|
485
632
|
name: '🔒 Security Audit',
|
|
486
633
|
'runs-on': 'ubuntu-latest',
|
|
487
|
-
permissions: {
|
|
488
|
-
actions: 'read',
|
|
489
|
-
contents: 'read',
|
|
490
|
-
'security-events': 'write',
|
|
491
|
-
},
|
|
492
634
|
steps: steps.filter(Boolean),
|
|
493
635
|
},
|
|
494
636
|
},
|
|
@@ -509,12 +651,19 @@ class WorkflowGenerator {
|
|
|
509
651
|
* Returns setup + cache steps for the given language.
|
|
510
652
|
* v2.0.0: added explicit caching for pip, poetry, cargo, maven, gradle, bundler, go, composer.
|
|
511
653
|
*/
|
|
512
|
-
_setupSteps(lang) {
|
|
654
|
+
_setupSteps(lang, useMatrix = false) {
|
|
513
655
|
const steps = [];
|
|
514
656
|
const cacheOverride = this.extraConfig.cache || {};
|
|
515
657
|
|
|
516
658
|
// ── JavaScript / TypeScript ──────────────────────────────────────────
|
|
517
659
|
if (['JavaScript', 'TypeScript'].includes(lang.name)) {
|
|
660
|
+
if (lang.packageManager === 'bun') {
|
|
661
|
+
steps.push({
|
|
662
|
+
name: 'Set up Bun',
|
|
663
|
+
uses: 'oven-sh/setup-bun@v2',
|
|
664
|
+
with: { 'bun-version': 'latest' },
|
|
665
|
+
});
|
|
666
|
+
}
|
|
518
667
|
if (lang.packageManager === 'pnpm') {
|
|
519
668
|
steps.push({ name: 'Install pnpm', uses: 'pnpm/action-setup@v3', with: { version: 'latest' } });
|
|
520
669
|
}
|
|
@@ -522,9 +671,17 @@ class WorkflowGenerator {
|
|
|
522
671
|
name: 'Set up Node.js',
|
|
523
672
|
uses: 'actions/setup-node@v4',
|
|
524
673
|
with: {
|
|
525
|
-
'node-version': lang.nodeVersion || '20',
|
|
674
|
+
'node-version': useMatrix ? '${{ matrix.node-version }}' : (lang.nodeVersion || '20'),
|
|
526
675
|
// Use native caching in setup-node
|
|
527
|
-
cache: cacheOverride.npm !== false
|
|
676
|
+
cache: cacheOverride.npm !== false
|
|
677
|
+
? (lang.packageManager === 'yarn'
|
|
678
|
+
? 'yarn'
|
|
679
|
+
: lang.packageManager === 'pnpm'
|
|
680
|
+
? 'pnpm'
|
|
681
|
+
: lang.packageManager === 'npm'
|
|
682
|
+
? 'npm'
|
|
683
|
+
: undefined)
|
|
684
|
+
: undefined,
|
|
528
685
|
},
|
|
529
686
|
});
|
|
530
687
|
}
|
|
@@ -535,7 +692,7 @@ class WorkflowGenerator {
|
|
|
535
692
|
name: 'Set up Python',
|
|
536
693
|
uses: 'actions/setup-python@v5',
|
|
537
694
|
with: {
|
|
538
|
-
'python-version': '3.
|
|
695
|
+
'python-version': useMatrix ? '${{ matrix.python-version }}' : (lang.pythonVersion || '3.11'),
|
|
539
696
|
// Native caching for pip/poetry
|
|
540
697
|
cache: cacheOverride.pip !== false ? (lang.packageManager === 'poetry' ? 'poetry' : 'pip') : undefined
|
|
541
698
|
},
|
|
@@ -623,9 +780,9 @@ class WorkflowGenerator {
|
|
|
623
780
|
|
|
624
781
|
_stepInstallDeps(lang) {
|
|
625
782
|
const pm = lang.packageManager;
|
|
626
|
-
if (pm === 'npm') return { name: 'Install dependencies', run: 'npm ci' };
|
|
627
|
-
if (pm === 'yarn') return { name: 'Install dependencies', run: 'yarn install --frozen-lockfile' };
|
|
628
|
-
if (pm === 'pnpm') return { name: 'Install dependencies', run: 'pnpm install --frozen-lockfile' };
|
|
783
|
+
if (pm === 'npm') return { name: 'Install dependencies', run: this.lockFiles.has('package-lock.json') ? 'npm ci' : 'npm install' };
|
|
784
|
+
if (pm === 'yarn') return { name: 'Install dependencies', run: this.lockFiles.has('yarn.lock') ? 'yarn install --frozen-lockfile' : 'yarn install' };
|
|
785
|
+
if (pm === 'pnpm') return { name: 'Install dependencies', run: this.lockFiles.has('pnpm-lock.yaml') ? 'pnpm install --frozen-lockfile' : 'pnpm install' };
|
|
629
786
|
if (pm === 'bun') return { name: 'Install dependencies', run: 'bun install' };
|
|
630
787
|
if (pm === 'pip') return { name: 'Install dependencies', run: 'pip install -r requirements.txt' };
|
|
631
788
|
if (pm === 'poetry') return { name: 'Install dependencies', run: 'pip install poetry && poetry install' };
|
|
@@ -639,46 +796,50 @@ class WorkflowGenerator {
|
|
|
639
796
|
return { name: 'Install dependencies', run: 'npm ci' };
|
|
640
797
|
}
|
|
641
798
|
|
|
642
|
-
_stepLint(lang) {
|
|
643
|
-
const pm = lang.packageManager || 'npm';
|
|
644
|
-
|
|
799
|
+
_stepLint(lang, pkg = null) {
|
|
645
800
|
if (['JavaScript', 'TypeScript'].includes(lang.name)) {
|
|
646
|
-
const lintScript = this._findScript(['lint', 'lint:ci', 'eslint']);
|
|
647
|
-
const typeCheck = this._findScript(['type-check', 'typecheck', 'tsc']);
|
|
648
|
-
const format = this._findScript(['format:check', 'prettier:check', 'fmt:check']);
|
|
649
|
-
const runPfx = pm === 'yarn' ? 'yarn' : pm === 'pnpm' ? 'pnpm' : 'npm run';
|
|
801
|
+
const lintScript = this._findScript(['lint', 'lint:ci', 'eslint'], pkg);
|
|
802
|
+
const typeCheck = this._findScript(['type-check', 'typecheck', 'tsc'], pkg);
|
|
803
|
+
const format = this._findScript(['format:check', 'prettier:check', 'fmt:check'], pkg);
|
|
650
804
|
|
|
651
805
|
const cmds = [];
|
|
652
|
-
if (lintScript) cmds.push(
|
|
653
|
-
if (typeCheck) cmds.push(
|
|
654
|
-
if (format) cmds.push(
|
|
655
|
-
if (cmds.length === 0)
|
|
806
|
+
if (lintScript) cmds.push(this._scriptCommand(lang, lintScript, pkg));
|
|
807
|
+
if (typeCheck) cmds.push(this._scriptCommand(lang, typeCheck, pkg));
|
|
808
|
+
if (format) cmds.push(this._scriptCommand(lang, format, pkg));
|
|
809
|
+
if (cmds.length === 0) {
|
|
810
|
+
const lintTarget = pkg ? pkg.relativePath : '.';
|
|
811
|
+
cmds.push(`npx eslint ${lintTarget} --ext .js,.jsx,.ts,.tsx --max-warnings 0`);
|
|
812
|
+
}
|
|
656
813
|
|
|
657
814
|
return { name: 'Lint', run: cmds.join('\n') };
|
|
658
815
|
}
|
|
659
816
|
|
|
660
|
-
if (lang.name === 'Python') return { name: 'Lint', run: 'pip install flake8 && flake8 .' };
|
|
817
|
+
if (lang.name === 'Python') return { name: 'Lint', run: 'pip install flake8 black && flake8 . && black --check .' };
|
|
661
818
|
if (lang.name === 'Go') return { name: 'Lint', run: 'gofmt -d . && go vet ./...' };
|
|
662
819
|
if (lang.name === 'Rust') return { name: 'Lint', run: 'cargo clippy -- -D warnings && cargo fmt --check' };
|
|
663
820
|
if (lang.name === 'Ruby') return { name: 'Lint', run: 'gem install rubocop && rubocop' };
|
|
664
|
-
if (lang.name === 'PHP') return { name: 'Lint', run: 'vendor/bin/phpcs' };
|
|
821
|
+
if (lang.name === 'PHP') return { name: 'Lint', run: 'vendor/bin/phpcs && vendor/bin/phpstan analyze' };
|
|
665
822
|
|
|
666
823
|
return null;
|
|
667
824
|
}
|
|
668
825
|
|
|
669
|
-
_unitTestSteps(lang) {
|
|
826
|
+
_unitTestSteps(lang, pkg = null) {
|
|
827
|
+
if (pkg && ['JavaScript', 'TypeScript'].includes(lang.name)) {
|
|
828
|
+
const pkgTestScript = this._findScript(['test:ci', 'test'], pkg);
|
|
829
|
+
if (pkgTestScript) {
|
|
830
|
+
return [{ name: 'Run tests', run: this._scriptCommand(lang, pkgTestScript, pkg) }];
|
|
831
|
+
}
|
|
832
|
+
}
|
|
670
833
|
return this.unitTests.map((t) => ({ name: `Run ${t.name}`, run: t.command }));
|
|
671
834
|
}
|
|
672
835
|
|
|
673
|
-
_buildSteps(lang) {
|
|
674
|
-
const
|
|
675
|
-
const buildScript = this._findScript(['build', 'build:prod']);
|
|
836
|
+
_buildSteps(lang, pkg = null) {
|
|
837
|
+
const buildScript = this._findScript(['build', 'build:prod', 'compile'], pkg);
|
|
676
838
|
if (!buildScript && !['Go', 'Rust', 'Java', 'Kotlin'].includes(lang.name)) return [];
|
|
677
839
|
|
|
678
840
|
const steps = [];
|
|
679
841
|
if (['JavaScript', 'TypeScript'].includes(lang.name) && buildScript) {
|
|
680
|
-
|
|
681
|
-
steps.push({ name: 'Build', run: `${runPfx} run ${buildScript}`, env: { NODE_ENV: 'production' } });
|
|
842
|
+
steps.push({ name: 'Build', run: this._scriptCommand(lang, buildScript, pkg), env: { NODE_ENV: 'production' } });
|
|
682
843
|
}
|
|
683
844
|
if (lang.name === 'Go') steps.push({ name: 'Build', run: 'go build -v ./...' });
|
|
684
845
|
if (lang.name === 'Rust') steps.push({ name: 'Build', run: 'cargo build --release' });
|
|
@@ -688,8 +849,21 @@ class WorkflowGenerator {
|
|
|
688
849
|
return steps;
|
|
689
850
|
}
|
|
690
851
|
|
|
691
|
-
_stepUploadArtifact() {
|
|
692
|
-
|
|
852
|
+
_stepUploadArtifact(lang) {
|
|
853
|
+
let buildDir = null;
|
|
854
|
+
|
|
855
|
+
if (['JavaScript', 'TypeScript'].includes(lang.name)) {
|
|
856
|
+
buildDir = (this.frameworks[0] && this.frameworks[0].buildDir) || null;
|
|
857
|
+
} else if (lang.name === 'Rust') {
|
|
858
|
+
buildDir = 'target/release';
|
|
859
|
+
} else if (lang.name === 'Java') {
|
|
860
|
+
buildDir = 'target';
|
|
861
|
+
} else if (lang.name === 'Kotlin') {
|
|
862
|
+
buildDir = 'build/libs';
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
if (!buildDir) return null;
|
|
866
|
+
|
|
693
867
|
return {
|
|
694
868
|
name: 'Upload build artifact',
|
|
695
869
|
uses: 'actions/upload-artifact@v4',
|
|
@@ -719,15 +893,48 @@ class WorkflowGenerator {
|
|
|
719
893
|
return null;
|
|
720
894
|
}
|
|
721
895
|
|
|
896
|
+
_resolveBranches(fallback) {
|
|
897
|
+
if (Array.isArray(this.extraConfig.branches) && this.extraConfig.branches.length > 0) {
|
|
898
|
+
return [...new Set(this.extraConfig.branches)];
|
|
899
|
+
}
|
|
900
|
+
if (this.defaultBranch) {
|
|
901
|
+
return [this.defaultBranch];
|
|
902
|
+
}
|
|
903
|
+
return fallback;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
_workspaceRunCommand(lang, scriptName) {
|
|
907
|
+
const pm = lang.packageManager || 'npm';
|
|
908
|
+
if (pm === 'pnpm') return `pnpm --filter \${{ matrix.package }} run ${scriptName}`;
|
|
909
|
+
if (pm === 'yarn') return `yarn workspace \${{ matrix.name }} run ${scriptName}`;
|
|
910
|
+
if (pm === 'bun') return `bun run --filter './\${{ matrix.package }}' ${scriptName}`;
|
|
911
|
+
return `npm run ${scriptName} --workspace=\${{ matrix.name }}`;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
_scriptCommand(lang, scriptName, pkg = null) {
|
|
915
|
+
const pm = lang.packageManager || 'npm';
|
|
916
|
+
|
|
917
|
+
if (pkg) {
|
|
918
|
+
if (pm === 'pnpm') return `pnpm --filter ${pkg.relativePath} run ${scriptName}`;
|
|
919
|
+
if (pm === 'yarn') return `yarn workspace ${pkg.name} run ${scriptName}`;
|
|
920
|
+
if (pm === 'bun') return `bun run --filter './${pkg.relativePath}' ${scriptName}`;
|
|
921
|
+
return `npm run ${scriptName} --workspace=${pkg.name}`;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
if (pm === 'yarn') return `yarn run ${scriptName}`;
|
|
925
|
+
if (pm === 'pnpm') return `pnpm run ${scriptName}`;
|
|
926
|
+
if (pm === 'bun') return `bun run ${scriptName}`;
|
|
927
|
+
return `npm run ${scriptName}`;
|
|
928
|
+
}
|
|
929
|
+
|
|
722
930
|
// ══════════════════════════════════════════════════════════════════════════
|
|
723
931
|
// Hosting-specific deploy steps
|
|
724
932
|
// ══════════════════════════════════════════════════════════════════════════
|
|
725
933
|
|
|
726
|
-
_hostingDeploySteps(h, lang, isPreview = false) {
|
|
934
|
+
_hostingDeploySteps(h, lang, isPreview = false, productionBranch = null) {
|
|
727
935
|
const steps = [];
|
|
728
936
|
const buildScript = this._findScript(['build', 'build:prod']);
|
|
729
|
-
const
|
|
730
|
-
const runCmd = (s) => `${pm === 'yarn' ? 'yarn' : pm === 'pnpm' ? 'pnpm' : 'npm'} run ${s}`;
|
|
937
|
+
const runCmd = (s) => this._scriptCommand(lang, s);
|
|
731
938
|
|
|
732
939
|
switch (h.name) {
|
|
733
940
|
case 'Firebase': {
|
|
@@ -767,7 +974,8 @@ class WorkflowGenerator {
|
|
|
767
974
|
},
|
|
768
975
|
{
|
|
769
976
|
name: 'Deploy to Vercel',
|
|
770
|
-
|
|
977
|
+
id: 'deploy',
|
|
978
|
+
run: `vercel deploy --prebuilt${prodFlag ? ' ' + prodFlag : ''} --token=\${{ secrets.VERCEL_TOKEN }} > deployment_url.txt && echo "DEPLOYMENT_URL=$(cat deployment_url.txt)" >> $GITHUB_ENV`,
|
|
771
979
|
env: vercelEnv,
|
|
772
980
|
},
|
|
773
981
|
);
|
|
@@ -780,10 +988,11 @@ class WorkflowGenerator {
|
|
|
780
988
|
}
|
|
781
989
|
steps.push({
|
|
782
990
|
name: isPreview ? 'Deploy Preview' : 'Deploy to Netlify',
|
|
991
|
+
id: 'netlify_deploy',
|
|
783
992
|
uses: 'nwtgck/actions-netlify@v3.0',
|
|
784
993
|
with: {
|
|
785
|
-
'publish-dir': h.publishDir || 'dist',
|
|
786
|
-
'production-branch': 'main',
|
|
994
|
+
'publish-dir': h.publishDir || (this.frameworks[0] && this.frameworks[0].buildDir) || 'dist',
|
|
995
|
+
'production-branch': productionBranch || this.defaultBranch || 'main',
|
|
787
996
|
'github-token': '${{ secrets.GITHUB_TOKEN }}',
|
|
788
997
|
'deploy-message': isPreview ? 'Preview Deploy – ${{ github.event.number }}' : 'Production Deploy – ${{ github.sha }}',
|
|
789
998
|
'enable-pull-request-comment': true,
|
|
@@ -796,6 +1005,12 @@ class WorkflowGenerator {
|
|
|
796
1005
|
NETLIFY_SITE_ID: '${{ secrets.NETLIFY_SITE_ID }}',
|
|
797
1006
|
},
|
|
798
1007
|
});
|
|
1008
|
+
if (isPreview) {
|
|
1009
|
+
steps.push({
|
|
1010
|
+
name: 'Set Netlify URL',
|
|
1011
|
+
run: 'echo "DEPLOYMENT_URL=${{ steps.netlify_deploy.outputs.deploy-url }}" >> $GITHUB_ENV'
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
799
1014
|
break;
|
|
800
1015
|
}
|
|
801
1016
|
|
|
@@ -930,11 +1145,12 @@ class WorkflowGenerator {
|
|
|
930
1145
|
return lang;
|
|
931
1146
|
}
|
|
932
1147
|
|
|
933
|
-
_findScript(names) {
|
|
1148
|
+
_findScript(names, pkg = null) {
|
|
934
1149
|
const fs = require('fs');
|
|
935
1150
|
const path = require('path');
|
|
936
1151
|
try {
|
|
937
|
-
const
|
|
1152
|
+
const pkgPath = pkg ? path.join(this.projectPath, pkg.relativePath, 'package.json') : path.join(this.projectPath, 'package.json');
|
|
1153
|
+
const raw = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
938
1154
|
const scripts = raw.scripts || {};
|
|
939
1155
|
for (const n of names) {
|
|
940
1156
|
if (scripts[n]) return n;
|
|
@@ -973,6 +1189,21 @@ class WorkflowGenerator {
|
|
|
973
1189
|
});
|
|
974
1190
|
return header + raw;
|
|
975
1191
|
}
|
|
1192
|
+
|
|
1193
|
+
_getPublicEnv() {
|
|
1194
|
+
const env = {};
|
|
1195
|
+
if (this.envVars && this.envVars.public) {
|
|
1196
|
+
for (const p of this.envVars.public) {
|
|
1197
|
+
const parts = p.split('=');
|
|
1198
|
+
if (parts.length >= 2) {
|
|
1199
|
+
env[parts[0]] = parts.slice(1).join('=');
|
|
1200
|
+
} else {
|
|
1201
|
+
env[p] = `\${{ vars.${p} || secrets.${p} }}`;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
return env;
|
|
1206
|
+
}
|
|
976
1207
|
}
|
|
977
1208
|
|
|
978
1209
|
module.exports = WorkflowGenerator;
|