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
@@ -99,7 +99,9 @@ The font wrapper is mandatory — if you just move `\begin{itemize}` outside `\l
\lettercontent{I look forward to hearing from you.} \lettercontent{I look forward to hearing from you.}
\begin{flushright} \begin{flushright}
\closing{Kind regards,\\} % No trailing \\ inside \closing{} - cover.cls appends its own \\, and a
% doubled break triggers "! LaTeX Error: There's no line here to end."
\closing{Kind regards,}
\signature{[YOUR_NAME]} \signature{[YOUR_NAME]}
\end{flushright} \end{flushright}
+108
View File
@@ -0,0 +1,108 @@
# CI for the framework itself: LaTeX smoke compiles, skill/command lint,
# CLI typechecks, and (upstream only) placeholder integrity.
#
# Fork-friendly by design: forks personalize CLAUDE.md, the skill files, and
# cv/main_example.tex via /setup, so the placeholder-integrity job and the
# exact page-count assertions run only on the upstream template repo. Compile
# success and lint correctness are asserted everywhere.
#
# Deliberately NOT here: live smoke tests of the job-portal CLIs. They hit
# real portals (network-flaky, and the linkedin-search skill is personal-use
# only per its own ToS warning - CI-automated requests would violate that).
# CLIs get typechecked instead; live testing stays a local, on-demand step.
name: CI
on:
push:
branches: [master]
pull_request:
workflow_dispatch:
jobs:
lint:
name: Lint skills, commands, settings
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install pyyaml
- run: python tools/lint_skills.py
latex-smoke:
name: Compile example CV and cover letter
runs-on: ubuntu-latest
container: texlive/texlive:latest
steps:
- uses: actions/checkout@v4
- name: Compile CV example (lualatex)
run: |
cd cv
lualatex -interaction=nonstopmode main_example.tex
test -f main_example.pdf
if grep -q '^!' main_example.log; then
echo '::error::lualatex reported errors compiling cv/main_example.tex'
grep -A3 '^!' main_example.log
exit 1
fi
- name: Compile cover letter example (xelatex)
run: |
cd cover_letters
xelatex -interaction=nonstopmode cover_example.tex
test -f cover_example.pdf
if grep -q '^!' cover_example.log; then
echo '::error::xelatex reported errors compiling cover_letters/cover_example.tex'
grep -A3 '^!' cover_example.log
exit 1
fi
- name: Assert exact page counts (upstream template only)
if: github.repository == 'MadsLorentzen/ai-job-search'
run: |
grep -q 'Output written on main_example.pdf (2 pages' cv/main_example.log \
|| { echo '::error::cv/main_example.tex no longer compiles to exactly 2 pages'; exit 1; }
grep -q 'Output written on cover_example.pdf (1 page' cover_letters/cover_example.log \
|| { echo '::error::cover_letters/cover_example.tex no longer compiles to exactly 1 page'; exit 1; }
cli-typecheck:
name: Typecheck ${{ matrix.tool }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
tool:
- jobbank-search
- jobdanmark-search
- jobindex-search
- jobnet-search
- linkedin-search
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install
working-directory: .agents/skills/${{ matrix.tool }}/cli
- run: bun run typecheck
working-directory: .agents/skills/${{ matrix.tool }}/cli
placeholder-integrity:
name: Placeholder integrity (upstream template only)
if: github.repository == 'MadsLorentzen/ai-job-search'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Tracked template files must keep their placeholder tokens
run: |
fail=0
check() {
if ! grep -q "$2" "$1"; then
echo "::error file=$1::expected placeholder token $2 - personal data may have been committed"
fail=1
fi
}
check CLAUDE.md '\[YOUR_NAME\]'
check cv/main_example.tex '\[YOUR_NAME\]'
check cover_letters/cover_example.tex '\[YOUR NAME\]'
check .claude/skills/job-application-assistant/01-candidate-profile.md '<!-- SETUP'
check .claude/skills/job-application-assistant/04-job-evaluation.md '\[YOUR_PRIMARY_SKILLS\]'
exit $fail
+1
View File
@@ -51,6 +51,7 @@ cv/main_*.tex
cv/*.txt cv/*.txt
cover_letters/cover_*.tex cover_letters/cover_*.tex
cover_letters/Cover_*.tex cover_letters/Cover_*.tex
!cover_letters/cover_example.tex
# documents/ subfolder contents are personal — only README and folder structure are tracked # documents/ subfolder contents are personal — only README and folder structure are tracked
documents/cv/** documents/cv/**
+5
View File
@@ -4,6 +4,8 @@
# AI Job Search # AI Job Search
[![CI](https://github.com/MadsLorentzen/ai-job-search/actions/workflows/ci.yml/badge.svg)](https://github.com/MadsLorentzen/ai-job-search/actions/workflows/ci.yml)
An AI-powered job application framework built on [Claude Code](https://claude.com/claude-code). Fork it, fill in your profile, and let Claude evaluate job postings, tailor your CV, write cover letters, and prepare you for interviews. An AI-powered job application framework built on [Claude Code](https://claude.com/claude-code). Fork it, fill in your profile, and let Claude evaluate job postings, tailor your CV, write cover letters, and prepare you for interviews.
> Note: This is an independent open-source project and is not affiliated with, endorsed by, sponsored by, or maintained by Anthropic. Anthropic and Claude Code are referenced only to describe the toolchain this workflow uses. > Note: This is an independent open-source project and is not affiliated with, endorsed by, sponsored by, or maintained by Anthropic. Anthropic and Claude Code are referenced only to describe the toolchain this workflow uses.
@@ -150,6 +152,7 @@ ai-job-search/
│ └── main_example.tex # moderncv LaTeX template │ └── main_example.tex # moderncv LaTeX template
├── cover_letters/ ├── cover_letters/
│ ├── cover.cls # Custom cover letter LaTeX class │ ├── cover.cls # Custom cover letter LaTeX class
│ ├── cover_example.tex # Example cover letter (structural reference + CI smoke test)
│ └── OpenFonts/ # Lato + Raleway fonts │ └── OpenFonts/ # Lato + Raleway fonts
├── templates/ # Custom templates registered via /add-template ├── templates/ # Custom templates registered via /add-template
│ └── README.md # Folder layout instructions │ └── README.md # Folder layout instructions
@@ -160,9 +163,11 @@ ai-job-search/
│ ├── diplomas/ # Degree certificates and transcripts │ ├── diplomas/ # Degree certificates and transcripts
│ ├── references/ # Reference letters │ ├── references/ # Reference letters
│ └── applications/ # Past application records (<company>_<role>/) │ └── applications/ # Past application records (<company>_<role>/)
├── .github/workflows/ci.yml # CI: LaTeX smoke compiles, skill lint, CLI typechecks
├── salary_lookup.py # Salary benchmarking tool (BYO data) ├── salary_lookup.py # Salary benchmarking tool (BYO data)
├── tools/ ├── tools/
│ ├── convert_salary_excel.py # Convert salary Excel to JSON │ ├── convert_salary_excel.py # Convert salary Excel to JSON
│ ├── lint_skills.py # CI lint for skills, commands, settings.json
│ └── README_SALARY_TOOL.md # Salary tool setup instructions │ └── README_SALARY_TOOL.md # Salary tool setup instructions
├── job_scraper/ # Scraper state (seen jobs, results) ├── job_scraper/ # Scraper state (seen jobs, results)
├── upskill/ # /upskill report output (markdown reports per run) ├── upskill/ # /upskill report output (markdown reports per run)
+61
View File
@@ -0,0 +1,61 @@
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
% Cover Letter - [Company], [Role]
%
% Example cover letter with placeholder content. This is the structural
% reference for /apply (see 06-cover-letter-templates.md) and the CI smoke
% test for cover.cls: it must always compile with xelatex to exactly 1 page.
% It demonstrates the correct itemize pattern: the list sits OUTSIDE
% \lettercontent{} (whose trailing \\ errors on \end{itemize}) and is
% wrapped in Raleway-Medium so the bullet font matches the body.
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
\documentclass[]{cover}
\usepackage{fancyhdr}
\pagestyle{fancy}
\fancyhf{}
\rfoot{Page \thepage \hspace{0pt}}
\thispagestyle{empty}
\renewcommand{\headrulewidth}{0pt}
\begin{document}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
% TITLE NAME
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
\namesection{}{\Huge{[YOUR NAME]}}{ \href{mailto:your.email@example.com}{your.email@example.com} | [+XX XXXXXXXXXX] | \urlstyle{same}\href{https://www.linkedin.com/in/yourprofile}{LinkedIn}
}
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
% MAIN COVER LETTER CONTENT
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
\currentdate{\today}
\lettercontent{Dear [Hiring Manager / Team],}
\lettercontent{[Opening paragraph: name the role and where you found it, state your strongest connection to it in one sentence, and preview why you are a fit. Keep it to 2--3 sentences.]}
\lettercontent{[Body paragraph: your most relevant experience, framed toward the tasks in the posting. Follow with 3--5 concrete bullets:]}
{\raggedright\fontspec[Path = OpenFonts/fonts/raleway/]{Raleway-Medium}\fontsize{11pt}{13pt}\selectfont
\begin{itemize}
\item \textbf{[Achievement 1]:} [concrete result with a number where possible]
\item \textbf{[Achievement 2]:} [skill or project mapped to a posting requirement]
\item \textbf{[Achievement 3]:} [evidence for a nice-to-have requirement]
\end{itemize}\par}
\vspace{6pt}
\lettercontent{[Connection paragraph: why this company specifically. Reference a verified specific: a product, a stated priority, a team. Never generic.]}
\lettercontent{[Personal fit paragraph: behavioral strengths and what you bring to the team, 2--3 sentences.]}
\lettercontent{I look forward to hearing from you.}
\begin{flushright}
% Note: no trailing \\ inside \closing{} - cover.cls appends its own \\, and a
% doubled break triggers "There's no line here to end."
\closing{Kind regards,}
\signature{[YOUR NAME]}
\end{flushright}
\end{document}
+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())