commit f091cc110a08a1c2ac2c6bb411f04e488fbce381 Author: Prad Nukala Date: Wed Aug 26 10:33:48 2026 -0400 ci(ci): add CI workflow with linting, tests, LaTeX compilation, and dependency review diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100755 index 0000000..c25aca5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,218 @@ +# 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 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + - 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: + name: Compile example CV and cover letter + runs-on: ubuntu-latest + container: texlive/texlive:latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - 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 \ + --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 diff --git a/.github/workflows/upstream-watch.yml b/.github/workflows/upstream-watch.yml new file mode 100755 index 0000000..32d2100 --- /dev/null +++ b/.github/workflows/upstream-watch.yml @@ -0,0 +1,85 @@ +# Weekly upstream triage. Reports only - it NEVER merges, pushes, or edits code. +# +# It fetches the upstream template, runs tools/upstream_triage.py to sort the +# commits this fork lacks into "worth reviewing" vs "probably skip" (dropping +# cherry-picks already applied and changes that only touch files this fork +# removed), and writes the result into a single rolling issue. You read it and +# port anything worth porting by hand. +# +# The report/act boundary is deliberate and load-bearing: the report stops at +# ready-to-run cherry-pick lines and never opens a draft PR or merges. On a +# fork "applies cleanly" is not "correct" - a commit for portals the fork +# dropped can cherry-pick fine and still be wrong, and that silent-wrong case +# is worse than a conflict. Merges stay a human decision, the same posture +# /apply keeps (it drafts, never submits). Keep it that way. +# +# This is the commit-level companion to tools/check_upstream_updates.py, which +# tracks personalized-file version stamps. Two tools, two questions. +# +# Runs only on forks (guarded below), so the upstream template never triggers +# it against itself - GitHub also leaves inherited workflows disabled on a fork +# until the owner enables Actions, so the guard is a second fence, not the only +# one. Token is the built-in GITHUB_TOKEN, scoped to reading contents and +# writing issues in this repo only: the digest can never be written outside the +# fork. + +name: Upstream watch + +on: + schedule: + - cron: "0 8 * * 1" # 08:00 UTC every Monday + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + triage: + name: Triage upstream commits + # No-op on the upstream template itself. Pinned by + # tests/test_upstream_triage.py so a template clone never runs it by surprise. + if: github.repository != 'MadsLorentzen/ai-job-search' + 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" + + - name: Fetch upstream template + run: | + git remote add upstream https://github.com/MadsLorentzen/ai-job-search.git 2>/dev/null || true + git fetch --quiet upstream master + + - name: Build triage report + run: | + { + echo "_Last checked: $(date -u '+%Y-%m-%d %H:%M UTC') ยท [run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})_" + echo + python tools/upstream_triage.py --remote upstream --branch master + } > report.md + cat report.md + + - name: Open or update the rolling issue + env: + # Built-in token is scoped to this repo only, so the digest can never + # be written outside the fork. + GH_TOKEN: ${{ github.token }} + # Pin to this fork. Without it, the `upstream` git remote added above + # makes gh's remote resolution target the base repo, so the digest + # would land on upstream's tracker instead of the fork's. + GH_REPO: ${{ github.repository }} + run: | + title="Upstream sync watch" + existing=$(gh issue list --state open --search "in:title \"$title\"" \ + --json number,title --jq ".[] | select(.title==\"$title\") | .number" | head -n1) + if [ -n "$existing" ]; then + gh issue edit "$existing" --body-file report.md + echo "Updated issue #$existing" + else + gh issue create --title "$title" --body-file report.md + echo "Created a new rolling issue" + fi