mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 08:36:25 +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.}
|
\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}
|
||||||
|
|||||||
@@ -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
|
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/**
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
# AI Job Search
|
# 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.
|
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)
|
||||||
|
|||||||
@@ -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