ci: add GitHub Actions workflow - LaTeX smoke compiles, skill lint, CLI typechecks, placeholder integrity (#59)

Every PR to this repo is currently verified by hand. This adds the checks
a machine can do:

- latex-smoke: compiles cv/main_example.tex (lualatex) and the new
  cover_letters/cover_example.tex (xelatex) in the texlive/texlive
  container, failing on any LaTeX error. Exact page-count assertions
  (CV=2, cover letter=1) run on the upstream repo only
- lint (tools/lint_skills.py, also runnable locally): every SKILL.md has
  parseable YAML frontmatter with name+description (frontmatter breakage
  happened before - 37a0eed), allowed-tools 'bun run <path>' targets
  exist, command files start with a '# /<name>' title, settings.json is
  valid JSON with a permissions.allow list
- cli-typecheck: bun install + tsc --noEmit for all five portal CLIs
  (matrix, fail-fast off)
- placeholder-integrity (upstream only): tracked template files still
  carry their placeholder tokens, catching accidental personal-data
  commits before they land

Fork-friendly by design: /setup personalizes CLAUDE.md, the skill files,
and main_example.tex in forks, so placeholder checks and exact page
counts are guarded with github.repository == upstream; compile success
and lint run everywhere. Live CLI smoke tests are deliberately excluded:
network-flaky, and linkedin-search is personal-use-only per its own ToS
warning - CI-automated requests would violate it. CLIs are typechecked
instead.

The cover letter previously had no tracked example (cover_*.tex is
gitignored), so cover_example.tex is new: a placeholder letter following
the documented 06 structure, demonstrating the correct itemize-outside-
lettercontent pattern. It doubles as the structural reference /apply
Step 2 looks for on fresh clones, which until now matched nothing. The
gitignore exception is ordered after Cover_*.tex because case-insensitive
filesystems match that pattern against cover_example.tex too.

Writing it surfaced a latent bug in the documented template itself:
06-cover-letter-templates.md's structure ends with \closing{Kind
regards,\} - but cover.cls appends its own \, and the doubled break
produces '! LaTeX Error: There's no line here to end.' on every compile
(nonstopmode swallows it, so it went unnoticed). Fixed in 06 and noted
in the example.
This commit is contained in:
Ayobami Adegoke
2026-07-08 17:09:33 +02:00
committed by GitHub
parent 75fbfc56e4
commit 6477987608
6 changed files with 285 additions and 1 deletions
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Lint the repo's skill, command, and settings files.
Run from anywhere: python tools/lint_skills.py
Checks:
- Every SKILL.md (.claude/skills/*, .agents/skills/*) has YAML frontmatter that
parses, with non-empty `name` and `description` keys
- `allowed-tools` entries of the form `Bash(bun run <path> *)` point at files
that exist (skill paths resolve relative to the repo root and to .agents/)
- Every .claude/commands/*.md starts with a `# /<name>` title
- .claude/settings.json is valid JSON with a permissions.allow list
Exit code 0 on success, 1 with a failure list otherwise.
"""
import json
import re
import sys
from pathlib import Path
try:
import yaml
except ImportError:
sys.exit("lint_skills.py requires PyYAML: pip install pyyaml")
ROOT = Path(__file__).resolve().parent.parent
errors: list[str] = []
def rel(path: Path) -> str:
return str(path.relative_to(ROOT))
def check_skill(path: Path) -> None:
text = path.read_text(encoding="utf-8")
if not text.startswith("---\n"):
errors.append(f"{rel(path)}: missing YAML frontmatter (file must start with ---)")
return
end = text.find("\n---", 4)
if end == -1:
errors.append(f"{rel(path)}: unterminated YAML frontmatter")
return
try:
data = yaml.safe_load(text[4:end])
except yaml.YAMLError as exc:
errors.append(f"{rel(path)}: frontmatter is not valid YAML: {exc}")
return
if not isinstance(data, dict):
errors.append(f"{rel(path)}: frontmatter did not parse to a mapping")
return
for key in ("name", "description"):
if not data.get(key):
errors.append(f"{rel(path)}: frontmatter missing required key '{key}'")
allowed = data.get("allowed-tools", "")
if isinstance(allowed, str):
for match in re.finditer(r"bun run ([^\s*)]+)", allowed):
target = match.group(1)
candidates = [ROOT / target, ROOT / ".agents" / target]
if not any(c.is_file() for c in candidates):
errors.append(f"{rel(path)}: allowed-tools references a missing file: {target}")
def check_command(path: Path) -> None:
lines = path.read_text(encoding="utf-8").lstrip().splitlines()
first = lines[0] if lines else ""
if not first.startswith("# /"):
errors.append(f"{rel(path)}: command file must start with a '# /<name>' title (found: {first[:50]!r})")
def check_settings() -> None:
path = ROOT / ".claude" / "settings.json"
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
errors.append(f".claude/settings.json: {exc}")
return
if not isinstance(data.get("permissions", {}).get("allow"), list):
errors.append(".claude/settings.json: expected permissions.allow to be a list")
def main() -> int:
skills = sorted(ROOT.glob(".claude/skills/*/SKILL.md")) + sorted(ROOT.glob(".agents/skills/*/SKILL.md"))
commands = sorted((ROOT / ".claude" / "commands").glob("*.md"))
if not skills:
errors.append("no SKILL.md files found - glob roots are wrong or the tree moved")
if not commands:
errors.append("no command files found under .claude/commands/")
for skill in skills:
check_skill(skill)
for command in commands:
check_command(command)
check_settings()
if errors:
print(f"lint_skills: {len(errors)} failure(s)")
for err in errors:
print(f" - {err}")
return 1
print(f"lint_skills: OK ({len(skills)} skills, {len(commands)} commands, settings.json)")
return 0
if __name__ == "__main__":
sys.exit(main())