File size: 14,130 Bytes
a7caaff 85b7ac8 4b07aaf 85b7ac8 4b07aaf a7caaff 4b07aaf a7caaff 85b7ac8 a7caaff 4b07aaf a7caaff 4b07aaf a7caaff 85b7ac8 4b07aaf 85b7ac8 4b07aaf 85b7ac8 4b07aaf 85b7ac8 4b07aaf 85b7ac8 4b07aaf a7caaff 4b07aaf a7caaff 4b07aaf 85b7ac8 4b07aaf 85b7ac8 a7caaff 4b07aaf a7caaff 85b7ac8 4b07aaf 85b7ac8 4b07aaf 85b7ac8 a7caaff 4b07aaf a7caaff 4b07aaf a7caaff 4b07aaf a7caaff 4b07aaf a7caaff 4b07aaf 85b7ac8 a7caaff 85b7ac8 a7caaff 4b07aaf a7caaff 4b07aaf a7caaff 4b07aaf a7caaff 4b07aaf 85b7ac8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 | """Workflow simulator — YAML parse + CI rule checks."""
import re
from typing import Any, Dict, List, Optional
import yaml
from server.models import FileContent
class WorkflowSimulator:
def validate(self, workflow: Optional[FileContent], files: Dict[str, FileContent]):
if workflow is None:
return {"parse_success": True, "execution_success": True}
content = workflow.content
# single-brace expressions: ${ } should be ${{ }}
# Match ${ ... } that is NOT ${{ ... }}
single_brace = re.findall(r'\$\{(?!\{)\s*[^}]+\}', content)
if single_brace:
return {
"parse_success": False,
"execution_success": False,
"error": (
"Unrecognized expression syntax. "
"Use ${{ expression }} with double braces for GitHub Actions expressions."
),
}
# parse yaml
try:
parsed = yaml.safe_load(content)
except yaml.YAMLError as exc:
return {
"parse_success": False,
"execution_success": False,
"error": f"YAML parse error: {exc}",
}
if not isinstance(parsed, dict):
return {
"parse_success": False,
"execution_success": False,
"error": "Workflow root must be a mapping",
}
# needs an 'on' trigger
if "on" not in parsed and True not in parsed:
# yaml.safe_load converts `on:` to True key in some contexts
return {
"parse_success": False,
"execution_success": False,
"error": "Workflow must define an 'on' trigger event",
}
# validate trigger structure
on_value = parsed.get("on") or parsed.get(True)
if isinstance(on_value, dict):
for event_key, event_config in on_value.items():
if isinstance(event_config, dict):
# Check branches is a list, not a bare string
branches_val = event_config.get("branches")
if isinstance(branches_val, str):
return {
"parse_success": False,
"execution_success": False,
"error": (
f"Unexpected value '{branches_val}' for 'on.{event_key}.branches'. "
"Expected a sequence (list) value."
),
}
# jobs block
jobs = parsed.get("jobs")
if not isinstance(jobs, dict) or not jobs:
return {
"parse_success": False,
"execution_success": False,
"error": "Workflow must define at least one job",
}
# Content-level flags for cross-cutting checks
has_buildx_setup = "docker/setup-buildx-action" in content
has_platforms = "platforms:" in content
has_docker_login = "docker login" in content
has_docker_push = "docker push" in content
has_username_secret = "secrets.DOCKER_USERNAME" in content
has_password_secret = "secrets.DOCKER_PASSWORD" in content
has_github_token_secret = "secrets.GITHUB_TOKEN" in content
# Collect job IDs for needs validation
job_ids = set(jobs.keys())
for job_name, job in jobs.items():
if not isinstance(job, dict):
continue
# runs-on is required
if "runs-on" not in job:
return {
"parse_success": False,
"execution_success": False,
"error": f"Job '{job_name}' is missing required field 'runs-on'",
}
# check 'needs' refs point to real jobs
needs = job.get("needs")
if needs:
needed = [needs] if isinstance(needs, str) else (needs if isinstance(needs, list) else [])
for dep in needed:
if dep not in job_ids:
return {
"parse_success": False,
"execution_success": False,
"error": f"Job '{job_name}' depends on unknown job '{dep}'",
}
steps = job.get("steps", [])
if not isinstance(steps, list):
return {
"parse_success": False,
"execution_success": False,
"error": f"Job '{job_name}' steps must be a list",
}
# every step needs 'uses' or 'run'
for step in steps:
if not isinstance(step, dict):
continue
has_uses = "uses" in step
has_run = "run" in step
if not has_uses and not has_run:
step_name = step.get("name", "unnamed")
return {
"parse_success": False,
"execution_success": False,
"error": f"Every step must define a 'uses' or 'run' key. Step '{step_name}' has neither.",
}
# checkout must come before docker build
checkout_index = -1
build_index = -1
for idx, step in enumerate(steps):
if not isinstance(step, dict):
continue
uses = step.get("uses", "")
run_cmd = step.get("run", "")
if isinstance(uses, str) and "actions/checkout" in uses:
checkout_index = idx
if (isinstance(run_cmd, str) and "docker build" in run_cmd) or (
isinstance(uses, str) and "docker/build-push-action" in uses
):
build_index = idx
if build_index != -1 and (checkout_index == -1 or checkout_index > build_index):
return {
"parse_success": True,
"execution_success": False,
"exec_error": "Checkout must happen before Docker build steps",
}
# cross-job artifact dependency: download needs 'needs'
# If a job uses download-artifact but doesn't declare needs on the upload job
for job_name, job in jobs.items():
if not isinstance(job, dict):
continue
steps = job.get("steps", [])
if not isinstance(steps, list):
continue
uses_download = any(
isinstance(s, dict) and "actions/download-artifact" in str(s.get("uses", ""))
for s in steps
)
if uses_download:
needs = job.get("needs")
if not needs:
return {
"parse_success": True,
"execution_success": False,
"exec_error": (
f"Job '{job_name}' uses download-artifact but has no 'needs' dependency — "
"add 'needs' to ensure the upload job completes first"
),
}
# docker login needs secrets wired via env
if has_docker_login:
# Check if the login step has env block with secrets
login_has_env_secrets = has_username_secret and has_password_secret
if not login_has_env_secrets:
# Check if login uses $DOCKER_USERNAME (env var) without secret mapping
if "$DOCKER_USERNAME" in content and not has_username_secret:
return {
"parse_success": True,
"execution_success": False,
"exec_error": "Docker login secrets not wired — add env block with secrets.DOCKER_USERNAME and secrets.DOCKER_PASSWORD",
}
# push without login
if has_docker_push and not has_docker_login:
# Check if using docker/login-action instead
has_login_action = "docker/login-action" in content
if not has_login_action:
return {
"parse_success": True,
"execution_success": False,
"exec_error": "Docker push without login — add a docker login step before pushing",
}
# ghcr.io needs GITHUB_TOKEN not DOCKER_PASSWORD
if "docker login ghcr.io" in content:
if has_password_secret and not has_github_token_secret:
return {
"parse_success": True,
"execution_success": False,
"exec_error": "GHCR requires GITHUB_TOKEN for authentication, not DOCKER_PASSWORD",
}
# ghcr push needs packages:write permission
if "ghcr.io" in content and "docker push" in content:
# Check if permissions block has packages: write
if "packages: write" not in content and "packages:write" not in content:
return {
"parse_success": True,
"execution_success": False,
"exec_error": "GITHUB_TOKEN does not have packages:write permission — add permissions block",
}
# multi-platform needs buildx
if has_platforms and not has_buildx_setup:
return {
"parse_success": True,
"execution_success": False,
"exec_error": "Multi-platform build requires docker/setup-buildx-action",
}
# GHA cache export needs mode=max
if "cache-to:" in content and "cache-from:" in content:
# Check for mode=max
if "cache-to: type=gha" in content and "mode=max" not in content:
return {
"parse_success": True,
"execution_success": False,
"exec_error": "GHA cache export needs mode=max for proper cache support",
}
# context vs dockerfile path mismatch
for job_name, job in jobs.items():
if not isinstance(job, dict):
continue
for step in job.get("steps", []):
if not isinstance(step, dict):
continue
with_block = step.get("with", {})
if not isinstance(with_block, dict):
continue
context = with_block.get("context")
file_path = with_block.get("file")
if context and file_path and isinstance(context, str) and isinstance(file_path, str):
# If context is a subdirectory but file is at root
if context not in {".", "./"} and not file_path.startswith(context):
return {
"parse_success": True,
"execution_success": False,
"exec_error": f"Dockerfile path '{file_path}' does not match build context '{context}'",
}
# shell env var from secret but not mapped in env block
for job_name, job in jobs.items():
if not isinstance(job, dict):
continue
for step in job.get("steps", []):
if not isinstance(step, dict):
continue
run_cmd = step.get("run", "")
if not isinstance(run_cmd, str):
continue
env_block = step.get("env", {})
if not isinstance(env_block, dict):
env_block = {}
# Find env vars used in run that look like they should come from secrets
env_var_refs = re.findall(r'\$([A-Z][A-Z0-9_]+)', run_cmd)
for var in env_var_refs:
# Skip GitHub expression vars (they're in ${{ }})
if var in ("GITHUB_SHA", "GITHUB_REF", "GITHUB_ACTOR", "GITHUB_REPOSITORY"):
continue
# Common secret-backed env vars
if var in ("SLACK_WEBHOOK_URL", "DEPLOY_TOKEN", "NPM_TOKEN", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"):
if var not in env_block:
return {
"parse_success": True,
"execution_success": False,
"exec_error": f"{var} is empty — secret not available in shell environment. Map it via env block.",
}
# node version vs package.json engines
for job_name, job in jobs.items():
if not isinstance(job, dict):
continue
strategy = job.get("strategy", {})
if not isinstance(strategy, dict):
continue
matrix = strategy.get("matrix", {})
if not isinstance(matrix, dict):
continue
node_versions = matrix.get("node", [])
if isinstance(node_versions, list):
# Check package.json engines constraint
pkg = files.get("package.json")
if pkg:
engines_match = re.search(r'"node"\s*:\s*">=(\d+)"', pkg.content)
if engines_match:
min_version = int(engines_match.group(1))
for v in node_versions:
if isinstance(v, int) and v < min_version:
return {
"parse_success": True,
"execution_success": False,
"exec_error": f"Matrix job (node: {v}) failed: package.json requires Node >= {min_version}",
}
return {"parse_success": True, "execution_success": True}
|