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 ADDED
@@ -0,0 +1,3 @@
1
+ """gha-debug: Debug GitHub Actions workflows locally."""
2
+
3
+ __version__ = "0.1.0"
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
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gha-debug = gha_debug.cli:main
@@ -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