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}