vibe-codex
Version:
CLI tool to install development rules and git hooks with interactive configuration
676 lines (598 loc) • 18.8 kB
JavaScript
/**
* GitHub Workflow module - GitHub Actions and CI/CD workflow rules
*/
import { RuleModule } from "../base.js";
import fs from "fs/promises";
import path from "path";
import yaml from "js-yaml";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
export class GitHubWorkflowModule extends RuleModule {
constructor() {
super({
name: "github-workflow",
version: "1.0.0",
description: "GitHub Actions workflow validation and best practices",
dependencies: ["github"],
options: {
requireCI: true,
requireSecurityScanning: true,
requireDependencyUpdates: true,
workflowTimeout: 60, // minutes
},
});
}
async loadRules() {
// Level 4: GitHub Workflow Rules
this.registerRule({
id: "GHW-1",
name: "CI Workflow Exists",
description: "Repository must have continuous integration workflow",
level: 4,
category: "github-workflow",
severity: "error",
check: async (context) => {
if (!context.config?.["github-workflow"]?.requireCI) return [];
const workflowDir = path.join(
context.projectPath,
".github",
"workflows",
);
try {
const files = await fs.readdir(workflowDir);
const workflows = files.filter(
(f) => f.endsWith(".yml") || f.endsWith(".yaml"),
);
if (workflows.length === 0) {
return [
{
message: "No GitHub Actions workflows found",
},
];
}
// Check for CI-related workflows
const ciWorkflows = [];
for (const workflow of workflows) {
const content = await fs.readFile(
path.join(workflowDir, workflow),
"utf8",
);
const workflowData = yaml.load(content);
// Check if workflow runs on push/PR
const triggers = workflowData.on || workflowData.true;
if (triggers && (triggers.push || triggers.pull_request)) {
ciWorkflows.push(workflow);
}
}
if (ciWorkflows.length === 0) {
return [
{
message: "No CI workflow triggered on push/pull_request found",
},
];
}
return [];
} catch (error) {
return [
{
message: "No .github/workflows directory found",
},
];
}
},
fix: async (context) => {
const workflowDir = path.join(
context.projectPath,
".github",
"workflows",
);
await fs.mkdir(workflowDir, { recursive: true });
const ciWorkflow = `name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js \${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: \${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint --if-present
- name: Run tests
run: npm test
- name: Build
run: npm run build --if-present
`;
await fs.writeFile(path.join(workflowDir, "ci.yml"), ciWorkflow);
return true;
},
});
this.registerRule({
id: "GHW-2",
name: "Workflow Security",
description: "Workflows must follow security best practices",
level: 4,
category: "github-workflow",
severity: "error",
check: async (context) => {
const violations = [];
const workflowDir = path.join(
context.projectPath,
".github",
"workflows",
);
try {
const files = await fs.readdir(workflowDir);
const workflows = files.filter(
(f) => f.endsWith(".yml") || f.endsWith(".yaml"),
);
for (const workflow of workflows) {
const content = await fs.readFile(
path.join(workflowDir, workflow),
"utf8",
);
const lines = content.split("\n");
// Check for hardcoded secrets
const secretPatterns = [
/[A-Za-z0-9]{40}/, // GitHub tokens
/[A-Za-z0-9]{32}/, // API keys
/password\s*[:=]\s*["'][^"']+["']/i,
];
lines.forEach((line, index) => {
for (const pattern of secretPatterns) {
if (
pattern.test(line) &&
!line.includes("${{") &&
!line.includes("secrets.")
) {
violations.push({
file: workflow,
line: index + 1,
message: "Potential hardcoded secret detected",
});
}
}
});
// Check for workflow permissions
const workflowData = yaml.load(content);
if (!workflowData.permissions) {
violations.push({
file: workflow,
message: "Workflow should specify permissions explicitly",
});
}
// Check for third-party actions without hash
const actionUses = content.match(/uses:\s*([^\s]+)/g) || [];
for (const action of actionUses) {
const actionName = action.replace("uses:", "").trim();
if (
!actionName.includes("@") ||
actionName.includes("@master") ||
actionName.includes("@main")
) {
violations.push({
file: workflow,
action: actionName,
message:
"Third-party actions should be pinned to a specific version/commit SHA",
});
}
}
}
} catch (error) {
// Workflows directory doesn't exist
}
return violations;
},
});
this.registerRule({
id: "GHW-3",
name: "Workflow Timeout",
description: "Workflows must have appropriate timeouts",
level: 4,
category: "github-workflow",
severity: "warning",
check: async (context) => {
const violations = [];
const workflowDir = path.join(
context.projectPath,
".github",
"workflows",
);
const maxTimeout =
context.config?.["github-workflow"]?.workflowTimeout ||
this.options.workflowTimeout;
try {
const files = await fs.readdir(workflowDir);
const workflows = files.filter(
(f) => f.endsWith(".yml") || f.endsWith(".yaml"),
);
for (const workflow of workflows) {
const content = await fs.readFile(
path.join(workflowDir, workflow),
"utf8",
);
const workflowData = yaml.load(content);
// Check job timeouts
if (workflowData.jobs) {
for (const [jobName, jobConfig] of Object.entries(
workflowData.jobs,
)) {
if (!jobConfig["timeout-minutes"]) {
violations.push({
file: workflow,
job: jobName,
message: `Job '${jobName}' should have timeout-minutes specified`,
});
} else if (jobConfig["timeout-minutes"] > maxTimeout) {
violations.push({
file: workflow,
job: jobName,
message: `Job '${jobName}' timeout (${jobConfig["timeout-minutes"]}min) exceeds maximum (${maxTimeout}min)`,
});
}
}
}
}
} catch (error) {
// Workflows directory doesn't exist
}
return violations;
},
});
this.registerRule({
id: "GHW-4",
name: "Security Scanning",
description: "Repository should have security scanning workflows",
level: 4,
category: "github-workflow",
severity: "warning",
check: async (context) => {
if (!context.config?.["github-workflow"]?.requireSecurityScanning)
return [];
const workflowDir = path.join(
context.projectPath,
".github",
"workflows",
);
try {
const files = await fs.readdir(workflowDir);
const workflows = files.filter(
(f) => f.endsWith(".yml") || f.endsWith(".yaml"),
);
// Check for security-related workflows
const securityKeywords = [
"security",
"codeql",
"dependabot",
"snyk",
"trivy",
"scan",
];
const hasSecurityWorkflow = workflows.some((workflow) =>
securityKeywords.some((keyword) =>
workflow.toLowerCase().includes(keyword),
),
);
if (!hasSecurityWorkflow) {
// Check workflow contents
let foundSecurity = false;
for (const workflow of workflows) {
const content = await fs.readFile(
path.join(workflowDir, workflow),
"utf8",
);
if (
securityKeywords.some((keyword) =>
content.toLowerCase().includes(keyword),
)
) {
foundSecurity = true;
break;
}
}
if (!foundSecurity) {
return [
{
message:
"No security scanning workflow found (CodeQL, Dependabot, etc.)",
},
];
}
}
return [];
} catch (error) {
return [
{
message: "No security scanning workflows found",
},
];
}
},
fix: async (context) => {
const workflowDir = path.join(
context.projectPath,
".github",
"workflows",
);
await fs.mkdir(workflowDir, { recursive: true });
const codeqlWorkflow = `name: "CodeQL"
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
schedule:
- cron: '0 0 * * 1'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: \${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
`;
await fs.writeFile(
path.join(workflowDir, "codeql.yml"),
codeqlWorkflow,
);
return true;
},
});
this.registerRule({
id: "GHW-5",
name: "Dependency Updates",
description: "Repository should have automated dependency updates",
level: 4,
category: "github-workflow",
severity: "info",
check: async (context) => {
if (!context.config?.["github-workflow"]?.requireDependencyUpdates)
return [];
// Check for Dependabot config
const dependabotPath = path.join(
context.projectPath,
".github",
"dependabot.yml",
);
const renovatePaths = [
"renovate.json",
".renovaterc",
".renovaterc.json",
".github/renovate.json",
];
try {
await fs.access(dependabotPath);
return []; // Dependabot configured
} catch {
// Check for Renovate
const hasRenovate = await Promise.all(
renovatePaths.map((p) =>
fs
.access(path.join(context.projectPath, p))
.then(() => true)
.catch(() => false),
),
);
if (!hasRenovate.some((exists) => exists)) {
return [
{
message:
"No automated dependency updates configured (Dependabot/Renovate)",
},
];
}
}
return [];
},
fix: async (context) => {
const dependabotPath = path.join(
context.projectPath,
".github",
"dependabot.yml",
);
await fs.mkdir(path.dirname(dependabotPath), { recursive: true });
const dependabotConfig = `version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 10
reviewers:
- "your-github-username"
labels:
- "dependencies"
- "automated"
`;
await fs.writeFile(dependabotPath, dependabotConfig);
return true;
},
});
this.registerRule({
id: "GHW-6",
name: "Workflow Efficiency",
description: "Workflows should be efficient and use caching",
level: 4,
category: "github-workflow",
severity: "info",
check: async (context) => {
const violations = [];
const workflowDir = path.join(
context.projectPath,
".github",
"workflows",
);
try {
const files = await fs.readdir(workflowDir);
const workflows = files.filter(
(f) => f.endsWith(".yml") || f.endsWith(".yaml"),
);
for (const workflow of workflows) {
const content = await fs.readFile(
path.join(workflowDir, workflow),
"utf8",
);
// Check for Node.js workflows without caching
if (
content.includes("actions/setup-node") &&
!content.includes("cache:")
) {
violations.push({
file: workflow,
message: "Node.js workflow should use dependency caching",
});
}
// Check for redundant checkouts
const checkoutCount = (content.match(/actions\/checkout/g) || [])
.length;
if (checkoutCount > 1) {
violations.push({
file: workflow,
message:
"Multiple checkout actions detected - consider reusing code",
});
}
// Check for artifact usage in multi-job workflows
const workflowData = yaml.load(content);
if (
workflowData.jobs &&
Object.keys(workflowData.jobs).length > 1
) {
const hasArtifacts =
content.includes("actions/upload-artifact") ||
content.includes("actions/download-artifact");
if (!hasArtifacts) {
violations.push({
file: workflow,
message:
"Multi-job workflow could benefit from artifact sharing",
});
}
}
}
} catch (error) {
// Workflows directory doesn't exist
}
return violations;
},
});
}
async loadHooks() {
// Pre-commit hook to validate workflow files
this.registerHook("pre-commit", async (context) => {
const modifiedFiles = context.stagedFiles || [];
const workflowFiles = modifiedFiles.filter(
(f) =>
f.includes(".github/workflows/") &&
(f.endsWith(".yml") || f.endsWith(".yaml")),
);
if (workflowFiles.length > 0) {
console.log("🔄 Validating GitHub Actions workflows...");
for (const file of workflowFiles) {
try {
const content = await fs.readFile(file, "utf8");
yaml.load(content); // Validate YAML syntax
} catch (error) {
console.error(`❌ Invalid YAML in ${file}: ${error.message}`);
return false;
}
}
console.log("✅ Workflow files are valid");
}
return true;
});
}
async loadValidators() {
// Workflow syntax validator
this.registerValidator("workflow-syntax", async (projectPath) => {
const workflowDir = path.join(projectPath, ".github", "workflows");
const errors = [];
try {
const files = await fs.readdir(workflowDir);
const workflows = files.filter(
(f) => f.endsWith(".yml") || f.endsWith(".yaml"),
);
for (const workflow of workflows) {
try {
const content = await fs.readFile(
path.join(workflowDir, workflow),
"utf8",
);
const data = yaml.load(content);
// Basic structure validation
if (!data.name) {
errors.push(`${workflow}: Missing workflow name`);
}
if (!data.on) {
errors.push(`${workflow}: Missing trigger events`);
}
if (!data.jobs || Object.keys(data.jobs).length === 0) {
errors.push(`${workflow}: No jobs defined`);
}
} catch (error) {
errors.push(`${workflow}: ${error.message}`);
}
}
} catch {
// No workflows directory
}
return {
valid: errors.length === 0,
errors,
};
});
// GitHub Actions availability validator
this.registerValidator("github-actions", async (projectPath) => {
try {
// Check if it's a GitHub repository
const { stdout } = await execAsync("git remote -v", {
cwd: projectPath,
});
if (!stdout.includes("github.com")) {
return {
valid: false,
message: "GitHub Actions only available for GitHub repositories",
};
}
return { valid: true };
} catch (error) {
return {
valid: false,
message: "Unable to verify GitHub repository",
};
}
});
}
}
// Export singleton instance
export default new GitHubWorkflowModule();