pygenkit 0.2.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.
- pygenkit/__init__.py +2 -0
- pygenkit/ai/__init__.py +11 -0
- pygenkit/ai/prompts.py +27 -0
- pygenkit/ai/provider.py +57 -0
- pygenkit/ai/review.py +59 -0
- pygenkit/cli/__init__.py +3 -0
- pygenkit/cli/app.py +53 -0
- pygenkit/cli/commands/__init__.py +21 -0
- pygenkit/cli/commands/build.py +41 -0
- pygenkit/cli/commands/doctor.py +119 -0
- pygenkit/cli/commands/generate.py +52 -0
- pygenkit/cli/commands/health.py +64 -0
- pygenkit/cli/commands/init.py +23 -0
- pygenkit/cli/commands/inspect.py +58 -0
- pygenkit/cli/commands/new.py +37 -0
- pygenkit/cli/commands/publish.py +30 -0
- pygenkit/cli/commands/release.py +33 -0
- pygenkit/cli/commands/release_check.py +17 -0
- pygenkit/cli/commands/review.py +58 -0
- pygenkit/cli/commands/validate.py +29 -0
- pygenkit/cli.py +4 -0
- pygenkit/config/__init__.py +3 -0
- pygenkit/generators/__init__.py +13 -0
- pygenkit/generators/base.py +31 -0
- pygenkit/generators/deploy.py +48 -0
- pygenkit/generators/docker.py +43 -0
- pygenkit/generators/github_actions.py +68 -0
- pygenkit/generators/orchestrator.py +33 -0
- pygenkit/generators/project.py +110 -0
- pygenkit/health/__init__.py +3 -0
- pygenkit/health/api.py +57 -0
- pygenkit/health/checks.py +343 -0
- pygenkit/inspector/__init__.py +3 -0
- pygenkit/inspector/api.py +154 -0
- pygenkit/inspector/debian.py +51 -0
- pygenkit/inspector/detect.py +84 -0
- pygenkit/inspector/git.py +55 -0
- pygenkit/inspector/pyproject.py +74 -0
- pygenkit/models/__init__.py +25 -0
- pygenkit/models/config.py +266 -0
- pygenkit/models/health.py +22 -0
- pygenkit/models/inspection.py +56 -0
- pygenkit/render/__init__.py +3 -0
- pygenkit/render/engine.py +41 -0
- pygenkit/services/__init__.py +0 -0
- pygenkit/templates/deploy/Procfile.j2 +1 -0
- pygenkit/templates/deploy/fly.toml.j2 +14 -0
- pygenkit/templates/deploy/railway.json.j2 +12 -0
- pygenkit/templates/docker/Dockerfile.j2 +15 -0
- pygenkit/templates/docker/docker-compose.yml.j2 +17 -0
- pygenkit/templates/github/workflows/ci.yml.j2 +33 -0
- pygenkit/templates/github/workflows/publish-launchpad.yml.j2 +37 -0
- pygenkit/templates/github/workflows/publish-pypi.yml.j2 +32 -0
- pygenkit/templates/github/workflows/release.yml.j2 +28 -0
- pygenkit/templates/project/LICENSE.j2 +21 -0
- pygenkit/templates/project/README.md.j2 +21 -0
- pygenkit/templates/project/pyproject.toml.j2 +51 -0
- pygenkit/templates/project/src/__init__.py.j2 +2 -0
- pygenkit/templates/project/src/cli.py.j2 +5 -0
- pygenkit/templates/project/tests/__init__.py.j2 +0 -0
- pygenkit/templates/project/tests/test_cli.py.j2 +5 -0
- pygenkit/templates/python-cli/CHANGELOG.md.jinja +7 -0
- pygenkit/templates/python-cli/LICENSE.jinja +204 -0
- pygenkit/templates/python-cli/Makefile.jinja +31 -0
- pygenkit/templates/python-cli/README.md.jinja +86 -0
- pygenkit/templates/python-cli/debian/changelog.jinja +5 -0
- pygenkit/templates/python-cli/debian/control.jinja +23 -0
- pygenkit/templates/python-cli/debian/copyright.jinja +23 -0
- pygenkit/templates/python-cli/debian/install.jinja +1 -0
- pygenkit/templates/python-cli/debian/links.jinja +1 -0
- pygenkit/templates/python-cli/debian/postinst.jinja +15 -0
- pygenkit/templates/python-cli/debian/prerm.jinja +14 -0
- pygenkit/templates/python-cli/debian/rules.jinja +11 -0
- pygenkit/templates/python-cli/debian/source/options.jinja +2 -0
- pygenkit/templates/python-cli/debian/{{module_name}}.service.jinja +13 -0
- pygenkit/templates/python-cli/pygenkit.yaml.jinja +19 -0
- pygenkit/templates/python-cli/pyproject.toml.jinja +53 -0
- pygenkit/templates/python-cli/src/{{module_name}}/__init__.py.jinja +4 -0
- pygenkit/templates/python-cli/src/{{module_name}}/cli.py.jinja +28 -0
- pygenkit/templates/python-cli/tests/__init__.py +0 -0
- pygenkit/templates/python-cli/tests/test_cli.py.jinja +21 -0
- pygenkit/utils/__init__.py +4 -0
- pygenkit/utils/files.py +29 -0
- pygenkit/utils/filters.py +43 -0
- pygenkit/validators/__init__.py +3 -0
- pygenkit/validators/api.py +30 -0
- pygenkit/validators/security.py +102 -0
- pygenkit/validators/version.py +77 -0
- pygenkit/validators/workflow.py +141 -0
- pygenkit-0.2.0.dist-info/METADATA +350 -0
- pygenkit-0.2.0.dist-info/RECORD +95 -0
- pygenkit-0.2.0.dist-info/WHEEL +5 -0
- pygenkit-0.2.0.dist-info/entry_points.txt +2 -0
- pygenkit-0.2.0.dist-info/licenses/LICENSE +674 -0
- pygenkit-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""CLI entry point for {{ project.name }}."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
10
|
+
parser = argparse.ArgumentParser(prog="{{ module_name }}")
|
|
11
|
+
parser.add_argument(
|
|
12
|
+
"--version",
|
|
13
|
+
action="version",
|
|
14
|
+
version=f"%(prog)s {{ project.version }}",
|
|
15
|
+
)
|
|
16
|
+
return parser
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv: list[str] | None = None) -> int:
|
|
20
|
+
parser = build_parser()
|
|
21
|
+
args = parser.parse_args(argv)
|
|
22
|
+
_ = args
|
|
23
|
+
print("Hello from {{ project.name }}!")
|
|
24
|
+
return 0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
sys.exit(main())
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Tests for the CLI."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
7
|
+
SRC = ROOT / "src"
|
|
8
|
+
if str(SRC) not in sys.path:
|
|
9
|
+
sys.path.insert(0, str(SRC))
|
|
10
|
+
|
|
11
|
+
from {{ module_name }}.cli import main
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_help_returns_zero() -> None:
|
|
15
|
+
exit_code = main(["--help"])
|
|
16
|
+
assert exit_code == 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_version_output() -> None:
|
|
20
|
+
exit_code = main(["--version"])
|
|
21
|
+
assert exit_code == 0
|
pygenkit/utils/files.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FileUtils:
|
|
8
|
+
@staticmethod
|
|
9
|
+
def ensure_dir(path: str | Path) -> Path:
|
|
10
|
+
p = Path(path)
|
|
11
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
return p
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
def write_file(path: str | Path, content: str) -> Path:
|
|
16
|
+
p = Path(path)
|
|
17
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
p.write_text(content, encoding="utf-8")
|
|
19
|
+
return p
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def remove_tree(path: str | Path) -> None:
|
|
23
|
+
p = Path(path)
|
|
24
|
+
if p.exists():
|
|
25
|
+
shutil.rmtree(p)
|
|
26
|
+
|
|
27
|
+
@staticmethod
|
|
28
|
+
def copy_tree(src: str | Path, dst: str | Path) -> None:
|
|
29
|
+
shutil.copytree(Path(src), Path(dst), dirs_exist_ok=True)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TemplateFilters:
|
|
7
|
+
@staticmethod
|
|
8
|
+
def snake_case(value: str) -> str:
|
|
9
|
+
s = re.sub(r"[-\s]+", "_", value)
|
|
10
|
+
return s.lower().strip("_")
|
|
11
|
+
|
|
12
|
+
@staticmethod
|
|
13
|
+
def kebab_case(value: str) -> str:
|
|
14
|
+
s = re.sub(r"[_\s]+", "-", value)
|
|
15
|
+
return s.lower().strip("-")
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def pascal_case(value: str) -> str:
|
|
19
|
+
return "".join(word.capitalize() for word in re.split(r"[-_\\s]", value))
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def camel_case(value: str) -> str:
|
|
23
|
+
s = TemplateFilters.pascal_case(value)
|
|
24
|
+
return s[0].lower() + s[1:] if s else s
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def module_name(value: str) -> str:
|
|
28
|
+
return TemplateFilters.snake_case(value)
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def capitalise(value: str) -> str:
|
|
32
|
+
return value.capitalize()
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def get_filters() -> dict[str, object]:
|
|
36
|
+
return {
|
|
37
|
+
"snake_case": TemplateFilters.snake_case,
|
|
38
|
+
"kebab_case": TemplateFilters.kebab_case,
|
|
39
|
+
"pascal_case": TemplateFilters.pascal_case,
|
|
40
|
+
"camel_case": TemplateFilters.camel_case,
|
|
41
|
+
"module_name": TemplateFilters.module_name,
|
|
42
|
+
"capitalise": TemplateFilters.capitalise,
|
|
43
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pygenkit.validators import security as security_validator
|
|
6
|
+
from pygenkit.validators import version as version_validator
|
|
7
|
+
from pygenkit.validators import workflow as workflow_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_project(root: str | Path = ".") -> list[dict[str, object]]:
|
|
11
|
+
results: list[dict[str, object]] = []
|
|
12
|
+
|
|
13
|
+
_add_results(results, "version", version_validator.validate_version_consistency(root))
|
|
14
|
+
_add_results(results, "workflow", workflow_validator.validate_workflows(root))
|
|
15
|
+
_add_results(results, "security", security_validator.validate_security(root))
|
|
16
|
+
|
|
17
|
+
return results
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _add_results(
|
|
21
|
+
results: list[dict[str, object]],
|
|
22
|
+
category: str,
|
|
23
|
+
issues: list[str],
|
|
24
|
+
) -> None:
|
|
25
|
+
for issue in issues:
|
|
26
|
+
results.append({"category": category, "message": issue})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def has_errors(results: list[dict[str, object]]) -> bool:
|
|
30
|
+
return len(results) > 0
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def validate_security(root: str | Path = ".") -> list[str]:
|
|
10
|
+
issues: list[str] = []
|
|
11
|
+
wf_dir = Path(root) / ".github" / "workflows"
|
|
12
|
+
|
|
13
|
+
if not wf_dir.is_dir():
|
|
14
|
+
return []
|
|
15
|
+
|
|
16
|
+
for yml_file in sorted(wf_dir.glob("*.yml")):
|
|
17
|
+
issues.extend(_check_workflow_security(yml_file))
|
|
18
|
+
|
|
19
|
+
secrets_file = Path(root) / ".github" / "secrets"
|
|
20
|
+
if secrets_file.exists():
|
|
21
|
+
issues.append(".github/secrets file should not be versioned")
|
|
22
|
+
|
|
23
|
+
return issues
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _check_workflow_security(path: Path) -> list[str]:
|
|
27
|
+
issues: list[str] = []
|
|
28
|
+
name = path.name
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
raw = path.read_text(encoding="utf-8")
|
|
32
|
+
data = yaml.safe_load(raw)
|
|
33
|
+
except yaml.YAMLError:
|
|
34
|
+
return issues
|
|
35
|
+
|
|
36
|
+
if not isinstance(data, dict):
|
|
37
|
+
return issues
|
|
38
|
+
|
|
39
|
+
jobs = data.get("jobs", {})
|
|
40
|
+
if not isinstance(jobs, dict):
|
|
41
|
+
return issues
|
|
42
|
+
|
|
43
|
+
for job_id, job in jobs.items():
|
|
44
|
+
if not isinstance(job, dict):
|
|
45
|
+
continue
|
|
46
|
+
issues.extend(_check_job_permissions(name, job_id, job))
|
|
47
|
+
issues.extend(_check_job_secrets(name, job_id, job))
|
|
48
|
+
issues.extend(_check_job_environment(name, job_id, job))
|
|
49
|
+
|
|
50
|
+
return issues
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _check_job_permissions(wf_name: str, job_id: str, job: dict[str, Any]) -> list[str]:
|
|
54
|
+
issues: list[str] = []
|
|
55
|
+
prefix = f"{wf_name}/{job_id}"
|
|
56
|
+
|
|
57
|
+
if "permissions" not in job:
|
|
58
|
+
issues.append(
|
|
59
|
+
f"{prefix}: no explicit permissions block"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
steps = job.get("steps", [])
|
|
63
|
+
if isinstance(steps, list):
|
|
64
|
+
for i, step in enumerate(steps):
|
|
65
|
+
if isinstance(step, dict) and step.get("uses", "").startswith("actions/checkout"):
|
|
66
|
+
token = step.get("with", {}).get("token", "")
|
|
67
|
+
if token and "secrets." not in str(token):
|
|
68
|
+
issues.append(f"{prefix}/step{i}: hardcoded token in checkout action")
|
|
69
|
+
|
|
70
|
+
return issues
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _check_job_secrets(wf_name: str, job_id: str, job: dict[str, Any]) -> list[str]:
|
|
74
|
+
issues: list[str] = []
|
|
75
|
+
raw_yml = yaml.dump(job)
|
|
76
|
+
|
|
77
|
+
hardcoded_patterns = ["password=", "api_key=", "secret=", "token="]
|
|
78
|
+
for pattern in hardcoded_patterns:
|
|
79
|
+
if pattern in raw_yml.lower():
|
|
80
|
+
prefix = f"{wf_name}/{job_id}"
|
|
81
|
+
issues.append(f"{prefix}: possible hardcoded secret ('{pattern}') in workflow")
|
|
82
|
+
|
|
83
|
+
return issues
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _check_job_environment(wf_name: str, job_id: str, job: dict[str, Any]) -> list[str]:
|
|
87
|
+
issues: list[str] = []
|
|
88
|
+
env = job.get("environment")
|
|
89
|
+
|
|
90
|
+
if env and isinstance(env, str) and env == "pypi":
|
|
91
|
+
return issues
|
|
92
|
+
|
|
93
|
+
steps = job.get("steps", [])
|
|
94
|
+
if isinstance(steps, list):
|
|
95
|
+
for step in steps:
|
|
96
|
+
if isinstance(step, dict):
|
|
97
|
+
uses = step.get("uses", "")
|
|
98
|
+
if ("pypi-publish" in uses or "pypa/gh-action-pypi-publish" in uses) and not env:
|
|
99
|
+
prefix = f"{wf_name}/{job_id}"
|
|
100
|
+
issues.append(f"{prefix}: PyPI publish should use a protected environment")
|
|
101
|
+
|
|
102
|
+
return issues
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pygenkit.inspector import debian as debian_inspector
|
|
6
|
+
from pygenkit.inspector import detect as detect_inspector
|
|
7
|
+
from pygenkit.inspector import git as git_inspector
|
|
8
|
+
from pygenkit.inspector import pyproject as pyproject_inspector
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def validate_version_consistency(root: str | Path = ".") -> list[str]:
|
|
12
|
+
issues: list[str] = []
|
|
13
|
+
root = Path(root).resolve()
|
|
14
|
+
|
|
15
|
+
pyproject_ver = _get_pyproject_version(root)
|
|
16
|
+
init_ver = _get_init_version(root)
|
|
17
|
+
changelog_ver = _get_changelog_version(root)
|
|
18
|
+
latest_tag = _get_latest_tag(root)
|
|
19
|
+
|
|
20
|
+
if pyproject_ver and init_ver and pyproject_ver != init_ver:
|
|
21
|
+
issues.append(
|
|
22
|
+
f"pyproject.toml: {pyproject_ver} != src/.../__init__.py: {init_ver}"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if pyproject_ver and changelog_ver and pyproject_ver != changelog_ver:
|
|
26
|
+
issues.append(
|
|
27
|
+
f"pyproject.toml: {pyproject_ver} != debian/changelog: {changelog_ver}"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if latest_tag and pyproject_ver and latest_tag != pyproject_ver:
|
|
31
|
+
issues.append(
|
|
32
|
+
f"latest git tag v{latest_tag} != pyproject.toml: {pyproject_ver}"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
return issues
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _get_pyproject_version(root: Path) -> str | None:
|
|
39
|
+
try:
|
|
40
|
+
data = pyproject_inspector.read_pyproject(root)
|
|
41
|
+
return pyproject_inspector.detect_project_version(data)
|
|
42
|
+
except (FileNotFoundError, Exception):
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_init_version(root: Path) -> str | None:
|
|
47
|
+
try:
|
|
48
|
+
data = pyproject_inspector.read_pyproject(root)
|
|
49
|
+
name = pyproject_inspector.detect_project_name(data)
|
|
50
|
+
if name:
|
|
51
|
+
module = detect_inspector.detect_module(root, name)
|
|
52
|
+
else:
|
|
53
|
+
module = detect_inspector.detect_module(root)
|
|
54
|
+
if module:
|
|
55
|
+
return detect_inspector.detect_version_in_init(root, module)
|
|
56
|
+
except (FileNotFoundError, Exception):
|
|
57
|
+
pass
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _get_changelog_version(root: Path) -> str | None:
|
|
62
|
+
try:
|
|
63
|
+
info = debian_inspector.inspect_debian(root)
|
|
64
|
+
return info.version_in_changelog
|
|
65
|
+
except Exception:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _get_latest_tag(root: Path) -> str | None:
|
|
70
|
+
try:
|
|
71
|
+
tags = git_inspector.detect_git_tags(root)
|
|
72
|
+
for t in tags:
|
|
73
|
+
if t.startswith("v"):
|
|
74
|
+
return t.lstrip("v")
|
|
75
|
+
return tags[0] if tags else None
|
|
76
|
+
except Exception:
|
|
77
|
+
return None
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def validate_workflows(root: str | Path = ".") -> list[str]:
|
|
10
|
+
issues: list[str] = []
|
|
11
|
+
wf_dir = Path(root) / ".github" / "workflows"
|
|
12
|
+
|
|
13
|
+
if not wf_dir.is_dir():
|
|
14
|
+
return []
|
|
15
|
+
|
|
16
|
+
for yml_file in sorted(wf_dir.glob("*.yml")):
|
|
17
|
+
issues.extend(_validate_single_workflow(yml_file))
|
|
18
|
+
|
|
19
|
+
return issues
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _validate_single_workflow(path: Path) -> list[str]:
|
|
23
|
+
issues: list[str] = []
|
|
24
|
+
name = path.name
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
raw = path.read_text(encoding="utf-8")
|
|
28
|
+
data = yaml.safe_load(raw)
|
|
29
|
+
except yaml.YAMLError as exc:
|
|
30
|
+
issues.append(f"{name}: invalid YAML — {exc}")
|
|
31
|
+
return issues
|
|
32
|
+
|
|
33
|
+
if not isinstance(data, dict):
|
|
34
|
+
issues.append(f"{name}: not a mapping")
|
|
35
|
+
return issues
|
|
36
|
+
|
|
37
|
+
if "name" not in data:
|
|
38
|
+
issues.append(f"{name}: missing 'name' field")
|
|
39
|
+
|
|
40
|
+
triggers = _collect_triggers(data)
|
|
41
|
+
if not triggers:
|
|
42
|
+
issues.append(f"{name}: no triggers defined (on:)")
|
|
43
|
+
|
|
44
|
+
jobs = data.get("jobs", {})
|
|
45
|
+
if not jobs:
|
|
46
|
+
issues.append(f"{name}: no jobs defined")
|
|
47
|
+
return issues
|
|
48
|
+
|
|
49
|
+
for job_id, job in jobs.items():
|
|
50
|
+
if not isinstance(job, dict):
|
|
51
|
+
continue
|
|
52
|
+
issues.extend(_validate_job(name, job_id, job))
|
|
53
|
+
|
|
54
|
+
return issues
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _collect_triggers(data: dict[str, Any]) -> list[str]:
|
|
58
|
+
triggers: list[str] = []
|
|
59
|
+
on = data.get("on", {})
|
|
60
|
+
if isinstance(on, str):
|
|
61
|
+
triggers.append(on)
|
|
62
|
+
elif isinstance(on, dict):
|
|
63
|
+
triggers.extend(on.keys())
|
|
64
|
+
elif isinstance(on, list):
|
|
65
|
+
triggers.extend(str(t) for t in on)
|
|
66
|
+
return triggers
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _validate_job(wf_name: str, job_id: str, job: dict[str, Any]) -> list[str]:
|
|
70
|
+
issues: list[str] = []
|
|
71
|
+
prefix = f"{wf_name}/{job_id}"
|
|
72
|
+
|
|
73
|
+
if "runs-on" not in job:
|
|
74
|
+
issues.append(f"{prefix}: missing runs-on")
|
|
75
|
+
|
|
76
|
+
steps = job.get("steps", [])
|
|
77
|
+
if not steps:
|
|
78
|
+
issues.append(f"{prefix}: no steps")
|
|
79
|
+
return issues
|
|
80
|
+
|
|
81
|
+
for i, step in enumerate(steps):
|
|
82
|
+
if not isinstance(step, dict):
|
|
83
|
+
continue
|
|
84
|
+
if "uses" in step:
|
|
85
|
+
issues.extend(_check_action_version(prefix, i, step["uses"]))
|
|
86
|
+
if "run" in step:
|
|
87
|
+
issues.extend(_check_shell_injection(prefix, i, step["run"]))
|
|
88
|
+
|
|
89
|
+
return issues
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _check_action_version(prefix: str, step_idx: int, uses: str) -> list[str]:
|
|
93
|
+
issues: list[str] = []
|
|
94
|
+
if not isinstance(uses, str):
|
|
95
|
+
return issues
|
|
96
|
+
|
|
97
|
+
if "@" not in uses:
|
|
98
|
+
issues.append(f"{prefix}/step{step_idx}: action '{uses}' has no version pin")
|
|
99
|
+
return issues
|
|
100
|
+
|
|
101
|
+
action, version = uses.rsplit("@", 1)
|
|
102
|
+
known_outdated = {
|
|
103
|
+
"actions/checkout@v4": "use actions/checkout@v6 or newer",
|
|
104
|
+
"actions/checkout@v3": "use actions/checkout@v6 or newer",
|
|
105
|
+
"actions/checkout@v2": "use actions/checkout@v6 or newer",
|
|
106
|
+
"actions/setup-python@v5": "use actions/setup-python@v6 or newer",
|
|
107
|
+
"actions/setup-python@v4": "use actions/setup-python@v6 or newer",
|
|
108
|
+
"actions/setup-python@v3": "use actions/setup-python@v6 or newer",
|
|
109
|
+
"actions/upload-artifact@v3": "use actions/upload-artifact@v4 or newer",
|
|
110
|
+
"actions/download-artifact@v3": "use actions/download-artifact@v4 or newer",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
full = f"{action}@{version}"
|
|
114
|
+
if full in known_outdated:
|
|
115
|
+
issues.append(f"{prefix}/step{step_idx}: {full} is outdated — {known_outdated[full]}")
|
|
116
|
+
|
|
117
|
+
if not _is_sha_pin(version):
|
|
118
|
+
issues.append(
|
|
119
|
+
f"{prefix}/step{step_idx}: '{full}' uses tag '{version}' instead of SHA pin"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return issues
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _is_sha_pin(version: str) -> bool:
|
|
126
|
+
return len(version) == 40 and all(c in "0123456789abcdef" for c in version)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _check_shell_injection(prefix: str, step_idx: int, run: str) -> list[str]:
|
|
130
|
+
issues: list[str] = []
|
|
131
|
+
if not isinstance(run, str):
|
|
132
|
+
return issues
|
|
133
|
+
|
|
134
|
+
dangerous = ["${{ github.event.issue.title }}", "${{ github.event.pull_request.title }}"]
|
|
135
|
+
for pattern in dangerous:
|
|
136
|
+
if pattern in run:
|
|
137
|
+
issues.append(
|
|
138
|
+
f"{prefix}/step{step_idx}: possible script injection via {pattern}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return issues
|