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.
- package/README.md +48 -34
- package/bin/ciflow.js +91 -7
- package/package.json +10 -3
- package/src/analyzers/monorepo.js +124 -0
- package/src/analyzers/workflow.js +195 -0
- package/src/config/loader.js +163 -0
- package/src/detectors/env.js +69 -0
- package/src/detectors/framework.js +37 -12
- package/src/detectors/hosting.js +54 -46
- package/src/detectors/release.js +124 -0
- package/src/generators/dependabot.js +155 -0
- package/src/generators/release.js +195 -0
- package/src/generators/workflow.js +402 -125
- package/src/index.js +247 -54
- package/src/utils/helpers.js +146 -9
package/README.md
CHANGED
|
@@ -10,12 +10,15 @@
|
|
|
10
10
|
|
|
11
11
|
- 🔍 **Deep codebase analysis** — reads `package.json`, lock files, config files, and directory structure
|
|
12
12
|
- 🧠 **Smart detection** — identifies 30+ frameworks, 12 languages, 12+ testing tools, and 10+ hosting platforms
|
|
13
|
+
- ⚡ **Native Cache support** — speeds up pipelines by 2–4min using native caching for npm, pip, go, cargo, maven, gradle, and bundler
|
|
14
|
+
- ✨ **PR Preview Deploys** — automatic preview environments for Vercel and Netlify on every pull request
|
|
13
15
|
- 🚀 **Hosting auto-detect** — Firebase, Vercel, Netlify, AWS, GCP, Azure, Heroku, Render, Railway, GitHub Pages, Docker
|
|
14
|
-
-
|
|
16
|
+
- 🛡️ **Workflow Audit & Upgrade** — analyse existing `.github/workflows` for outdated actions and missing best practices
|
|
17
|
+
- 🏗️ **Multi-workflow output** — generates separate `ci.yml`, `deploy.yml`, `docker.yml`, and `security.yml`
|
|
15
18
|
- 🔒 **Security built-in** — CodeQL analysis + dependency auditing on every pipeline
|
|
16
|
-
- 📦 **Monorepo aware** — detects Turborepo, Nx, Lerna, pnpm workspaces
|
|
19
|
+
- 📦 **Monorepo aware** — detects Turborepo, Nx, Lerna, pnpm workspaces (supports per-package workflows)
|
|
17
20
|
- ✅ **Interactive mode** — confirms detected settings before writing files
|
|
18
|
-
- 🎯 **Zero config** — works out of the box with
|
|
21
|
+
- 🎯 **Zero config** — works out of the box with `cistack.config.js` for overrides
|
|
19
22
|
|
|
20
23
|
---
|
|
21
24
|
|
|
@@ -33,10 +36,15 @@ npm install -g cistack
|
|
|
33
36
|
|
|
34
37
|
## Usage
|
|
35
38
|
|
|
39
|
+
### Generate Pipelines
|
|
40
|
+
Analyze your stack and generate best-practice workflows.
|
|
36
41
|
```bash
|
|
37
42
|
# In your project directory
|
|
38
43
|
npx cistack
|
|
39
44
|
|
|
45
|
+
# Show reasoning for detected stack
|
|
46
|
+
npx cistack --explain
|
|
47
|
+
|
|
40
48
|
# Specify a project path
|
|
41
49
|
npx cistack --path /path/to/project
|
|
42
50
|
|
|
@@ -45,19 +53,40 @@ npx cistack --output .github/workflows
|
|
|
45
53
|
|
|
46
54
|
# Dry run (print YAML without writing files)
|
|
47
55
|
npx cistack --dry-run
|
|
56
|
+
```
|
|
48
57
|
|
|
49
|
-
|
|
50
|
-
|
|
58
|
+
### Audit Existing Workflows
|
|
59
|
+
Analyze your current `.github/workflows` folder for outdated actions or missing features.
|
|
60
|
+
```bash
|
|
61
|
+
npx cistack audit
|
|
62
|
+
```
|
|
51
63
|
|
|
52
|
-
|
|
53
|
-
|
|
64
|
+
### Automatic Upgrade
|
|
65
|
+
Automatically bump all action versions (e.g., `actions/checkout@v3` → `@v4`) across all your workflow files to the latest stable releases.
|
|
66
|
+
```bash
|
|
67
|
+
npx cistack upgrade
|
|
68
|
+
```
|
|
54
69
|
|
|
55
|
-
|
|
56
|
-
|
|
70
|
+
### Initialization
|
|
71
|
+
Create a `cistack.config.js` to override auto-detected settings.
|
|
72
|
+
```bash
|
|
73
|
+
npx cistack init
|
|
57
74
|
```
|
|
58
75
|
|
|
59
76
|
---
|
|
60
77
|
|
|
78
|
+
## Flags
|
|
79
|
+
|
|
80
|
+
- `--explain` — Show detailed reasoning for every detection (build trust)
|
|
81
|
+
- `--dry-run` — Print YAML to terminal without writing to disk
|
|
82
|
+
- `--force` — Overwrite existing files instead of smart-merging
|
|
83
|
+
- `--no-prompt` — Skip interactive confirmation
|
|
84
|
+
- `--verbose` — Show raw analysis data
|
|
85
|
+
- `--path <dir>` — Project root directory
|
|
86
|
+
- `--output <dir>` — Workflow output directory (default: `.github/workflows`)
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
61
90
|
## Detected Hosting Platforms
|
|
62
91
|
|
|
63
92
|
| Platform | Detection Signal |
|
|
@@ -112,21 +141,23 @@ Runs on every push and pull request:
|
|
|
112
141
|
2. **Test** — unit tests with coverage upload (matrix across Node versions)
|
|
113
142
|
3. **Build** — production build, artifact upload
|
|
114
143
|
4. **E2E** — Cypress / Playwright (if detected)
|
|
144
|
+
5. **Caching** — Full dependency caching for faster runs
|
|
115
145
|
|
|
116
146
|
### `deploy.yml` — Continuous Deployment
|
|
117
147
|
Triggers on push to `main`/`master` + manual dispatch:
|
|
118
|
-
- Platform-specific deploy using
|
|
148
|
+
- Platform-specific deploy using official GitHub Actions
|
|
149
|
+
- **PR Preview Deploys** — automatic previews for Vercel and Netlify pull requests
|
|
119
150
|
- Proper secret references documented in the file header
|
|
120
151
|
|
|
121
152
|
### `docker.yml` — Docker Build & Push
|
|
122
153
|
Triggers on push to `main` and version tags:
|
|
123
154
|
- Multi-platform build via Docker Buildx
|
|
124
155
|
- Pushes to GitHub Container Registry (GHCR)
|
|
125
|
-
- Build cache via GitHub Actions cache
|
|
156
|
+
- Build cache via GitHub Actions cache (GHA)
|
|
126
157
|
|
|
127
158
|
### `security.yml` — Security Audit
|
|
128
159
|
Runs on push, PRs, and weekly schedule:
|
|
129
|
-
- Dependency vulnerability audit (npm audit / safety /
|
|
160
|
+
- Dependency vulnerability audit (npm audit / safety / cargo audit)
|
|
130
161
|
- GitHub CodeQL analysis for the detected language
|
|
131
162
|
|
|
132
163
|
---
|
|
@@ -142,28 +173,11 @@ Each generated `deploy.yml` has a comment at the top listing the exact secrets n
|
|
|
142
173
|
|
|
143
174
|
## Examples
|
|
144
175
|
|
|
145
|
-
**Next.js + Vercel project:**
|
|
146
|
-
```
|
|
147
|
-
npx cistack
|
|
148
|
-
|
|
149
|
-
#
|
|
150
|
-
# → .github/workflows/security.yml
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
**Firebase + React project:**
|
|
154
|
-
```
|
|
155
|
-
npx cistack
|
|
156
|
-
# → .github/workflows/ci.yml
|
|
157
|
-
# → .github/workflows/deploy.yml (firebase deploy --only hosting)
|
|
158
|
-
# → .github/workflows/security.yml
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
**Node.js API + Docker:**
|
|
162
|
-
```
|
|
163
|
-
npx cistack
|
|
164
|
-
# → .github/workflows/ci.yml
|
|
165
|
-
# → .github/workflows/docker.yml (GHCR push)
|
|
166
|
-
# → .github/workflows/security.yml
|
|
176
|
+
**Next.js + Vercel project with Audit:**
|
|
177
|
+
```bash
|
|
178
|
+
npx cistack audit # Check existing workflows
|
|
179
|
+
npx cistack upgrade # Update versions to v4
|
|
180
|
+
npx cistack generate # Refresh with latest caching & previews
|
|
167
181
|
```
|
|
168
182
|
|
|
169
183
|
---
|
package/bin/ciflow.js
CHANGED
|
@@ -9,7 +9,7 @@ const CIFlow = require('../src/index');
|
|
|
9
9
|
program
|
|
10
10
|
.name('cistack')
|
|
11
11
|
.description('Generate GitHub Actions CI/CD pipelines by analysing your codebase')
|
|
12
|
-
.version('
|
|
12
|
+
.version('2.0.0');
|
|
13
13
|
|
|
14
14
|
program
|
|
15
15
|
.command('generate', { isDefault: true })
|
|
@@ -17,19 +17,103 @@ program
|
|
|
17
17
|
.option('-p, --path <dir>', 'Path to the project root', process.cwd())
|
|
18
18
|
.option('-o, --output <dir>', 'Output directory for workflow files', '.github/workflows')
|
|
19
19
|
.option('--dry-run', 'Print the generated YAML without writing files')
|
|
20
|
-
.option('--force', 'Overwrite existing workflow files without
|
|
20
|
+
.option('--force', 'Overwrite existing workflow files without smart-merge')
|
|
21
21
|
.option('--no-prompt', 'Skip interactive prompts and use detected settings')
|
|
22
22
|
.option('--verbose', 'Show detailed analysis output')
|
|
23
|
+
.option('--explain', 'Show reasoning for detected stack')
|
|
23
24
|
.action(async (options) => {
|
|
24
25
|
const ciflow = new CIFlow({
|
|
25
26
|
projectPath: path.resolve(options.path),
|
|
26
|
-
outputDir:
|
|
27
|
-
dryRun:
|
|
28
|
-
force:
|
|
29
|
-
prompt:
|
|
30
|
-
verbose:
|
|
27
|
+
outputDir: options.output,
|
|
28
|
+
dryRun: options.dryRun,
|
|
29
|
+
force: options.force,
|
|
30
|
+
prompt: options.prompt,
|
|
31
|
+
verbose: options.verbose,
|
|
32
|
+
explain: options.explain,
|
|
31
33
|
});
|
|
32
34
|
await ciflow.run();
|
|
33
35
|
});
|
|
34
36
|
|
|
37
|
+
program
|
|
38
|
+
.command('audit')
|
|
39
|
+
.description("Analyse existing .github/workflows/ folder and suggest fixes")
|
|
40
|
+
.option('-p, --path <dir>', 'Path to the project root', process.cwd())
|
|
41
|
+
.action(async (options) => {
|
|
42
|
+
const ciflow = new CIFlow({ projectPath: path.resolve(options.path) });
|
|
43
|
+
await ciflow.audit();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
program
|
|
47
|
+
.command('upgrade')
|
|
48
|
+
.description("Automatically bump action versions across all workflow files")
|
|
49
|
+
.option('-p, --path <dir>', 'Path to the project root', process.cwd())
|
|
50
|
+
.option('--dry-run', 'Show what would be upgraded without modifying files')
|
|
51
|
+
.action(async (options) => {
|
|
52
|
+
const ciflow = new CIFlow({
|
|
53
|
+
projectPath: path.resolve(options.path),
|
|
54
|
+
dryRun: options.dryRun
|
|
55
|
+
});
|
|
56
|
+
await ciflow.upgrade();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
program
|
|
60
|
+
.command('init')
|
|
61
|
+
// ... rest of init
|
|
62
|
+
.description('Create a starter cistack.config.js in the current directory')
|
|
63
|
+
.option('-p, --path <dir>', 'Path to the project root', process.cwd())
|
|
64
|
+
.action(async (options) => {
|
|
65
|
+
const fs = require('fs');
|
|
66
|
+
const resolvedPath = path.resolve(options.path);
|
|
67
|
+
const configPath = path.join(resolvedPath, 'cistack.config.js');
|
|
68
|
+
|
|
69
|
+
// Ensure the target directory exists
|
|
70
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
71
|
+
fs.mkdirSync(resolvedPath, { recursive: true });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (fs.existsSync(configPath)) {
|
|
75
|
+
console.error('cistack.config.js already exists. Delete it first or edit it manually.');
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const template = `// cistack.config.js
|
|
80
|
+
// Override auto-detected settings for this project.
|
|
81
|
+
// All fields are optional — omit what you don't need.
|
|
82
|
+
|
|
83
|
+
/** @type {import('cistack').Config} */
|
|
84
|
+
module.exports = {
|
|
85
|
+
// nodeVersion: '20', // Override detected Node.js version
|
|
86
|
+
// packageManager: 'pnpm', // 'npm' | 'yarn' | 'pnpm' | 'bun'
|
|
87
|
+
// hosting: ['Firebase'], // Force a specific hosting provider
|
|
88
|
+
// branches: ['main', 'staging'], // CI branches (default: main, master, develop)
|
|
89
|
+
// outputDir: '.github/workflows', // Where to write workflow files
|
|
90
|
+
|
|
91
|
+
// cache: {
|
|
92
|
+
// npm: true, // enabled by default
|
|
93
|
+
// pip: true,
|
|
94
|
+
// cargo: true,
|
|
95
|
+
// maven: true,
|
|
96
|
+
// gradle: true,
|
|
97
|
+
// go: true,
|
|
98
|
+
// composer: true,
|
|
99
|
+
// },
|
|
100
|
+
|
|
101
|
+
// monorepo: {
|
|
102
|
+
// perPackage: true, // Generate one ci-<name>.yml per workspace
|
|
103
|
+
// },
|
|
104
|
+
|
|
105
|
+
// release: {
|
|
106
|
+
// tool: 'semantic-release', // override release tool detection
|
|
107
|
+
// },
|
|
108
|
+
|
|
109
|
+
// secrets: ['MY_EXTRA_SECRET'], // Document additional secrets in workflow comments
|
|
110
|
+
};
|
|
111
|
+
`;
|
|
112
|
+
|
|
113
|
+
fs.writeFileSync(configPath, template, 'utf8');
|
|
114
|
+
const chalk = require('chalk');
|
|
115
|
+
console.log(chalk.green(`✔ Created cistack.config.js at ${configPath}`));
|
|
116
|
+
console.log(chalk.dim(' Edit the file to override detected settings.'));
|
|
117
|
+
});
|
|
118
|
+
|
|
35
119
|
program.parse(process.argv);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cistack",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Automatically generate GitHub Actions CI/CD pipelines by analysing your codebase",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node bin/ciflow.js",
|
|
11
|
-
"test": "node bin/ciflow.js --dry-run"
|
|
11
|
+
"test": "node bin/ciflow.js --dry-run --no-prompt"
|
|
12
12
|
},
|
|
13
13
|
"keywords": [
|
|
14
14
|
"github-actions",
|
|
@@ -18,7 +18,14 @@
|
|
|
18
18
|
"devops",
|
|
19
19
|
"firebase",
|
|
20
20
|
"vercel",
|
|
21
|
-
"netlify"
|
|
21
|
+
"netlify",
|
|
22
|
+
"monorepo",
|
|
23
|
+
"dependabot",
|
|
24
|
+
"semantic-release",
|
|
25
|
+
"changesets",
|
|
26
|
+
"caching",
|
|
27
|
+
"turborepo",
|
|
28
|
+
"nx"
|
|
22
29
|
],
|
|
23
30
|
"author": "",
|
|
24
31
|
"license": "MIT",
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { globSync } = require('glob');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolves workspace packages from monorepo config files.
|
|
9
|
+
*
|
|
10
|
+
* Supported:
|
|
11
|
+
* - package.json#workspaces (npm/yarn workspaces array or { packages: [] })
|
|
12
|
+
* - pnpm-workspace.yaml
|
|
13
|
+
* - turbo.json (uses package.json workspaces in conjunction)
|
|
14
|
+
* - nx.json (discovers apps/* and libs/*)
|
|
15
|
+
* - lerna.json (uses lerna packages array)
|
|
16
|
+
*
|
|
17
|
+
* Returns: Array<{ name: string, relativePath: string, absolutePath: string, packageJson: object|null }>
|
|
18
|
+
*/
|
|
19
|
+
class MonorepoAnalyzer {
|
|
20
|
+
constructor(projectPath, codebaseInfo) {
|
|
21
|
+
this.root = projectPath;
|
|
22
|
+
this.info = codebaseInfo;
|
|
23
|
+
this.pkg = codebaseInfo.packageJson || {};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async analyze() {
|
|
27
|
+
if (!this.info.hasMonorepo) return [];
|
|
28
|
+
|
|
29
|
+
const workspaceGlobs = this._collectWorkspaceGlobs();
|
|
30
|
+
if (workspaceGlobs.length === 0) return [];
|
|
31
|
+
|
|
32
|
+
const packages = [];
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
|
|
35
|
+
for (const pattern of workspaceGlobs) {
|
|
36
|
+
// Each glob glob like 'packages/*' → expand to actual dirs
|
|
37
|
+
const matches = globSync(pattern, {
|
|
38
|
+
cwd: this.root,
|
|
39
|
+
onlyDirectories: true,
|
|
40
|
+
absolute: false,
|
|
41
|
+
ignore: ['node_modules/**', '**/node_modules/**'],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
for (const relDir of matches) {
|
|
45
|
+
if (seen.has(relDir)) continue;
|
|
46
|
+
seen.add(relDir);
|
|
47
|
+
|
|
48
|
+
const absPath = path.join(this.root, relDir);
|
|
49
|
+
const pkgJsonPath = path.join(absPath, 'package.json');
|
|
50
|
+
let pkgJson = null;
|
|
51
|
+
|
|
52
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
53
|
+
try {
|
|
54
|
+
pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
|
|
55
|
+
} catch (_) {}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const name = (pkgJson && pkgJson.name) || path.basename(relDir);
|
|
59
|
+
|
|
60
|
+
packages.push({
|
|
61
|
+
name,
|
|
62
|
+
relativePath: relDir,
|
|
63
|
+
absolutePath: absPath,
|
|
64
|
+
packageJson: pkgJson,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return packages;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_collectWorkspaceGlobs() {
|
|
73
|
+
const globs = [];
|
|
74
|
+
|
|
75
|
+
// 1. package.json workspaces
|
|
76
|
+
if (this.pkg.workspaces) {
|
|
77
|
+
const ws = this.pkg.workspaces;
|
|
78
|
+
if (Array.isArray(ws)) {
|
|
79
|
+
globs.push(...ws);
|
|
80
|
+
} else if (ws.packages) {
|
|
81
|
+
globs.push(...ws.packages);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 2. pnpm-workspace.yaml
|
|
86
|
+
const pnpmWsPath = path.join(this.root, 'pnpm-workspace.yaml');
|
|
87
|
+
if (fs.existsSync(pnpmWsPath)) {
|
|
88
|
+
try {
|
|
89
|
+
const yaml = require('js-yaml');
|
|
90
|
+
const parsed = yaml.load(fs.readFileSync(pnpmWsPath, 'utf8'));
|
|
91
|
+
if (parsed && parsed.packages) {
|
|
92
|
+
globs.push(...parsed.packages);
|
|
93
|
+
}
|
|
94
|
+
} catch (_) {}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 3. lerna.json
|
|
98
|
+
const lernaPath = path.join(this.root, 'lerna.json');
|
|
99
|
+
if (fs.existsSync(lernaPath)) {
|
|
100
|
+
try {
|
|
101
|
+
const lerna = JSON.parse(fs.readFileSync(lernaPath, 'utf8'));
|
|
102
|
+
if (lerna.packages) globs.push(...lerna.packages);
|
|
103
|
+
} catch (_) {}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 4. nx.json — default convention: apps/* + libs/*
|
|
107
|
+
const nxPath = path.join(this.root, 'nx.json');
|
|
108
|
+
if (fs.existsSync(nxPath) && globs.length === 0) {
|
|
109
|
+
globs.push('apps/*', 'libs/*', 'packages/*');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 5. turbo.json — uses root package.json workspaces (already handled above)
|
|
113
|
+
// If none found yet, use convention
|
|
114
|
+
const turboPath = path.join(this.root, 'turbo.json');
|
|
115
|
+
if (fs.existsSync(turboPath) && globs.length === 0) {
|
|
116
|
+
globs.push('apps/*', 'packages/*');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Deduplicate
|
|
120
|
+
return [...new Set(globs)];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = MonorepoAnalyzer;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const yaml = require('js-yaml');
|
|
6
|
+
const chalk = require('chalk');
|
|
7
|
+
|
|
8
|
+
class WorkflowAnalyzer {
|
|
9
|
+
constructor(projectPath) {
|
|
10
|
+
this.projectPath = projectPath;
|
|
11
|
+
this.workflowsDir = path.join(projectPath, '.github/workflows');
|
|
12
|
+
|
|
13
|
+
// Latest stable versions for common actions
|
|
14
|
+
this.latestVersions = {
|
|
15
|
+
'actions/checkout': 'v4',
|
|
16
|
+
'actions/setup-node': 'v4',
|
|
17
|
+
'actions/setup-python': 'v5',
|
|
18
|
+
'actions/setup-java': 'v4',
|
|
19
|
+
'actions/setup-go': 'v5',
|
|
20
|
+
'actions/upload-artifact': 'v4',
|
|
21
|
+
'actions/download-artifact': 'v4',
|
|
22
|
+
'actions/cache': 'v4',
|
|
23
|
+
'docker/setup-buildx-action': 'v3',
|
|
24
|
+
'docker/login-action': 'v3',
|
|
25
|
+
'docker/build-push-action': 'v5',
|
|
26
|
+
'docker/metadata-action': 'v5',
|
|
27
|
+
'pnpm/action-setup': 'v3',
|
|
28
|
+
'codecov/codecov-action': 'v4',
|
|
29
|
+
'github/codeql-action/init': 'v3',
|
|
30
|
+
'github/codeql-action/analyze': 'v3',
|
|
31
|
+
'github/codeql-action/autobuild': 'v3',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async audit() {
|
|
36
|
+
const results = {
|
|
37
|
+
files: [],
|
|
38
|
+
totalIssues: 0,
|
|
39
|
+
suggestions: [],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (!fs.existsSync(this.workflowsDir)) {
|
|
43
|
+
return results;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const files = fs.readdirSync(this.workflowsDir).filter(f => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
47
|
+
|
|
48
|
+
for (const filename of files) {
|
|
49
|
+
const filePath = path.join(this.workflowsDir, filename);
|
|
50
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const parsed = yaml.load(content);
|
|
54
|
+
const fileIssues = this._auditFile(filename, parsed, content);
|
|
55
|
+
results.files.push({
|
|
56
|
+
filename,
|
|
57
|
+
issues: fileIssues,
|
|
58
|
+
});
|
|
59
|
+
results.totalIssues += fileIssues.length;
|
|
60
|
+
} catch (err) {
|
|
61
|
+
results.files.push({
|
|
62
|
+
filename,
|
|
63
|
+
error: `Failed to parse YAML: ${err.message}`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return results;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_auditFile(filename, parsed, rawContent) {
|
|
72
|
+
const issues = [];
|
|
73
|
+
|
|
74
|
+
// 1. Check for concurrency
|
|
75
|
+
if (!parsed.concurrency) {
|
|
76
|
+
issues.push({
|
|
77
|
+
type: 'missing_concurrency',
|
|
78
|
+
severity: 'medium',
|
|
79
|
+
message: 'Missing concurrency group (highly recommended to prevent redundant runs)',
|
|
80
|
+
fix: 'Add concurrency block with cancel-in-progress: true',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 2. Check for outdated actions
|
|
85
|
+
const actionRegex = /uses:\s*([\w\-\/]+)@([\w\.]+)/g;
|
|
86
|
+
let match;
|
|
87
|
+
while ((match = actionRegex.exec(rawContent)) !== null) {
|
|
88
|
+
const fullAction = match[0];
|
|
89
|
+
const actionName = match[1];
|
|
90
|
+
const currentVersion = match[2];
|
|
91
|
+
|
|
92
|
+
const latest = this.latestVersions[actionName];
|
|
93
|
+
if (latest && this._isOutdated(currentVersion, latest)) {
|
|
94
|
+
issues.push({
|
|
95
|
+
type: 'outdated_action',
|
|
96
|
+
severity: 'low',
|
|
97
|
+
message: `Outdated action: ${actionName}@${currentVersion} (latest is ${latest})`,
|
|
98
|
+
action: actionName,
|
|
99
|
+
current: currentVersion,
|
|
100
|
+
latest: latest,
|
|
101
|
+
fix: `Update to @${latest}`,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 3. Check for node-version (hardcoded vs matrix)
|
|
107
|
+
const rawLines = rawContent.split('\n');
|
|
108
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
109
|
+
if (rawLines[i].includes('node-version:')) {
|
|
110
|
+
const versionMatch = rawLines[i].match(/node-version:\s*['"]?(\d+)['"]?/);
|
|
111
|
+
if (versionMatch && parseInt(versionMatch[1]) < 18) {
|
|
112
|
+
issues.push({
|
|
113
|
+
type: 'old_node_version',
|
|
114
|
+
severity: 'medium',
|
|
115
|
+
message: `Using end-of-life Node.js version: ${versionMatch[1]}`,
|
|
116
|
+
line: i + 1,
|
|
117
|
+
fix: 'Upgrade to Node.js 18 or 20',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 4. Check for caching
|
|
124
|
+
if (rawContent.includes('actions/setup-node') && !rawContent.includes('cache:')) {
|
|
125
|
+
issues.push({
|
|
126
|
+
type: 'missing_cache',
|
|
127
|
+
severity: 'high',
|
|
128
|
+
message: 'Dependency caching is not enabled in setup-node',
|
|
129
|
+
fix: 'Add cache: "npm" (or yarn/pnpm) to actions/setup-node',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return issues;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async upgrade(dryRun = false) {
|
|
137
|
+
const results = {
|
|
138
|
+
upgradedFiles: [],
|
|
139
|
+
changes: 0,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
if (!fs.existsSync(this.workflowsDir)) {
|
|
143
|
+
return results;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const files = fs.readdirSync(this.workflowsDir).filter(f => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
147
|
+
|
|
148
|
+
for (const filename of files) {
|
|
149
|
+
const filePath = path.join(this.workflowsDir, filename);
|
|
150
|
+
let content = fs.readFileSync(filePath, 'utf8');
|
|
151
|
+
let originalContent = content;
|
|
152
|
+
let fileChanges = 0;
|
|
153
|
+
|
|
154
|
+
for (const [action, latest] of Object.entries(this.latestVersions)) {
|
|
155
|
+
const regex = new RegExp(`uses:\\s*${action}@([\\w\\.]+)`, 'g');
|
|
156
|
+
content = content.replace(regex, (match, version) => {
|
|
157
|
+
if (this._isOutdated(version, latest)) {
|
|
158
|
+
fileChanges++;
|
|
159
|
+
return `uses: ${action}@${latest}`;
|
|
160
|
+
}
|
|
161
|
+
return match;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (fileChanges > 0) {
|
|
166
|
+
if (!dryRun) {
|
|
167
|
+
fs.writeFileSync(filePath, content, 'utf8');
|
|
168
|
+
}
|
|
169
|
+
results.upgradedFiles.push({
|
|
170
|
+
filename,
|
|
171
|
+
changes: fileChanges,
|
|
172
|
+
});
|
|
173
|
+
results.changes += fileChanges;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return results;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_isOutdated(current, latest) {
|
|
181
|
+
// Simple version comparison for vX formats
|
|
182
|
+
if (current === latest) return false;
|
|
183
|
+
|
|
184
|
+
const currNum = parseInt(current.replace('v', ''));
|
|
185
|
+
const lateNum = parseInt(latest.replace('v', ''));
|
|
186
|
+
|
|
187
|
+
if (!isNaN(currNum) && !isNaN(lateNum)) {
|
|
188
|
+
return currNum < lateNum;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return current !== latest; // Fallback for complex tags
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = WorkflowAnalyzer;
|