guanghulab/scripts/skyeye/scan-workflows.js
Guanghu Domestic Migration d1e47f4565
Some checks are pending
自动更新代码和重启 / update-and-restart (push) Waiting to run
CI检查 + 自动部署 / check (push) Waiting to run
CI检查 + 自动部署 / deploy (push) Blocked by required conditions
重启聊天服务 / restart (push) Waiting to run
chore: import sanitized domestic snapshot for REPO-002
Source snapshot: ca48d3ddf926d79aa138306164169baf764bb829
2026-07-17 15:54:41 +08:00

242 lines
7.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// scripts/skyeye/scan-workflows.js
// 天眼·扫描模块A · Workflow 健康度扫描
//
// 扫描内容:
// ① 所有 .yml 语法检查
// ② 最近 24h 运行结果(通过 /tmp/skyeye/recent-runs.json
// ③ workflow 之间的触发冲突/死循环检测
// ④ cron 表达式合理性
// ⑤ workflow 基本结构验证
//
// 输出JSON → stdout
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '../..');
const WF_DIR = path.join(ROOT, '.github/workflows');
const BEIJING_OFFSET_MS = 8 * 3600 * 1000;
const now = new Date();
// ━━━ YAML 基本语法检查 ━━━
function checkYamlSyntax(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
// Basic checks: not empty, has 'name:', has 'on:', has 'jobs:'
const hasName = /^name\s*:/m.test(content);
const hasOn = /^on\s*:/m.test(content) || /^"on"\s*:/m.test(content) || /^'on'\s*:/m.test(content);
const hasJobs = /^jobs\s*:/m.test(content);
const issues = [];
if (!hasName) issues.push('缺少 name 字段');
if (!hasOn) issues.push('缺少 on 触发器');
if (!hasJobs) issues.push('缺少 jobs 定义');
if (content.includes('\t')) issues.push('包含 tab 字符YAML 建议使用空格)');
return {
valid: issues.length === 0,
issues,
lines: content.split('\n').length
};
} catch (e) {
return { valid: false, issues: ['读取文件失败: ' + e.message], lines: 0 };
}
}
// ━━━ 提取 Workflow 元信息 ━━━
function extractWorkflowMeta(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
const meta = {};
// Name
const nameMatch = content.match(/^name\s*:\s*["']?(.+?)["']?\s*$/m);
meta.name = nameMatch ? nameMatch[1].trim() : path.basename(filePath);
// Triggers
meta.triggers = [];
if (/schedule\s*:/.test(content)) meta.triggers.push('schedule');
if (/push\s*:/.test(content)) meta.triggers.push('push');
if (/pull_request/.test(content)) meta.triggers.push('pull_request');
if (/workflow_dispatch/.test(content)) meta.triggers.push('workflow_dispatch');
if (/workflow_run/.test(content)) meta.triggers.push('workflow_run');
if (/issues\s*:/.test(content)) meta.triggers.push('issues');
if (/discussion/.test(content)) meta.triggers.push('discussion');
// Cron expressions
const cronMatches = content.match(/cron\s*:\s*['"](.+?)['"]/g);
meta.crons = cronMatches
? cronMatches.map(c => c.match(/['"](.+?)['"]/)[1])
: [];
// Referenced secrets
const secretMatches = content.match(/secrets\.([A-Z_]+)/g);
meta.secrets = secretMatches
? [...new Set(secretMatches.map(s => s.replace('secrets.', '')))]
: [];
// Uses git push?
meta.uses_git_push = /git\s+push/i.test(content);
// Commit prefix
const commitMatch = content.match(/git\s+commit\s+-m\s+["']([^\s"']+)/);
meta.commit_prefix = commitMatch ? commitMatch[1] : null;
return meta;
} catch (e) {
return { name: path.basename(filePath), triggers: [], crons: [], secrets: [], error: e.message };
}
}
// ━━━ 检测 Cron 冲突 ━━━
function detectCronConflicts(workflows) {
const cronMap = {};
const conflicts = [];
for (const wf of workflows) {
for (const cron of (wf.meta.crons || [])) {
if (!cronMap[cron]) cronMap[cron] = [];
cronMap[cron].push(wf.file);
}
}
for (const [cron, files] of Object.entries(cronMap)) {
if (files.length > 1) {
conflicts.push({
type: 'cron_conflict',
cron,
workflows: files,
detail: `${files.length} 个 workflow 使用相同 cron: ${cron}`
});
}
}
return conflicts;
}
// ━━━ 检测潜在循环触发 ━━━
function detectTriggerLoops(workflows) {
const loops = [];
const pushWorkflows = workflows.filter(w => w.meta.triggers.includes('push') && w.meta.uses_git_push);
for (const wf of pushWorkflows) {
// A workflow that triggers on push AND does git push could cause loops
// Unless it has proper guards (bot check, prefix check)
loops.push({
type: 'potential_loop',
workflow: wf.file,
detail: `${wf.file} 由 push 触发且执行 git push — 需确认有防循环保护`
});
}
return loops;
}
// ━━━ 解析最近运行结果 ━━━
function parseRecentRuns() {
const runsFile = '/tmp/skyeye/recent-runs.json';
try {
if (!fs.existsSync(runsFile)) return { available: false, runs: [] };
const runs = JSON.parse(fs.readFileSync(runsFile, 'utf8'));
return { available: true, runs };
} catch (e) {
return { available: false, runs: [], error: e.message };
}
}
// ━━━ 主扫描 ━━━
function scanWorkflows() {
const result = {
scan_time: new Date(now.getTime() + BEIJING_OFFSET_MS).toISOString().replace('T', ' ').slice(0, 19) + '+08:00',
total_workflows: 0,
healthy: 0,
issues: [],
workflow_map: [],
cron_conflicts: [],
potential_loops: []
};
// 1. 扫描所有 workflow 文件
let files = [];
try {
files = fs.readdirSync(WF_DIR).filter(f => f.endsWith('.yml') || f.endsWith('.yaml'));
} catch (e) {
result.issues.push({ file: WF_DIR, type: 'dir_error', detail: '无法读取 workflows 目录: ' + e.message });
console.log(JSON.stringify(result, null, 2));
return;
}
result.total_workflows = files.length;
for (const file of files) {
const filePath = path.join(WF_DIR, file);
const syntax = checkYamlSyntax(filePath);
const meta = extractWorkflowMeta(filePath);
const entry = {
file,
name: meta.name,
triggers: meta.triggers,
crons: meta.crons,
secrets: meta.secrets,
uses_git_push: meta.uses_git_push,
syntax_ok: syntax.valid,
status: syntax.valid ? '✅' : '❌',
last_run: null
};
if (syntax.valid) {
result.healthy++;
} else {
result.issues.push({
file,
type: 'syntax_error',
detail: syntax.issues.join('; ')
});
}
result.workflow_map.push(entry);
}
// 2. 整合最近运行结果
const recentRuns = parseRecentRuns();
if (recentRuns.available) {
for (const run of recentRuns.runs) {
const entry = result.workflow_map.find(w => w.name === run.name);
if (entry) {
entry.last_run = run.createdAt;
if (run.conclusion === 'failure') {
entry.status = '❌';
if (!result.issues.find(i => i.file === entry.file && i.type === 'run_failure')) {
result.issues.push({
file: entry.file,
type: 'run_failure',
detail: `最近运行失败 · ${run.createdAt}`
});
}
}
}
}
}
// 3. 检测 Cron 冲突
result.cron_conflicts = detectCronConflicts(
result.workflow_map.map(w => ({ file: w.file, meta: { crons: w.crons } }))
);
// 4. 检测循环触发风险
result.potential_loops = detectTriggerLoops(
result.workflow_map.map(w => ({
file: w.file,
meta: { triggers: w.triggers, uses_git_push: w.uses_git_push }
}))
);
console.log(JSON.stringify(result, null, 2));
}
scanWorkflows();