gha-debug 0.1.0__py3-none-any.whl
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.
- gha_debug/__init__.py +3 -0
- gha_debug/cli.py +147 -0
- gha_debug/formatter.py +102 -0
- gha_debug/parser.py +97 -0
- gha_debug/runner.py +191 -0
- gha_debug/validator.py +96 -0
- gha_debug-0.1.0.dist-info/METADATA +113 -0
- gha_debug-0.1.0.dist-info/RECORD +12 -0
- gha_debug-0.1.0.dist-info/WHEEL +5 -0
- gha_debug-0.1.0.dist-info/entry_points.txt +2 -0
- gha_debug-0.1.0.dist-info/licenses/LICENSE +21 -0
- gha_debug-0.1.0.dist-info/top_level.txt +1 -0
gha_debug/__init__.py
ADDED
gha_debug/cli.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""CLI interface for gha-debug."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from gha_debug import __version__
|
|
11
|
+
from gha_debug.formatter import Formatter
|
|
12
|
+
from gha_debug.parser import WorkflowParser
|
|
13
|
+
from gha_debug.runner import WorkflowRunner
|
|
14
|
+
from gha_debug.validator import WorkflowValidator
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group()
|
|
20
|
+
@click.version_option(version=__version__)
|
|
21
|
+
def cli() -> None:
|
|
22
|
+
"""Debug GitHub Actions workflows locally with step-by-step execution."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@cli.command()
|
|
27
|
+
@click.argument("workflow_path", type=click.Path(exists=True))
|
|
28
|
+
@click.option("--job", "-j", help="Specific job to run")
|
|
29
|
+
@click.option("--verbose", "-v", is_flag=True, help="Show verbose output")
|
|
30
|
+
def run(workflow_path: str, job: Optional[str], verbose: bool) -> None:
|
|
31
|
+
"""Run a GitHub Actions workflow locally."""
|
|
32
|
+
try:
|
|
33
|
+
path = Path(workflow_path)
|
|
34
|
+
parser = WorkflowParser(path)
|
|
35
|
+
workflow = parser.parse()
|
|
36
|
+
|
|
37
|
+
runner = WorkflowRunner(workflow, verbose=verbose)
|
|
38
|
+
formatter = Formatter()
|
|
39
|
+
|
|
40
|
+
formatter.print_workflow_header(workflow["name"], path)
|
|
41
|
+
|
|
42
|
+
result = runner.run(job_filter=job)
|
|
43
|
+
|
|
44
|
+
if result["success"]:
|
|
45
|
+
formatter.print_success(result["total_time"])
|
|
46
|
+
sys.exit(0)
|
|
47
|
+
else:
|
|
48
|
+
formatter.print_failure(result["error"])
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
console.print(f"[red]Error:[/red] {str(e)}")
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@cli.command()
|
|
56
|
+
@click.argument("workflow_path", type=click.Path(exists=False), default=".github/workflows")
|
|
57
|
+
def list(workflow_path: str) -> None:
|
|
58
|
+
"""List all workflows, jobs, and steps."""
|
|
59
|
+
try:
|
|
60
|
+
path = Path(workflow_path)
|
|
61
|
+
|
|
62
|
+
if path.is_file():
|
|
63
|
+
workflow_files = [path]
|
|
64
|
+
elif path.is_dir():
|
|
65
|
+
workflow_files = list(path.glob("*.yml")) + list(path.glob("*.yaml"))
|
|
66
|
+
else:
|
|
67
|
+
console.print(f"[yellow]Warning:[/yellow] Path not found: {workflow_path}")
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
|
|
70
|
+
if not workflow_files:
|
|
71
|
+
console.print("[yellow]No workflow files found[/yellow]")
|
|
72
|
+
sys.exit(0)
|
|
73
|
+
|
|
74
|
+
formatter = Formatter()
|
|
75
|
+
|
|
76
|
+
for wf_path in sorted(workflow_files):
|
|
77
|
+
parser = WorkflowParser(wf_path)
|
|
78
|
+
workflow = parser.parse()
|
|
79
|
+
formatter.print_workflow_structure(workflow, wf_path)
|
|
80
|
+
console.print()
|
|
81
|
+
except Exception as e:
|
|
82
|
+
console.print(f"[red]Error:[/red] {str(e)}")
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@cli.command()
|
|
87
|
+
@click.argument("workflow_path", type=click.Path(exists=True))
|
|
88
|
+
@click.option("--job", "-j", help="Specific job to show environment for")
|
|
89
|
+
def env(workflow_path: str, job: Optional[str]) -> None:
|
|
90
|
+
"""Display environment variables and contexts for a workflow."""
|
|
91
|
+
try:
|
|
92
|
+
path = Path(workflow_path)
|
|
93
|
+
parser = WorkflowParser(path)
|
|
94
|
+
workflow = parser.parse()
|
|
95
|
+
|
|
96
|
+
formatter = Formatter()
|
|
97
|
+
formatter.print_environment(workflow, job_filter=job)
|
|
98
|
+
except Exception as e:
|
|
99
|
+
console.print(f"[red]Error:[/red] {str(e)}")
|
|
100
|
+
sys.exit(1)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@cli.command()
|
|
104
|
+
@click.argument("workflow_paths", nargs=-1, type=click.Path(exists=True), required=True)
|
|
105
|
+
def validate(workflow_paths: tuple) -> None:
|
|
106
|
+
"""Validate workflow syntax and catch common errors."""
|
|
107
|
+
try:
|
|
108
|
+
validator = WorkflowValidator()
|
|
109
|
+
all_valid = True
|
|
110
|
+
|
|
111
|
+
for workflow_path in workflow_paths:
|
|
112
|
+
path = Path(workflow_path)
|
|
113
|
+
|
|
114
|
+
if path.is_dir():
|
|
115
|
+
files = list(path.glob("*.yml")) + list(path.glob("*.yaml"))
|
|
116
|
+
else:
|
|
117
|
+
files = [path]
|
|
118
|
+
|
|
119
|
+
for file_path in files:
|
|
120
|
+
errors = validator.validate(file_path)
|
|
121
|
+
|
|
122
|
+
if errors:
|
|
123
|
+
all_valid = False
|
|
124
|
+
console.print(f"\n[red]✗[/red] {file_path}")
|
|
125
|
+
for error in errors:
|
|
126
|
+
console.print(f" [red]•[/red] {error}")
|
|
127
|
+
else:
|
|
128
|
+
console.print(f"[green]✓[/green] {file_path}")
|
|
129
|
+
|
|
130
|
+
if all_valid:
|
|
131
|
+
console.print("\n[green]All workflows are valid![/green]")
|
|
132
|
+
sys.exit(0)
|
|
133
|
+
else:
|
|
134
|
+
console.print("\n[red]Some workflows have errors[/red]")
|
|
135
|
+
sys.exit(1)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
console.print(f"[red]Error:[/red] {str(e)}")
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main() -> None:
|
|
142
|
+
"""Entry point for the CLI."""
|
|
143
|
+
cli()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
main()
|
gha_debug/formatter.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Output formatting with colors and tables."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Formatter:
|
|
13
|
+
"""Format output for the CLI with colors and structure."""
|
|
14
|
+
|
|
15
|
+
def print_workflow_header(self, workflow_name: str, path: Path) -> None:
|
|
16
|
+
"""Print workflow header.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
workflow_name: Name of the workflow
|
|
20
|
+
path: Path to the workflow file
|
|
21
|
+
"""
|
|
22
|
+
console.print(f"\n[bold cyan]Running workflow:[/bold cyan] {workflow_name}")
|
|
23
|
+
console.print(f"[dim]File: {path}[/dim]")
|
|
24
|
+
console.rule()
|
|
25
|
+
|
|
26
|
+
def print_step_success(self, step_name: str, elapsed: float) -> None:
|
|
27
|
+
"""Print successful step.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
step_name: Name of the step
|
|
31
|
+
elapsed: Time elapsed in seconds
|
|
32
|
+
"""
|
|
33
|
+
console.print(f" [green]✓[/green] {step_name} [dim]({elapsed:.1f}s)[/dim]")
|
|
34
|
+
|
|
35
|
+
def print_step_failure(self, step_name: str, elapsed: float) -> None:
|
|
36
|
+
"""Print failed step.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
step_name: Name of the step
|
|
40
|
+
elapsed: Time elapsed in seconds
|
|
41
|
+
"""
|
|
42
|
+
console.print(f" [red]✗[/red] {step_name} [dim]({elapsed:.1f}s)[/dim]")
|
|
43
|
+
|
|
44
|
+
def print_success(self, total_time: float) -> None:
|
|
45
|
+
"""Print workflow success message.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
total_time: Total execution time in seconds
|
|
49
|
+
"""
|
|
50
|
+
console.print(f"\n[green]✓ Workflow completed successfully in {total_time:.1f}s[/green]")
|
|
51
|
+
|
|
52
|
+
def print_failure(self, error: str) -> None:
|
|
53
|
+
"""Print workflow failure message.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
error: Error message
|
|
57
|
+
"""
|
|
58
|
+
console.print(f"\n[red]✗ Workflow failed: {error}[/red]")
|
|
59
|
+
|
|
60
|
+
def print_workflow_structure(self, workflow: Dict[str, Any], path: Path) -> None:
|
|
61
|
+
"""Print workflow structure with jobs and steps.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
workflow: Parsed workflow dictionary
|
|
65
|
+
path: Path to the workflow file
|
|
66
|
+
"""
|
|
67
|
+
console.print(f"[bold]{workflow['name']}[/bold] [dim]({path.name})[/dim]")
|
|
68
|
+
|
|
69
|
+
for job in workflow["jobs"]:
|
|
70
|
+
console.print(f" [cyan]Job:[/cyan] {job['name']} [dim](runs-on: {job['runs-on']})[/dim]")
|
|
71
|
+
|
|
72
|
+
for step in job["steps"]:
|
|
73
|
+
icon = "🔧" if step.get("uses") else "▶"
|
|
74
|
+
console.print(f" {icon} {step['name']}")
|
|
75
|
+
|
|
76
|
+
def print_environment(self, workflow: Dict[str, Any], job_filter: Optional[str] = None) -> None:
|
|
77
|
+
"""Print environment variables for workflow or job.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
workflow: Parsed workflow dictionary
|
|
81
|
+
job_filter: Optional job ID to filter by
|
|
82
|
+
"""
|
|
83
|
+
table = Table(title="Environment Variables")
|
|
84
|
+
table.add_column("Scope", style="cyan")
|
|
85
|
+
table.add_column("Variable", style="green")
|
|
86
|
+
table.add_column("Value", style="yellow")
|
|
87
|
+
|
|
88
|
+
for key, value in workflow.get("env", {}).items():
|
|
89
|
+
table.add_row("Workflow", key, str(value))
|
|
90
|
+
|
|
91
|
+
for job in workflow["jobs"]:
|
|
92
|
+
if job_filter and job["id"] != job_filter:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
for key, value in job.get("env", {}).items():
|
|
96
|
+
table.add_row(f"Job: {job['id']}", key, str(value))
|
|
97
|
+
|
|
98
|
+
table.add_row("Default", "CI", "true")
|
|
99
|
+
table.add_row("Default", "GITHUB_ACTIONS", "true")
|
|
100
|
+
table.add_row("Default", "GITHUB_WORKFLOW", workflow["name"])
|
|
101
|
+
|
|
102
|
+
console.print(table)
|
gha_debug/parser.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""YAML parsing logic for GitHub Actions workflows."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict, List
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WorkflowParser:
|
|
10
|
+
"""Parse GitHub Actions workflow YAML files."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, workflow_path: Path):
|
|
13
|
+
"""Initialize the parser with a workflow file path.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
workflow_path: Path to the workflow YAML file
|
|
17
|
+
"""
|
|
18
|
+
self.workflow_path = workflow_path
|
|
19
|
+
|
|
20
|
+
def parse(self) -> Dict[str, Any]:
|
|
21
|
+
"""Parse the workflow file and extract structure.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Dictionary containing workflow structure with jobs and steps
|
|
25
|
+
|
|
26
|
+
Raises:
|
|
27
|
+
ValueError: If the workflow file is invalid
|
|
28
|
+
FileNotFoundError: If the workflow file doesn't exist
|
|
29
|
+
"""
|
|
30
|
+
if not self.workflow_path.exists():
|
|
31
|
+
raise FileNotFoundError(f"Workflow file not found: {self.workflow_path}")
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
with open(self.workflow_path, "r") as f:
|
|
35
|
+
data = yaml.safe_load(f)
|
|
36
|
+
except yaml.YAMLError as e:
|
|
37
|
+
raise ValueError(f"Invalid YAML in workflow file: {e}")
|
|
38
|
+
|
|
39
|
+
if not isinstance(data, dict):
|
|
40
|
+
raise ValueError("Workflow file must contain a YAML dictionary")
|
|
41
|
+
|
|
42
|
+
workflow_name = data.get("name", self.workflow_path.stem)
|
|
43
|
+
jobs = data.get("jobs", {})
|
|
44
|
+
env = data.get("env", {})
|
|
45
|
+
|
|
46
|
+
parsed_jobs = []
|
|
47
|
+
for job_id, job_data in jobs.items():
|
|
48
|
+
if not isinstance(job_data, dict):
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
steps = job_data.get("steps", [])
|
|
52
|
+
parsed_steps = []
|
|
53
|
+
|
|
54
|
+
for step in steps:
|
|
55
|
+
if not isinstance(step, dict):
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
step_name = step.get("name", step.get("uses", step.get("run", "Unnamed step")))
|
|
59
|
+
parsed_steps.append({
|
|
60
|
+
"name": step_name,
|
|
61
|
+
"uses": step.get("uses"),
|
|
62
|
+
"run": step.get("run"),
|
|
63
|
+
"env": step.get("env", {}),
|
|
64
|
+
"with": step.get("with", {}),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
parsed_jobs.append({
|
|
68
|
+
"id": job_id,
|
|
69
|
+
"name": job_data.get("name", job_id),
|
|
70
|
+
"runs-on": job_data.get("runs-on", "ubuntu-latest"),
|
|
71
|
+
"env": job_data.get("env", {}),
|
|
72
|
+
"steps": parsed_steps,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
"name": workflow_name,
|
|
77
|
+
"env": env,
|
|
78
|
+
"jobs": parsed_jobs,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
def get_job(self, workflow: Dict[str, Any], job_id: str) -> Dict[str, Any]:
|
|
82
|
+
"""Get a specific job from the workflow.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
workflow: Parsed workflow dictionary
|
|
86
|
+
job_id: ID of the job to retrieve
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
Job dictionary
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
ValueError: If job not found
|
|
93
|
+
"""
|
|
94
|
+
for job in workflow["jobs"]:
|
|
95
|
+
if job["id"] == job_id:
|
|
96
|
+
return job
|
|
97
|
+
raise ValueError(f"Job '{job_id}' not found in workflow")
|
gha_debug/runner.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Local execution engine for workflow steps."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from gha_debug.formatter import Formatter
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WorkflowRunner:
|
|
16
|
+
"""Run workflow steps locally with simulated GitHub Actions environment."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, workflow: Dict[str, Any], verbose: bool = False):
|
|
19
|
+
"""Initialize the runner with a parsed workflow.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
workflow: Parsed workflow dictionary
|
|
23
|
+
verbose: Whether to show verbose output
|
|
24
|
+
"""
|
|
25
|
+
self.workflow = workflow
|
|
26
|
+
self.verbose = verbose
|
|
27
|
+
self.formatter = Formatter()
|
|
28
|
+
|
|
29
|
+
def run(self, job_filter: Optional[str] = None) -> Dict[str, Any]:
|
|
30
|
+
"""Run the workflow or a specific job.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
job_filter: Optional job ID to run only that job
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Dictionary with success status and timing information
|
|
37
|
+
"""
|
|
38
|
+
start_time = time.time()
|
|
39
|
+
|
|
40
|
+
jobs_to_run = self.workflow["jobs"]
|
|
41
|
+
if job_filter:
|
|
42
|
+
jobs_to_run = [j for j in jobs_to_run if j["id"] == job_filter]
|
|
43
|
+
if not jobs_to_run:
|
|
44
|
+
return {
|
|
45
|
+
"success": False,
|
|
46
|
+
"error": f"Job '{job_filter}' not found",
|
|
47
|
+
"total_time": 0,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for job in jobs_to_run:
|
|
51
|
+
success = self._run_job(job)
|
|
52
|
+
if not success:
|
|
53
|
+
total_time = time.time() - start_time
|
|
54
|
+
return {
|
|
55
|
+
"success": False,
|
|
56
|
+
"error": f"Job '{job['id']}' failed",
|
|
57
|
+
"total_time": total_time,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
total_time = time.time() - start_time
|
|
61
|
+
return {
|
|
62
|
+
"success": True,
|
|
63
|
+
"total_time": total_time,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
def _run_job(self, job: Dict[str, Any]) -> bool:
|
|
67
|
+
"""Run a single job.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
job: Job dictionary
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
True if job succeeded, False otherwise
|
|
74
|
+
"""
|
|
75
|
+
console.print(f"\n[bold]Job:[/bold] {job['name']}")
|
|
76
|
+
|
|
77
|
+
env = self._build_environment(job)
|
|
78
|
+
|
|
79
|
+
for step in job["steps"]:
|
|
80
|
+
success = self._run_step(step, env)
|
|
81
|
+
if not success:
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
def _run_step(self, step: Dict[str, Any], env: Dict[str, str]) -> bool:
|
|
87
|
+
"""Run a single step.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
step: Step dictionary
|
|
91
|
+
env: Environment variables
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
True if step succeeded, False otherwise
|
|
95
|
+
"""
|
|
96
|
+
start_time = time.time()
|
|
97
|
+
|
|
98
|
+
step_env = {**env, **step.get("env", {})}
|
|
99
|
+
|
|
100
|
+
if step.get("uses"):
|
|
101
|
+
success = self._run_action(step, step_env)
|
|
102
|
+
elif step.get("run"):
|
|
103
|
+
success = self._run_command(step, step_env)
|
|
104
|
+
else:
|
|
105
|
+
success = True
|
|
106
|
+
|
|
107
|
+
elapsed = time.time() - start_time
|
|
108
|
+
|
|
109
|
+
if success:
|
|
110
|
+
self.formatter.print_step_success(step["name"], elapsed)
|
|
111
|
+
else:
|
|
112
|
+
self.formatter.print_step_failure(step["name"], elapsed)
|
|
113
|
+
|
|
114
|
+
return success
|
|
115
|
+
|
|
116
|
+
def _run_action(self, step: Dict[str, Any], env: Dict[str, str]) -> bool:
|
|
117
|
+
"""Simulate running a GitHub Action.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
step: Step dictionary
|
|
121
|
+
env: Environment variables
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
True (actions are simulated as successful)
|
|
125
|
+
"""
|
|
126
|
+
action = step["uses"]
|
|
127
|
+
|
|
128
|
+
if self.verbose:
|
|
129
|
+
console.print(f" [dim]Using action: {action}[/dim]")
|
|
130
|
+
if step.get("with"):
|
|
131
|
+
console.print(f" [dim]With: {step['with']}[/dim]")
|
|
132
|
+
|
|
133
|
+
time.sleep(0.1)
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
def _run_command(self, step: Dict[str, Any], env: Dict[str, str]) -> bool:
|
|
137
|
+
"""Run a shell command.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
step: Step dictionary
|
|
141
|
+
env: Environment variables
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
True if command succeeded, False otherwise
|
|
145
|
+
"""
|
|
146
|
+
command = step["run"]
|
|
147
|
+
|
|
148
|
+
if self.verbose:
|
|
149
|
+
console.print(f" [dim]Running: {command}[/dim]")
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
result = subprocess.run(
|
|
153
|
+
command,
|
|
154
|
+
shell=True,
|
|
155
|
+
env={**os.environ.copy(), **env},
|
|
156
|
+
capture_output=not self.verbose,
|
|
157
|
+
text=True,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if result.returncode == 0:
|
|
161
|
+
return True
|
|
162
|
+
else:
|
|
163
|
+
if not self.verbose and result.stderr:
|
|
164
|
+
console.print(f" [red]{result.stderr.strip()}[/red]")
|
|
165
|
+
return False
|
|
166
|
+
except Exception as e:
|
|
167
|
+
console.print(f" [red]Error: {str(e)}[/red]")
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
def _build_environment(self, job: Dict[str, Any]) -> Dict[str, str]:
|
|
171
|
+
"""Build environment variables for a job.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
job: Job dictionary
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Dictionary of environment variables
|
|
178
|
+
"""
|
|
179
|
+
env = {
|
|
180
|
+
"CI": "true",
|
|
181
|
+
"GITHUB_ACTIONS": "true",
|
|
182
|
+
"GITHUB_WORKFLOW": self.workflow["name"],
|
|
183
|
+
"GITHUB_JOB": job["id"],
|
|
184
|
+
"GITHUB_RUNNER_OS": "Linux",
|
|
185
|
+
"RUNNER_OS": "Linux",
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
env.update(self.workflow.get("env", {}))
|
|
189
|
+
env.update(job.get("env", {}))
|
|
190
|
+
|
|
191
|
+
return {k: str(v) for k, v in env.items()}
|
gha_debug/validator.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Workflow validation logic."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class WorkflowValidator:
|
|
10
|
+
"""Validate GitHub Actions workflow files."""
|
|
11
|
+
|
|
12
|
+
def validate(self, workflow_path: Path) -> List[str]:
|
|
13
|
+
"""Validate a workflow file and return list of errors.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
workflow_path: Path to the workflow YAML file
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
List of error messages (empty if valid)
|
|
20
|
+
"""
|
|
21
|
+
errors = []
|
|
22
|
+
|
|
23
|
+
if not workflow_path.exists():
|
|
24
|
+
return [f"File not found: {workflow_path}"]
|
|
25
|
+
|
|
26
|
+
# Read raw content for syntax checks
|
|
27
|
+
with open(workflow_path, "r") as f:
|
|
28
|
+
raw_content = f.read()
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
data = yaml.safe_load(raw_content)
|
|
32
|
+
except yaml.YAMLError as e:
|
|
33
|
+
return [f"Invalid YAML syntax: {e}"]
|
|
34
|
+
|
|
35
|
+
if not isinstance(data, dict):
|
|
36
|
+
return ["Workflow must be a YAML dictionary"]
|
|
37
|
+
|
|
38
|
+
if "jobs" not in data:
|
|
39
|
+
errors.append("Missing required field: 'jobs'")
|
|
40
|
+
return errors
|
|
41
|
+
|
|
42
|
+
if not isinstance(data["jobs"], dict):
|
|
43
|
+
errors.append("'jobs' must be a dictionary")
|
|
44
|
+
return errors
|
|
45
|
+
|
|
46
|
+
if not data["jobs"]:
|
|
47
|
+
errors.append("Workflow must have at least one job")
|
|
48
|
+
|
|
49
|
+
for job_id, job_data in data["jobs"].items():
|
|
50
|
+
if not isinstance(job_data, dict):
|
|
51
|
+
errors.append(f"Job '{job_id}' must be a dictionary")
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
if "steps" not in job_data:
|
|
55
|
+
errors.append(f"Job '{job_id}' missing required field: 'steps'")
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
if not isinstance(job_data["steps"], list):
|
|
59
|
+
errors.append(f"Job '{job_id}': 'steps' must be a list")
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
if not job_data["steps"]:
|
|
63
|
+
errors.append(f"Job '{job_id}' must have at least one step")
|
|
64
|
+
|
|
65
|
+
for idx, step in enumerate(job_data["steps"]):
|
|
66
|
+
if not isinstance(step, dict):
|
|
67
|
+
errors.append(f"Job '{job_id}', step {idx}: must be a dictionary")
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
has_uses = "uses" in step
|
|
71
|
+
has_run = "run" in step
|
|
72
|
+
|
|
73
|
+
if not has_uses and not has_run:
|
|
74
|
+
errors.append(f"Job '{job_id}', step {idx}: must have 'uses' or 'run'")
|
|
75
|
+
|
|
76
|
+
if has_uses and has_run:
|
|
77
|
+
errors.append(f"Job '{job_id}', step {idx}: cannot have both 'uses' and 'run'")
|
|
78
|
+
|
|
79
|
+
self._validate_syntax_patterns(raw_content, data, errors)
|
|
80
|
+
|
|
81
|
+
return errors
|
|
82
|
+
|
|
83
|
+
def _validate_syntax_patterns(self, raw_content: str, data: dict, errors: List[str]) -> None:
|
|
84
|
+
"""Check for common syntax mistakes.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
raw_content: Raw YAML content as string
|
|
88
|
+
data: Parsed workflow YAML
|
|
89
|
+
errors: List to append errors to
|
|
90
|
+
"""
|
|
91
|
+
# Check for malformed GitHub expressions (no space)
|
|
92
|
+
if "${{}}" in raw_content:
|
|
93
|
+
errors.append("Syntax hint: GitHub expressions use '${{ }}' not '${{}}'")
|
|
94
|
+
|
|
95
|
+
if "on" not in data and True not in data:
|
|
96
|
+
errors.append("Warning: Missing 'on:' trigger configuration")
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gha-debug
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Debug GitHub Actions workflows locally with step-by-step execution
|
|
5
|
+
Author: Intellirim
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/intellirim/gha-debug
|
|
8
|
+
Keywords: github-actions,developer-tools,cli,ci-cd,workflow-debugging,devops
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: click>=8.1.0
|
|
22
|
+
Requires-Dist: pyyaml>=6.0
|
|
23
|
+
Requires-Dist: rich>=13.0.0
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# gha-debug
|
|
27
|
+
|
|
28
|
+
Debug GitHub Actions workflows locally with step-by-step execution
|
|
29
|
+
|
|
30
|
+
[](https://opensource.org/licenses/MIT)
|
|
31
|
+
|
|
32
|
+
## Overview
|
|
33
|
+
|
|
34
|
+
`gha-debug` is a lightweight CLI tool that helps developers test and debug GitHub Actions workflows locally before pushing to CI. Unlike heavy Docker-based solutions, it provides fast validation, clear step-by-step execution visibility, and helpful error messages.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install gha-debug
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
### Run a workflow locally
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
gha-debug run .github/workflows/test.yml
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Run a specific job with verbose output
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
gha-debug run .github/workflows/build.yml --job build --verbose
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### List all workflows and jobs
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
gha-debug list
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Show environment variables for a job
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
gha-debug env .github/workflows/deploy.yml --job deploy
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Validate workflow syntax
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
gha-debug validate .github/workflows/*.yml
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Features
|
|
75
|
+
|
|
76
|
+
- ✅ Parse and validate GitHub Actions workflow YAML files
|
|
77
|
+
- ✅ List all workflows, jobs, and steps with clear formatting
|
|
78
|
+
- ✅ Run workflows locally with simulated GitHub Actions environment
|
|
79
|
+
- ✅ Show detailed step-by-step execution with timing information
|
|
80
|
+
- ✅ Display environment variables and contexts for debugging
|
|
81
|
+
- ✅ Validate workflow syntax and catch common errors before pushing
|
|
82
|
+
- ✅ Colorized output for better readability
|
|
83
|
+
- ✅ Support for filtering by specific job within a workflow
|
|
84
|
+
|
|
85
|
+
## Why gha-debug?
|
|
86
|
+
|
|
87
|
+
Debugging GitHub Actions workflows is painful:
|
|
88
|
+
- Logs are hard to navigate in the web interface
|
|
89
|
+
- Re-running failed jobs wastes time
|
|
90
|
+
- No simple local testing that mirrors the CI environment
|
|
91
|
+
|
|
92
|
+
`gha-debug` solves this by providing a fast feedback loop. It doesn't try to perfectly replicate GitHub's environment (which causes compatibility issues), but instead focuses on what developers need most: quick validation and clear error messages.
|
|
93
|
+
|
|
94
|
+
## Example Output
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
$ gha-debug run .github/workflows/test.yml
|
|
98
|
+
|
|
99
|
+
Running workflow: test.yml
|
|
100
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
101
|
+
|
|
102
|
+
Job: test
|
|
103
|
+
✓ Checkout code (1.2s)
|
|
104
|
+
✓ Setup Python 3.11 (0.8s)
|
|
105
|
+
✓ Install dependencies (4.5s)
|
|
106
|
+
✓ Run tests (12.3s)
|
|
107
|
+
|
|
108
|
+
✓ Workflow completed successfully in 18.8s
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT License - Copyright (c) 2026 Intellirim
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
gha_debug/__init__.py,sha256=BniAhhxX-uNwkwZw22Kej3-rYFhlfoW9x3crCBhSGM0,80
|
|
2
|
+
gha_debug/cli.py,sha256=Z2rOLY0UE3JStU3YKm-mP2eZYNs9VyuDCIjPCWN1ba8,4501
|
|
3
|
+
gha_debug/formatter.py,sha256=ymP039W2D6oInmABZ0cRxn--jHJcRMScL5gelUw-UDc,3489
|
|
4
|
+
gha_debug/parser.py,sha256=TQ4lW_KPqj9oGzOvKosSKn0nHRjsVclmJ3Ya_SLjR28,3015
|
|
5
|
+
gha_debug/runner.py,sha256=z1tEjENMrrx2b_wOtV71kdFN1VuqXiqBh8zYs0DymvQ,5367
|
|
6
|
+
gha_debug/validator.py,sha256=vRTP_hEofFBAxJB2dhqQxi9GiRLwZCg9-6P1OHy-gk8,3187
|
|
7
|
+
gha_debug-0.1.0.dist-info/licenses/LICENSE,sha256=eZ8ricP3gTwzaY1c4vb6oib6f5K0s8Nsg2EmBJ1RACk,1067
|
|
8
|
+
gha_debug-0.1.0.dist-info/METADATA,sha256=_-84NAiC9bl0DFLHIGF7i4ab1QwaHkdluIAkvRhohpw,3348
|
|
9
|
+
gha_debug-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
10
|
+
gha_debug-0.1.0.dist-info/entry_points.txt,sha256=qvyOVxmPbWo5z0wwbsuDD7vx_DREiN1CoHr0LCt3ClI,49
|
|
11
|
+
gha_debug-0.1.0.dist-info/top_level.txt,sha256=iZ8sJpbmI-IaEZoD4ldqz3i8t5Net1FPHuhhi5NQUy4,10
|
|
12
|
+
gha_debug-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Intellirim
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gha_debug
|