mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-09-17 00:26:26 +00:00
feat(versioning): framework_version markers, CI version guard, and fork update checker (#144)
* Add framework_version markers to assistant skills and implement CI version guard and update checker * Address review feedback: update ci.yml based on latest upstream, gate CI version guard to upstream repo, and remove non-ASCII characters from check_upstream_updates.py
This commit is contained in:
@@ -1,3 +1,7 @@
|
|||||||
|
---
|
||||||
|
framework_version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
# Candidate Profile
|
# Candidate Profile
|
||||||
|
|
||||||
<!-- SETUP: This file is populated by running /setup -->
|
<!-- SETUP: This file is populated by running /setup -->
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
---
|
||||||
|
framework_version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
# Behavioral Profile
|
# Behavioral Profile
|
||||||
|
|
||||||
<!-- SETUP: This file is populated by running /setup -->
|
<!-- SETUP: This file is populated by running /setup -->
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
---
|
||||||
|
framework_version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
# Writing Style Guide
|
# Writing Style Guide
|
||||||
|
|
||||||
## Critical Rules
|
## Critical Rules
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
---
|
||||||
|
framework_version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
# Job Evaluation Framework
|
# Job Evaluation Framework
|
||||||
|
|
||||||
<!-- SETUP: Skill match areas and career goals are personalized by running /setup -->
|
<!-- SETUP: Skill match areas and career goals are personalized by running /setup -->
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
---
|
||||||
|
framework_version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
# CV Templates and Tailoring Guide
|
# CV Templates and Tailoring Guide
|
||||||
|
|
||||||
<!-- SETUP: Profile statements and section ordering are personalized by running /setup -->
|
<!-- SETUP: Profile statements and section ordering are personalized by running /setup -->
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
---
|
||||||
|
framework_version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
# Cover Letter Templates and Tailoring Guide
|
# Cover Letter Templates and Tailoring Guide
|
||||||
|
|
||||||
## Template: Custom cover.cls (XeLaTeX)
|
## Template: Custom cover.cls (XeLaTeX)
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
---
|
||||||
|
framework_version: 1.0.0
|
||||||
|
---
|
||||||
|
|
||||||
# Interview Preparation Guide
|
# Interview Preparation Guide
|
||||||
|
|
||||||
<!-- SETUP: STAR examples are personalized by running /setup based on your actual experience -->
|
<!-- SETUP: STAR examples are personalized by running /setup based on your actual experience -->
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ description: >
|
|||||||
and preparing for interviews. Triggers on keywords like: job posting, job application, CV,
|
and preparing for interviews. Triggers on keywords like: job posting, job application, CV,
|
||||||
cover letter, resume, interview prep, job fit, career, application, apply, ansøgning, stilling
|
cover letter, resume, interview prep, job fit, career, application, apply, ansøgning, stilling
|
||||||
allowed-tools: Read, Glob, Grep, WebFetch, WebSearch, Edit, Write, AskUserQuestion
|
allowed-tools: Read, Glob, Grep, WebFetch, WebSearch, Edit, Write, AskUserQuestion
|
||||||
|
framework_version: 1.0.0
|
||||||
---
|
---
|
||||||
|
|
||||||
# Job Application Assistant
|
# Job Application Assistant
|
||||||
|
|||||||
@@ -40,11 +40,16 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
- run: pip install pyyaml
|
- run: pip install pyyaml
|
||||||
- run: python tools/lint_skills.py
|
- 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:
|
security-guards:
|
||||||
name: Security guards (permissions, gitignore, manifests)
|
name: Security guards (permissions, gitignore, manifests)
|
||||||
|
|||||||
+2
-1
@@ -33,7 +33,7 @@ Reviews here are empirical. Bug reports are reproduced on master before the fix
|
|||||||
|
|
||||||
- State the failing case and how to reproduce it.
|
- State the failing case and how to reproduce it.
|
||||||
- Put CLI tests in `.agents/skills/<name>/cli/tests/` (bun test, network-free where possible); Python tool tests in `tests/`.
|
- Put CLI tests in `.agents/skills/<name>/cli/tests/` (bun test, network-free where possible); Python tool tests in `tests/`.
|
||||||
- Run what CI runs: `python3 tools/lint_skills.py` (or `python tools/lint_skills.py` if that is your Python 3 executable), `bun run typecheck` in touched CLIs, and the relevant test suites.
|
- Run what CI runs: `python3 tools/lint_skills.py`, `python3 tools/check_framework_version.py`, `bun run typecheck` in touched CLIs, and the relevant test suites.
|
||||||
|
|
||||||
**Credit norm:** a change that incorporates your actual code gets a `Co-authored-by` trailer; a change written independently from your observation or report gets a named mention in the commit message and PR. Both happen unprompted.
|
**Credit norm:** a change that incorporates your actual code gets a `Co-authored-by` trailer; a change written independently from your observation or report gets a named mention in the commit message and PR. Both happen unprompted.
|
||||||
|
|
||||||
@@ -41,6 +41,7 @@ Reviews here are empirical. Bug reports are reproduced on master before the fix
|
|||||||
|
|
||||||
1. Fork the repo and run `/add-portal` with your local job board - it scaffolds a portal skill matching the shipped contract, and `/scrape` picks it up automatically.
|
1. Fork the repo and run `/add-portal` with your local job board - it scaffolds a portal skill matching the shipped contract, and `/scrape` picks it up automatically.
|
||||||
2. Announce your fork in the pinned [Community forks & adaptations](https://github.com/MadsLorentzen/ai-job-search/discussions/78) discussion so others can find it.
|
2. Announce your fork in the pinned [Community forks & adaptations](https://github.com/MadsLorentzen/ai-job-search/discussions/78) discussion so others can find it.
|
||||||
|
3. Run the framework update checker (`python3 tools/check_upstream_updates.py`) in your fork to check if upstream has updated any framework files and compare them with your personalized variants.
|
||||||
|
|
||||||
Market-specific skills are genuinely valuable - they just live in forks, where their maintainers can test them and their users can find them.
|
Market-specific skills are genuinely valuable - they just live in forks, where their maintainers can test them and their users can find them.
|
||||||
|
|
||||||
|
|||||||
Executable
+142
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""CI check: ensure that modified framework files have updated version markers.
|
||||||
|
|
||||||
|
Fails if any markdown file under .claude/skills/job-application-assistant/ is
|
||||||
|
modified in git without a change/bump to its 'framework_version' frontmatter key.
|
||||||
|
Also ensures all framework files have a valid 'framework_version' frontmatter key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SKILL_DIR = ROOT / ".claude/skills/job-application-assistant"
|
||||||
|
FRAMEWORK_FILES = sorted(SKILL_DIR.glob("*.md"))
|
||||||
|
|
||||||
|
def run_git(args: list[str]) -> tuple[int, str, str]:
|
||||||
|
res = subprocess.run(["git"] + args, cwd=str(ROOT), capture_output=True, text=True)
|
||||||
|
return res.returncode, res.stdout, res.stderr
|
||||||
|
|
||||||
|
def get_base_commit() -> str | None:
|
||||||
|
# If in GitHub Actions PR, use the target branch's base ref
|
||||||
|
base_ref = os.environ.get("GITHUB_BASE_REF")
|
||||||
|
if base_ref:
|
||||||
|
# Check if origin/base_ref is fetched
|
||||||
|
rc, _, _ = run_git(["rev-parse", "--verify", f"origin/{base_ref}"])
|
||||||
|
if rc == 0:
|
||||||
|
return f"origin/{base_ref}"
|
||||||
|
# Try without origin/
|
||||||
|
rc, _, _ = run_git(["rev-parse", "--verify", base_ref])
|
||||||
|
if rc == 0:
|
||||||
|
return base_ref
|
||||||
|
|
||||||
|
# If running in GitHub Actions but not a PR (e.g. push to master)
|
||||||
|
if os.environ.get("GITHUB_ACTIONS"):
|
||||||
|
rc, _, _ = run_git(["rev-parse", "--verify", "HEAD~1"])
|
||||||
|
if rc == 0:
|
||||||
|
return "HEAD~1"
|
||||||
|
|
||||||
|
# Otherwise (running locally), check uncommitted changes against HEAD
|
||||||
|
rc, _, _ = run_git(["rev-parse", "--verify", "HEAD"])
|
||||||
|
if rc == 0:
|
||||||
|
return "HEAD"
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_frontmatter(path: Path) -> dict:
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
if not text.startswith("---\n"):
|
||||||
|
return {}
|
||||||
|
end = text.find("\n---", 4)
|
||||||
|
if end == -1:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Simple parser for YAML/frontmatter
|
||||||
|
data = {}
|
||||||
|
for line in text[4:end].splitlines():
|
||||||
|
if ":" in line:
|
||||||
|
k, v = line.split(":", 1)
|
||||||
|
data[k.strip()] = v.strip().strip('"').strip("'")
|
||||||
|
return data
|
||||||
|
|
||||||
|
def has_non_trivial_changes(file_path: Path, base_commit: str) -> bool:
|
||||||
|
# Get diff of the file from base_commit to HEAD
|
||||||
|
rel_path = str(file_path.relative_to(ROOT))
|
||||||
|
rc, stdout, stderr = run_git(["diff", "-U0", base_commit, "--", rel_path])
|
||||||
|
if rc != 0:
|
||||||
|
# If diff fails (e.g. file is new/untracked), it's a change
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Parse diff lines
|
||||||
|
# We want to count lines added/removed that:
|
||||||
|
# - do not match framework_version line
|
||||||
|
# - are not empty/whitespace only
|
||||||
|
meaningful_changes = 0
|
||||||
|
version_changed = False
|
||||||
|
|
||||||
|
for line in stdout.splitlines():
|
||||||
|
if line.startswith("+++") or line.startswith("---") or line.startswith("@@"):
|
||||||
|
continue
|
||||||
|
if line.startswith("+") or line.startswith("-"):
|
||||||
|
content = line[1:].strip()
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
if re.match(r"^framework_version\s*:", content):
|
||||||
|
version_changed = True
|
||||||
|
continue
|
||||||
|
# Check if it's just frontmatter syntax (e.g. ---)
|
||||||
|
if content == "---":
|
||||||
|
continue
|
||||||
|
meaningful_changes += 1
|
||||||
|
|
||||||
|
# If the version key itself was modified, we don't fail, regardless of other changes
|
||||||
|
if version_changed:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# If there are meaningful changes but the version was not changed
|
||||||
|
return meaningful_changes > 0
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# 1. Lint: Check that all framework files have framework_version in frontmatter
|
||||||
|
for path in FRAMEWORK_FILES:
|
||||||
|
rel_path = str(path.relative_to(ROOT))
|
||||||
|
fm = parse_frontmatter(path)
|
||||||
|
if "framework_version" not in fm:
|
||||||
|
errors.append(f"{rel_path}: missing 'framework_version' in frontmatter")
|
||||||
|
|
||||||
|
# 2. Check for missing version bumps in modified files
|
||||||
|
base_commit = get_base_commit()
|
||||||
|
if base_commit:
|
||||||
|
print(f"Comparing HEAD against base commit: {base_commit}")
|
||||||
|
for path in FRAMEWORK_FILES:
|
||||||
|
rel_path = str(path.relative_to(ROOT))
|
||||||
|
if "framework_version" not in parse_frontmatter(path):
|
||||||
|
# Skip checking changes if it doesn't even have frontmatter (already reported above)
|
||||||
|
continue
|
||||||
|
if has_non_trivial_changes(path, base_commit):
|
||||||
|
errors.append(
|
||||||
|
f"{rel_path}: modified without bumping 'framework_version'. "
|
||||||
|
f"Please update the version in the frontmatter."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("No base commit found (e.g. initial commit or shallow clone without base branch). Skipping diff checks.")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("Framework Version Check Failed:")
|
||||||
|
for err in errors:
|
||||||
|
print(f" - {err}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("Framework Version Check: OK")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+148
@@ -0,0 +1,148 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Check for framework updates in the upstream repository.
|
||||||
|
|
||||||
|
Usage: python tools/check_upstream_updates.py [--remote <remote-name>] [--branch <branch-name>]
|
||||||
|
|
||||||
|
This script:
|
||||||
|
1. Identifies the upstream remote (defaults to 'upstream', falls back to 'origin').
|
||||||
|
2. Fetches the latest commits from the upstream remote.
|
||||||
|
3. Compares the 'framework_version' in your local files under
|
||||||
|
.claude/skills/job-application-assistant/ with those in the upstream remote.
|
||||||
|
4. Alerts you if a file has been updated upstream with a newer version.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
SKILL_DIR = ".claude/skills/job-application-assistant"
|
||||||
|
FRAMEWORK_FILES = [
|
||||||
|
"01-candidate-profile.md",
|
||||||
|
"02-behavioral-profile.md",
|
||||||
|
"03-writing-style.md",
|
||||||
|
"04-job-evaluation.md",
|
||||||
|
"05-cv-templates.md",
|
||||||
|
"06-cover-letter-templates.md",
|
||||||
|
"07-interview-prep.md",
|
||||||
|
"SKILL.md",
|
||||||
|
]
|
||||||
|
|
||||||
|
def run_git(args: list[str]) -> tuple[int, str, str]:
|
||||||
|
res = subprocess.run(["git"] + args, cwd=str(ROOT), capture_output=True, text=True)
|
||||||
|
return res.returncode, res.stdout, res.stderr
|
||||||
|
|
||||||
|
def get_framework_version_from_text(text: str) -> str | None:
|
||||||
|
if not text.startswith("---\n"):
|
||||||
|
return None
|
||||||
|
end = text.find("\n---", 4)
|
||||||
|
if end == -1:
|
||||||
|
return None
|
||||||
|
for line in text[4:end].splitlines():
|
||||||
|
if ":" in line:
|
||||||
|
k, v = line.split(":", 1)
|
||||||
|
if k.strip() == "framework_version":
|
||||||
|
return v.strip().strip('"').strip("'")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_semver(version_str: str) -> tuple[int, int, int]:
|
||||||
|
# Clean version string (e.g. remove 'v' prefix)
|
||||||
|
match = re.match(r"^v?(\d+)\.(\d+)\.(\d+)", version_str)
|
||||||
|
if not match:
|
||||||
|
return (0, 0, 0)
|
||||||
|
return tuple(int(x) for x in match.groups())
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Check for framework updates upstream.")
|
||||||
|
parser.add_argument("--remote", default="upstream", help="Name of the git remote for upstream (default: upstream)")
|
||||||
|
parser.add_argument("--branch", default="master", help="Branch name of the upstream repo (default: master)")
|
||||||
|
parser.add_argument("--no-fetch", action="store_true", help="Skip fetching from remote")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Verify remote exists
|
||||||
|
rc, stdout, _ = run_git(["remote"])
|
||||||
|
remotes = stdout.splitlines()
|
||||||
|
remote = args.remote
|
||||||
|
if remote not in remotes:
|
||||||
|
if "origin" in remotes:
|
||||||
|
print(f"Warning: Remote '{remote}' not found. Falling back to 'origin'.")
|
||||||
|
remote = "origin"
|
||||||
|
else:
|
||||||
|
print("Error: No git remotes found.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not args.no_fetch:
|
||||||
|
print(f"Fetching latest from remote '{remote}'...")
|
||||||
|
rc, _, stderr = run_git(["fetch", remote])
|
||||||
|
if rc != 0:
|
||||||
|
print(f"Warning: Failed to fetch from remote '{remote}': {stderr.strip()}")
|
||||||
|
print("Proceeding with cached remote tracking branches.")
|
||||||
|
|
||||||
|
ref = f"{remote}/{args.branch}"
|
||||||
|
# Verify ref exists
|
||||||
|
rc, _, _ = run_git(["rev-parse", "--verify", ref])
|
||||||
|
if rc != 0:
|
||||||
|
print(f"Error: Ref '{ref}' does not exist. Make sure you fetched and specified the correct branch.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"Comparing local files against upstream '{ref}'...\n")
|
||||||
|
|
||||||
|
updates_available = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for filename in FRAMEWORK_FILES:
|
||||||
|
local_path = ROOT / SKILL_DIR / filename
|
||||||
|
if not local_path.exists():
|
||||||
|
print(f"Local file missing: {SKILL_DIR}/{filename}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get local version
|
||||||
|
local_text = local_path.read_text(encoding="utf-8")
|
||||||
|
local_ver = get_framework_version_from_text(local_text)
|
||||||
|
|
||||||
|
# Get upstream version
|
||||||
|
rc, upstream_text, _ = run_git(["show", f"{ref}:{SKILL_DIR}/{filename}"])
|
||||||
|
if rc != 0:
|
||||||
|
# File might not exist upstream yet
|
||||||
|
continue
|
||||||
|
|
||||||
|
upstream_ver = get_framework_version_from_text(upstream_text)
|
||||||
|
|
||||||
|
if not local_ver:
|
||||||
|
errors.append(f"Local file {filename} is missing 'framework_version' in frontmatter.")
|
||||||
|
continue
|
||||||
|
if not upstream_ver:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if parse_semver(upstream_ver) > parse_semver(local_ver):
|
||||||
|
updates_available.append({
|
||||||
|
"filename": filename,
|
||||||
|
"local": local_ver,
|
||||||
|
"upstream": upstream_ver,
|
||||||
|
"path": f"{SKILL_DIR}/{filename}"
|
||||||
|
})
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("Configuration errors:")
|
||||||
|
for err in errors:
|
||||||
|
print(f" - {err}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if updates_available:
|
||||||
|
print("[UPDATE] Upstream updates available for framework methodology files:")
|
||||||
|
for up in updates_available:
|
||||||
|
print(f" - {up['filename']}: local {up['local']} < upstream {up['upstream']}")
|
||||||
|
print(f" Diff command: git diff {ref} -- {up['path']}")
|
||||||
|
print()
|
||||||
|
print("Review these changes to see if they fit your personalized fork!")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print("[OK] All framework files are up to date with upstream!")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user