Files
ai-job-search/.github/workflows/ci.yml
T
Ayobami Adegoke a45bbd1a2a feat(verify_pdf): add --ascii-dates, the non-folding check for the documented date-range rule (#385) (#498)
* feat(verify_pdf): add --ascii-dates, the non-folding check for the documented date-range rule (#385)

05-cv-templates.md records a real Workday import that dropped a role's
end date because `2016--2024` reaches the text layer as 2016<U+2013>2024,
and tells Step 5d to confirm every entry's years are joined by an ASCII
hyphen. Nothing executed that. The natural probe, --contains "2016-2024",
failed identically for "en-dashed" and "absent" before #458 and passes on
both since, because the fold maps the en-dash back to "-" on purpose.

Add --ascii-dates: scan the raw text layer (never the fold) for a year
joined to any Unicode dash or the minus sign and fail naming each hit
with its line and code point. A year on either side of the dash is
enough (Mar 2016 - Jul 2016, 2016 - Present); a numeric range with no
year is not a date. The dump is still written before the check fails.

Wire it in: Step 5d's extraction command carries the flag and the
"Dates recognizable" item points at it, keeping the bare single-year
half as a read-through; the guide's "add this to the step 5d checks"
paragraph names the command (framework_version 1.4.4 -> 1.4.5); the
upstream-only CI assertion runs it on the stock CV.

Demonstrated on the stock template compiled with lualatex through both
extractors: a \cventry{2016--2024} build fails with U+2013 while
--contains "2016-2024" passes on it; the \cventry{2016-2024} build passes
both; the untouched stock CV passes the CI assertion.

Ten new test_verify_pdf.py cases. Proposed by 9scorp4 in Discussion #385.

* fix(verify_pdf): --ascii-dates only joins a year and a dash across horizontal whitespace

`\s*` between the year and the dash also matched a newline, so a heading
ending in an ASCII date ("Heading 1988-1994\n- note") joined the dash
that merely opened the next line and was reported as a U+2013 range; the
mirror shape ("Six Sigma -\n2016 onwards") did the same. Both are real
text-layer shapes: a dash used as a bullet, a wrapped prose line.

Match horizontal whitespace only ([^\S\n]*). Every existing case still
passes; three fixtures pin the two newline shapes and the same-line
spaced dash that must still be caught.

Found in review by yang2632, with the regex and the fixture.
2026-09-28 19:06:41 +02:00

253 lines
11 KiB
YAML

# 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/content assertions run only on the upstream template repo.
# Compile success, lint correctness, and an extractable PDF text layer 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).
# CI runs typechecks and the checked-in fixture/mock test suites only; live
# portal testing stays a local, on-demand step.
#
# Security posture: this template ships pre-approved Claude Code permissions
# and CLI code that every fork user executes, so the security-guards job
# fails PRs that widen settings.json permissions, weaken the personal-data
# gitignore rules, or add package lifecycle scripts; dependency-review flags
# newly introduced vulnerable/malicious dependencies. Honest limit: a PR can
# edit this workflow itself, so these guards catch accidents and casual
# attempts, not a determined author - branch protection with required checks
# and human review of workflow/settings diffs remain the real backstop.
# Actions are pinned to commit SHAs; the token is read-only.
name: CI
on:
push:
branches: [master]
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
lint:
name: Lint skills, commands, settings
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
- run: pip install pyyaml
- run: python tools/lint_skills.py
- name: Framework version guard (upstream template only)
if: github.repository == 'MadsLorentzen/ai-job-search'
run: python tools/check_framework_version.py
security-guards:
name: Security guards (permissions, gitignore, manifests)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
- run: python tools/security_guards.py
python-tests:
name: Python tool tests (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ matrix.python-version }}
- name: Install Excel integration test dependency
run: python -m pip install openpyxl==3.1.5
- run: python -m unittest discover -s tests -t . -v
dependency-review:
name: Dependency review
# Requires the repo's Dependency graph, which not every repo (upstream or
# fork) has enabled - so the graph is probed first, and the job warns and
# passes instead of hard-failing if it's unavailable (the same
# graceful-skip pattern the workflow uses for optional tools), rather than
# being gated to a specific repository. Enabling Dependency graph under
# Settings -> Advanced Security activates the real check on any repo.
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Probe Dependency graph availability
id: graph
run: |
code=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer ${{ github.token }}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${{ github.repository }}/dependency-graph/sbom")
if [ "$code" = "200" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "::warning::Dependency graph is not enabled on this repository (HTTP $code). Dependency review was skipped - enable Dependency graph under Settings -> Advanced Security to activate this check."
fi
- name: Dependency review
if: steps.graph.outputs.enabled == 'true'
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
with:
fail-on-severity: high
latex-smoke:
# Two legs. texlive/texlive:latest tracks current TeX Live (moderncv 2.5+);
# debian:bookworm compiles on apt-packaged TeX Live 2022 with moderncv
# 2.3.1 - the environment #242 hit and the one texlive:latest can never
# catch a regression in, because it never shipped the old class. The
# README's Linux setup path is apt, so both ends of the moderncv range
# users actually have stay compiled.
name: Compile example CV and cover letter (${{ matrix.leg.name }})
runs-on: ubuntu-latest
container: ${{ matrix.leg.container }}
strategy:
fail-fast: false
matrix:
leg:
- name: texlive-latest
container: texlive/texlive:latest
- name: debian-bookworm
container: debian:bookworm
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Install apt-packaged TeX Live (bookworm leg)
if: matrix.leg.name == 'debian-bookworm'
# --no-install-recommends keeps the leg lean, so the two font packages
# must then be named explicitly: moderncv loads fontawesome5, which apt
# ships in texlive-fonts-extra (lualatex dies fatally without it), and
# hyperref's xetex driver probes the pzdr metrics from
# texlive-fonts-recommended (the cover letter fails without it).
run: |
apt-get update
apt-get install -y --no-install-recommends \
texlive-luatex texlive-latex-extra texlive-xetex \
texlive-fonts-extra texlive-fonts-recommended \
poppler-utils python3
- name: Install PDF inspection tools
run: |
if ! command -v pdfinfo >/dev/null || ! command -v pdftotext >/dev/null; then
apt-get update
apt-get install -y --no-install-recommends poppler-utils
fi
- name: Compile CV example (lualatex)
run: |
cd cv
lualatex -interaction=nonstopmode -halt-on-error 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 -halt-on-error 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: Verify extractable PDF text
run: |
python3 tools/verify_pdf.py cv/main_example.pdf --min-chars 100
python3 tools/verify_pdf.py cover_letters/cover_example.pdf --min-chars 100
- name: Assert stock PDF structure (upstream template only)
if: github.repository == 'MadsLorentzen/ai-job-search'
run: |
python3 tools/verify_pdf.py cv/main_example.pdf \
--pages 2 \
--ascii-dates \
--contains '[your.email@example.com]' \
--contains 'Professional Experience' \
--contains 'Achievement'
python3 tools/verify_pdf.py cover_letters/cover_example.pdf \
--pages 1 \
--contains 'your.email@example.com' \
--contains 'Dear [Hiring Manager / Team]'
discover-clis:
# The matrix is discovered, not hardcoded, so a portal CLI added in a fork
# (the /add-portal path) gets typechecked and tested without the fork
# having to edit this workflow - the same reason security-guards globs
# .agents/**/package.json instead of naming the shipped portals.
name: Discover portal CLIs
runs-on: ubuntu-latest
outputs:
tools: ${{ steps.list.outputs.tools }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- id: list
run: |
tools=$(find .agents/skills -mindepth 3 -maxdepth 3 -path '*/cli/package.json' \
| cut -d/ -f3 | sort | jq -R . | jq -cs .)
echo "Discovered portal CLIs: $tools"
echo "tools=$tools" >> "$GITHUB_OUTPUT"
cli-checks:
name: CLI checks ${{ matrix.tool }}
needs: discover-clis
if: needs.discover-clis.outputs.tools != '[]'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
tool: ${{ fromJSON(needs.discover-clis.outputs.tools) }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- run: bun install
working-directory: .agents/skills/${{ matrix.tool }}/cli
- run: bun run typecheck
working-directory: .agents/skills/${{ matrix.tool }}/cli
- name: Run fixture/mock tests when present
run: |
if find tests -type f -name '*.test.ts' | grep -q .; then
bun test
else
echo "No Bun tests found for ${{ matrix.tool }}; skipping"
fi
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@34e114876b0b11c390a56381ad16ebd13914f8d5 # 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 '\\name{\[First\]}{\[Last\]}'
check cv/main_example.tex '\\email{\[your\.email@example\.com\]}'
check cover_letters/cover_example.tex '\[YOUR NAME\]'
check .claude/skills/job-application-assistant/01-candidate-profile.md '\[YOUR_EMAIL\]'
check .claude/skills/job-application-assistant/04-job-evaluation.md '\[YOUR_PRIMARY_SKILLS\]'
exit $fail