Skip to content

Commit 2e8ed94

Browse files
committed
feat: add self-dogfooding via released scripts
- Add .github/scripts/ seeded from scripts/ — template-ci.yml now runs quality gates from these released copies instead of the development source - Add auto-release.yml: auto-creates a patch release when scripts/ changes land on main - Add self-update.yml: nightly check downloads released scripts into .github/scripts/ and opens a PR when a new release is detected - Update template-ci.yml and composite action to reference .github/scripts/ - Document the dogfooding model in PLAN.md This ensures the template validates itself with the same artifacts it ships to downstream repos, catching regressions before they propagate.
1 parent 2cd0bf6 commit 2e8ed94

15 files changed

Lines changed: 963 additions & 10 deletions

File tree

.github/actions/setup-python/action.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ runs:
2727
- name: Create venv and install package (Linux/macOS)
2828
if: runner.os != 'Windows'
2929
shell: bash
30-
run: bash scripts/setup.sh
30+
run: bash .github/scripts/setup.sh
3131

3232
- name: Create venv and install package (Windows)
3333
if: runner.os == 'Windows'
3434
shell: pwsh
35-
run: .\scripts\setup.ps1
35+
run: .\.github\scripts\setup.ps1
3636

3737
- name: Activate venv for subsequent steps (Linux/macOS)
3838
if: runner.os != 'Windows'

.github/scripts/.version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
none

.github/scripts/check_lint.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#!/usr/bin/env python3
2+
# Managed by nwarila/python-template — do not edit manually.
3+
# Source: https://github.com/nwarila/python-template
4+
5+
from __future__ import annotations
6+
7+
import argparse
8+
import os
9+
import subprocess
10+
import sys
11+
import tomllib
12+
from pathlib import Path
13+
from typing import Any
14+
15+
16+
def _load_pyproject() -> dict[str, Any]:
17+
path = Path("pyproject.toml")
18+
if not path.exists():
19+
return {}
20+
with open(path, "rb") as f:
21+
return tomllib.load(f)
22+
23+
24+
def _tool(name: str) -> str:
25+
exe_dir = Path(sys.executable).resolve().parent
26+
candidates = [exe_dir / name, exe_dir / f"{name}.exe"]
27+
for candidate in candidates:
28+
if candidate.exists():
29+
return str(candidate)
30+
return name
31+
32+
33+
def _run(cmd: list[str], label: str) -> int:
34+
print(f"\n--- {label} ---")
35+
result = subprocess.run(cmd)
36+
if result.returncode != 0 and os.environ.get("GITHUB_ACTIONS") == "true":
37+
print(f"::error::{label} failed with exit code {result.returncode}")
38+
return result.returncode
39+
40+
41+
def main() -> int:
42+
parser = argparse.ArgumentParser(description="Run ruff lint and format checks.")
43+
parser.add_argument("--fix", action="store_true", help="Auto-fix lint issues and reformat")
44+
parser.add_argument("--paths", nargs="+", help="Override source paths to check")
45+
args = parser.parse_args()
46+
47+
pyproject = _load_pyproject()
48+
paths = args.paths or pyproject.get("tool", {}).get("ruff", {}).get("src", ["src"])
49+
50+
if args.fix:
51+
rc1 = _run([_tool("ruff"), "check", "--fix", *paths], "Ruff Fix")
52+
rc2 = _run([_tool("ruff"), "format", *paths], "Ruff Format")
53+
else:
54+
rc1 = _run([_tool("ruff"), "check", *paths], "Ruff Check")
55+
rc2 = _run([_tool("ruff"), "format", "--check", *paths], "Ruff Format Check")
56+
57+
return 1 if (rc1 or rc2) else 0
58+
59+
60+
if __name__ == "__main__":
61+
sys.exit(main())

.github/scripts/check_package.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
#!/usr/bin/env python3
2+
# Managed by nwarila/python-template — do not edit manually.
3+
# Source: https://github.com/nwarila/python-template
4+
5+
from __future__ import annotations
6+
7+
import argparse
8+
import glob
9+
import os
10+
import shutil
11+
import subprocess
12+
import sys
13+
import tomllib
14+
from pathlib import Path
15+
from typing import Any
16+
17+
18+
def _load_pyproject() -> dict[str, Any]:
19+
path = Path("pyproject.toml")
20+
if not path.exists():
21+
return {}
22+
with open(path, "rb") as f:
23+
return tomllib.load(f)
24+
25+
26+
def _tool(name: str) -> str:
27+
exe_dir = Path(sys.executable).resolve().parent
28+
candidates = [exe_dir / name, exe_dir / f"{name}.exe"]
29+
for candidate in candidates:
30+
if candidate.exists():
31+
return str(candidate)
32+
return name
33+
34+
35+
def _run(cmd: list[str], label: str) -> int:
36+
print(f"\n--- {label} ---")
37+
result = subprocess.run(cmd)
38+
if result.returncode != 0 and os.environ.get("GITHUB_ACTIONS") == "true":
39+
print(f"::error::{label} failed with exit code {result.returncode}")
40+
return result.returncode
41+
42+
43+
def _cleanup() -> None:
44+
shutil.rmtree("dist", ignore_errors=True)
45+
for egg_dir in glob.glob("*.egg-info"):
46+
shutil.rmtree(egg_dir, ignore_errors=True)
47+
for egg_dir in glob.glob("src/*.egg-info"):
48+
shutil.rmtree(egg_dir, ignore_errors=True)
49+
50+
51+
def main() -> int:
52+
argparse.ArgumentParser(description="Validate package build, metadata, and entry points.").parse_args()
53+
54+
pyproject = _load_pyproject()
55+
56+
if "build-system" not in pyproject:
57+
print("No [build-system] found, skipping package check")
58+
return 0
59+
60+
entry_points = pyproject.get("project", {}).get("scripts", {})
61+
62+
try:
63+
rc = _run([_tool("validate-pyproject"), "pyproject.toml"], "Validate pyproject.toml")
64+
if rc != 0:
65+
return rc
66+
67+
rc = _run([sys.executable, "-m", "build"], "Build sdist+wheel")
68+
if rc != 0:
69+
return rc
70+
71+
dist_files = glob.glob("dist/*")
72+
if not dist_files:
73+
is_ci = os.environ.get("GITHUB_ACTIONS") == "true"
74+
print("::error::No dist files produced" if is_ci else "ERROR: No dist files produced")
75+
return 1
76+
77+
rc = _run([_tool("twine"), "check", "--strict", *dist_files], "Twine Check")
78+
if rc != 0:
79+
return rc
80+
81+
for name in entry_points:
82+
tool_path = shutil.which(name) or _tool(name)
83+
if shutil.which(name) is None and tool_path == name:
84+
print(f" Entry point '{name}' not found on PATH, skipping smoke test")
85+
continue
86+
rc = _run([tool_path, "--help"], f"Entry point: {name} --help")
87+
if rc != 0:
88+
return rc
89+
90+
finally:
91+
_cleanup()
92+
93+
return 0
94+
95+
96+
if __name__ == "__main__":
97+
sys.exit(main())

.github/scripts/check_security.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
#!/usr/bin/env python3
2+
# Managed by nwarila/python-template — do not edit manually.
3+
# Source: https://github.com/nwarila/python-template
4+
5+
from __future__ import annotations
6+
7+
import argparse
8+
import os
9+
import subprocess
10+
import sys
11+
from pathlib import Path
12+
13+
14+
def _tool(name: str) -> str:
15+
exe_dir = Path(sys.executable).resolve().parent
16+
candidates = [exe_dir / name, exe_dir / f"{name}.exe"]
17+
for candidate in candidates:
18+
if candidate.exists():
19+
return str(candidate)
20+
return name
21+
22+
23+
def _run(cmd: list[str], label: str) -> int:
24+
print(f"\n--- {label} ---")
25+
result = subprocess.run(cmd)
26+
if result.returncode != 0 and os.environ.get("GITHUB_ACTIONS") == "true":
27+
print(f"::error::{label} failed with exit code {result.returncode}")
28+
return result.returncode
29+
30+
31+
def main() -> int:
32+
argparse.ArgumentParser(description="Run pip-audit for dependency vulnerability scanning.").parse_args()
33+
34+
return _run([_tool("pip-audit"), "--skip-editable"], "Pip-Audit")
35+
36+
37+
if __name__ == "__main__":
38+
sys.exit(main())

.github/scripts/check_spelling.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/usr/bin/env python3
2+
# Managed by nwarila/python-template — do not edit manually.
3+
# Source: https://github.com/nwarila/python-template
4+
5+
from __future__ import annotations
6+
7+
import argparse
8+
import os
9+
import subprocess
10+
import sys
11+
from pathlib import Path
12+
13+
14+
def _tool(name: str) -> str:
15+
exe_dir = Path(sys.executable).resolve().parent
16+
candidates = [exe_dir / name, exe_dir / f"{name}.exe"]
17+
for candidate in candidates:
18+
if candidate.exists():
19+
return str(candidate)
20+
return name
21+
22+
23+
def _run(cmd: list[str], label: str) -> int:
24+
print(f"\n--- {label} ---")
25+
result = subprocess.run(cmd)
26+
if result.returncode != 0 and os.environ.get("GITHUB_ACTIONS") == "true":
27+
print(f"::error::{label} failed with exit code {result.returncode}")
28+
return result.returncode
29+
30+
31+
def main() -> int:
32+
parser = argparse.ArgumentParser(description="Run codespell for typo detection.")
33+
parser.add_argument("--fix", action="store_true", help="Auto-fix spelling mistakes")
34+
args = parser.parse_args()
35+
36+
cmd = [_tool("codespell")]
37+
if args.fix:
38+
cmd.append("--write-changes")
39+
40+
return _run(cmd, "Codespell")
41+
42+
43+
if __name__ == "__main__":
44+
sys.exit(main())

.github/scripts/check_tests.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#!/usr/bin/env python3
2+
# Managed by nwarila/python-template — do not edit manually.
3+
# Source: https://github.com/nwarila/python-template
4+
5+
from __future__ import annotations
6+
7+
import argparse
8+
import json
9+
import os
10+
import subprocess
11+
import sys
12+
from pathlib import Path
13+
14+
15+
def _tool(name: str) -> str:
16+
exe_dir = Path(sys.executable).resolve().parent
17+
candidates = [exe_dir / name, exe_dir / f"{name}.exe"]
18+
for candidate in candidates:
19+
if candidate.exists():
20+
return str(candidate)
21+
return name
22+
23+
24+
def _run(cmd: list[str], label: str) -> int:
25+
print(f"\n--- {label} ---")
26+
result = subprocess.run(cmd)
27+
if result.returncode != 0 and os.environ.get("GITHUB_ACTIONS") == "true":
28+
print(f"::error::{label} failed with exit code {result.returncode}")
29+
return result.returncode
30+
31+
32+
def _write_coverage_summary() -> None:
33+
coverage_path = Path("coverage.json")
34+
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
35+
if not coverage_path.exists() or not summary_path:
36+
return
37+
38+
with open(coverage_path) as f:
39+
data = json.load(f)
40+
41+
lines = [
42+
"## Coverage Summary",
43+
"",
44+
"| Module | Statements | Missed | Coverage |",
45+
"|--------|-----------|--------|----------|",
46+
]
47+
48+
files = data.get("files", {})
49+
for module, info in sorted(files.items()):
50+
summary = info.get("summary", {})
51+
stmts = summary.get("num_statements", 0)
52+
missed = summary.get("missing_lines", 0)
53+
covered = summary.get("percent_covered", 0.0)
54+
lines.append(f"| {module} | {stmts} | {missed} | {covered:.1f}% |")
55+
56+
totals = data.get("totals", {})
57+
total_stmts = totals.get("num_statements", 0)
58+
total_missed = totals.get("missing_lines", 0)
59+
total_covered = totals.get("percent_covered", 0.0)
60+
lines.append(f"| **Total** | **{total_stmts}** | **{total_missed}** | **{total_covered:.1f}%** |")
61+
62+
with open(summary_path, "a") as f:
63+
f.write("\n".join(lines) + "\n")
64+
65+
coverage_path.unlink()
66+
67+
68+
def main() -> int:
69+
argparse.ArgumentParser(description="Run pytest with coverage.").parse_args()
70+
71+
is_ci = os.environ.get("GITHUB_ACTIONS") == "true"
72+
73+
cmd = [_tool("pytest")]
74+
if is_ci:
75+
cmd.append("--cov-report=json:coverage.json")
76+
77+
rc = _run(cmd, "Pytest")
78+
79+
if is_ci:
80+
_write_coverage_summary()
81+
82+
return rc
83+
84+
85+
if __name__ == "__main__":
86+
sys.exit(main())

0 commit comments

Comments
 (0)