mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
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:
@@ -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.}
|
||||
|
||||
\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]}
|
||||
\end{flushright}
|
||||
|
||||
@@ -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
|
||||
@@ -51,6 +51,7 @@ cv/main_*.tex
|
||||
cv/*.txt
|
||||
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/cv/**
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
# AI Job Search
|
||||
|
||||
[](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.
|
||||
|
||||
> 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
|
||||
├── cover_letters/
|
||||
│ ├── cover.cls # Custom cover letter LaTeX class
|
||||
│ ├── cover_example.tex # Example cover letter (structural reference + CI smoke test)
|
||||
│ └── OpenFonts/ # Lato + Raleway fonts
|
||||
├── templates/ # Custom templates registered via /add-template
|
||||
│ └── README.md # Folder layout instructions
|
||||
@@ -160,9 +163,11 @@ ai-job-search/
|
||||
│ ├── diplomas/ # Degree certificates and transcripts
|
||||
│ ├── references/ # Reference letters
|
||||
│ └── 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)
|
||||
├── tools/
|
||||
│ ├── 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
|
||||
├── job_scraper/ # Scraper state (seen jobs, results)
|
||||
├── upskill/ # /upskill report output (markdown reports per run)
|
||||
|
||||
@@ -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}
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user