diff --git a/.cz.toml b/.cz.toml new file mode 100644 index 00000000..3c29d194 --- /dev/null +++ b/.cz.toml @@ -0,0 +1,4 @@ +[tool.commitizen] +name = "cz_conventional_commits" +version_provider = "scm" +tag_format = "v$version" diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3bf0178b..a8796734 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,6 +4,10 @@ Those adaptations live in forks - see CONTRIBUTING.md - and get discovered via the pinned "Community forks & adaptations" discussion (#78). Check the "base repository" dropdown above before you continue. --> + ## What changed and why diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..2c1cef48 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,88 @@ +# Weekly release. Every Monday it tags master and publishes a GitHub Release +# with GitHub-generated notes (one line per merged PR title), replacing the +# hand-edited CHANGELOG.md, which is frozen at 1.7.4. +# +# commitizen (`cz bump --get-next`, configured in .cz.toml) picks the SemVer +# version from the merged commit types; a manual run can override it. +# +# It skips a week with nothing to bump and refuses to release a master commit +# whose CI run did not pass. The release is created through the API, so it +# never commits to master. Upstream only: forks inherit this file and must not +# cut releases of their own. + +name: Weekly release + +on: + schedule: + - cron: "0 6 * * 1" # 06:00 UTC every Monday + workflow_dispatch: + inputs: + version: + description: "Override the computed version (e.g. 2.0.0, no v). Leave blank to derive it from the merged commits." + required: false + +permissions: + contents: read + +concurrency: + group: release + +jobs: + release: + name: Tag and publish the weekly release + if: github.repository == 'ai-job-search-dev/ai-job-search' + runs-on: ubuntu-latest + permissions: + contents: write + actions: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: master + fetch-depth: 0 + persist-credentials: false + + - uses: astral-sh/setup-uv@68559e99cc807c1eb7e9238fd43a15bba460eec2 # v10.3.3 + + - name: Release + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + last=$(gh release view --json tagName -q .tagName) + head=$(git rev-parse HEAD) + if [ "$(git rev-parse "$last^{commit}")" = "$head" ]; then + echo "No commits since $last, nothing to release" + exit 0 + fi + if [ -n "$VERSION" ]; then + next="v$VERSION" + else + rc=0 + next=$(uvx --from commitizen==4.19.3 cz bump --get-next) || rc=$? + case $rc in + 0) next="v$next" ;; + 21) echo "No feat, fix, refactor, perf or breaking commits since $last, nothing to release"; exit 0 ;; + *) echo "::error::cz bump --get-next exited $rc: $next"; exit 1 ;; + esac + fi + ci=$(gh run list -w ci.yml -b master -c "$head" -L 1 --json conclusion -q '.[0].conclusion') + if [ "$ci" != success ]; then + echo "::error::CI on $head is '${ci:-missing or not finished}', not releasing" + exit 1 + fi + if ! [[ "$next" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::'$next' is not a vMAJOR.MINOR.PATCH tag" + exit 1 + fi + if git rev-parse -q --verify "refs/tags/$next" >/dev/null; then + echo "::error::$next already exists" + exit 1 + fi + if [ "$(printf '%s\n' "${last#v}" "${next#v}" | sort -V | tail -n1)" != "${next#v}" ]; then + echo "::error::$next is not newer than $last" + exit 1 + fi + gh release create "$next" --target "$head" --generate-notes --notes-start-tag "$last" diff --git a/CHANGELOG.md b/CHANGELOG.md index 79467397..9f9d5025 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,20 @@ # Changelog -All notable changes to this project are documented here. The format is based on -[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres -to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +**This file is frozen at 1.7.4.** Later releases are published weekly and documented in +[GitHub Releases](https://github.com/ai-job-search-dev/ai-job-search/releases), with notes +generated from the titles of the PRs each release contains. Don't add entries here. -Releases are vetted checkpoints of `master`. If you maintain a personalized fork, -prefer updating to a tagged release over pulling raw `master` (see +The entries below follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this +project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +Releases are weekly checkpoints of `master` that passed CI. If you maintain a personalized +fork, prefer updating to a tagged release over pulling raw `master` (see [SETUP.md, section 8](SETUP.md#8-pulling-upstream-updates-into-your-fork)). The `framework_version` markers on methodology files tell you which of your customized files a release touched; `python3 tools/check_upstream_updates.py` lists them with per-file diff commands. -## [Unreleased] +## [1.7.4] ### Added @@ -25,6 +28,16 @@ per-file diff commands. ### Changed +- **Releases are weekly and automatic, and this changelog is frozen** (`.github/workflows/release.yml`, + `.cz.toml`, `tests/test_release_workflow.py`, `tests/test_changelog_structure.py`) - every + PR edited `[Unreleased]` by hand, so every PR after the first conflicted on this file, and only one + maintainer could cut a release. From now on a workflow tags `master` every Monday at 06:00 UTC and + publishes a GitHub Release whose notes list the merged PR titles. The version is SemVer, bumped by + commitizen from the merged commit types: `type!:` or a `BREAKING CHANGE:` footer is major, `feat` is + minor, `fix`, `refactor` and `perf` are patch. A week with nothing to bump is skipped, and a + `master` commit whose CI failed is never released. Forks keep updating to tags as before; release + notes now live in GitHub Releases, and a test fails any PR that still edits this file. + - **The repository moved to the `ai-job-search-dev` organization** - the canonical URL is now `https://github.com/ai-job-search-dev/ai-job-search`. GitHub redirects the old `MadsLorentzen/ai-job-search` address for web links, `git` remotes, raw files and the API, so @@ -2829,7 +2842,7 @@ At this baseline the framework provides: - **Cross-runtime support** - a root `AGENTS.md` pointer so Codex and Antigravity can discover the portable portal skills, with Claude Code as the reference runtime. -[Unreleased]: https://github.com/ai-job-search-dev/ai-job-search/compare/v1.7.3...HEAD +[1.7.4]: https://github.com/ai-job-search-dev/ai-job-search/compare/v1.7.3...v1.7.4 [1.7.3]: https://github.com/ai-job-search-dev/ai-job-search/compare/v1.7.2...v1.7.3 [1.7.2]: https://github.com/ai-job-search-dev/ai-job-search/compare/v1.7.1...v1.7.2 [1.7.1]: https://github.com/ai-job-search-dev/ai-job-search/compare/v1.7.0...v1.7.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f1b306a2..888a8f02 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,6 +66,7 @@ This is a decision, not a dogma: if cross-runtime standards mature to the point ## Practical notes +- **PR titles are the release notes, and they set the version**: don't edit `CHANGELOG.md` (it's frozen at 1.7.4). Every Monday a workflow publishes a GitHub Release listing the titles of the PRs merged that week, so write the title for a fork owner and use a [Conventional Commits](https://www.conventionalcommits.org/) type: `feat` makes the release a minor bump, `type!:` (or a `BREAKING CHANGE:` footer) a major one, and `fix`, `refactor` or `perf` a patch; a week of only `docs`, `chore`, `ci` and similar ships nothing on its own. [commitizen](https://commitizen-tools.github.io/commitizen/) computes it: `uvx --from commitizen cz bump --get-next` previews the next version. - **Portal-skill contract**: `search`/`detail` commands, `--format json|table|plain`, stderr JSON errors with exit 1, backoff on 429/5xx, zero runtime dependencies by default. See `/add-portal`'s spec and `linkedin-search` as the reference implementation. - **Personal-use boundaries**: portal skills that touch ToS-restricted sources carry a prominent personal-use-only warning, and CI deliberately makes no live portal requests. Don't "fix" that. - **LaTeX changes**: both templates must compile (`lualatex` for the CV, `xelatex` for the cover letter) and hold their exact page counts. CI smoke-checks this. diff --git a/README.md b/README.md index 7e6da959..98b9a2c4 100644 --- a/README.md +++ b/README.md @@ -332,7 +332,7 @@ To wipe your profile data and start fresh: ### Staying up to date -Upstream moves fast. Rather than pulling raw `master` and hoping, update your fork to a tagged [release](../../releases) - a vetted checkpoint described in [CHANGELOG.md](CHANGELOG.md). `python3 tools/check_upstream_updates.py` previews exactly which of your personalized files an update touches before you merge, and `python3 tools/upstream_triage.py` sorts the commits you're behind into "worth reviewing" vs "probably skip" (a weekly workflow can post this to a rolling issue). Full walkthrough in [SETUP.md, section 8](SETUP.md#8-pulling-upstream-updates-into-your-fork). +Upstream moves fast. Rather than pulling raw `master` and hoping, update your fork to a tagged [release](../../releases) - a weekly checkpoint that passed CI, with its changes listed on the release page. `python3 tools/check_upstream_updates.py` previews exactly which of your personalized files an update touches before you merge, and `python3 tools/upstream_triage.py` sorts the commits you're behind into "worth reviewing" vs "probably skip" (a weekly workflow can post this to a rolling issue). Full walkthrough in [SETUP.md, section 8](SETUP.md#8-pulling-upstream-updates-into-your-fork). ## Tips for better results diff --git a/SETUP.md b/SETUP.md index d2136040..52235765 100644 --- a/SETUP.md +++ b/SETUP.md @@ -305,7 +305,7 @@ These commands apply to the stock templates (moderncv CV, `cover.cls` cover lett Upstream keeps improving the methodology files your fork has personalized, so plan for updates from day one: -**Prefer releases over raw `master`.** Tagged [releases](../../releases) are vetted checkpoints, each described in [CHANGELOG.md](CHANGELOG.md). Updating to a tag pulls a stable, documented state instead of whatever `master` happens to be mid-review. Fetch tags with `git fetch upstream --tags` and merge a release (for example `git merge v1.0.0`) when you want stability; pull `master` directly only when you specifically want the latest unreleased changes. The steps below apply either way - substitute the release tag for `upstream/master` where you see it. +**Prefer releases over raw `master`.** Tagged [releases](../../releases) are weekly checkpoints of `master` that passed CI, each listing the PRs it contains on its release page ([CHANGELOG.md](CHANGELOG.md) covers releases up to 1.7.4). Updating to a tag pulls a stable, documented state instead of whatever `master` happens to be mid-review. Fetch tags with `git fetch upstream --tags` and merge a release (for example `git merge v1.0.0`) when you want stability; pull `master` directly only when you specifically want the latest unreleased changes. The steps below apply either way - substitute the release tag for `upstream/master` where you see it. 1. **Commit your personalization - but know where those commits land.** `/setup` edits CLAUDE.md and the profile skill files in place; those edits are *yours*, and committing them is what lets updates merge cleanly. But a GitHub **fork of this repo is public** - forks of public repositories cannot be made private - so anything you commit *and push to a fork* is visible to anyone. If you want your profile in a remote at all, don't push it to a fork: create a **private** repository, push there, and add this repo as the `upstream` remote (`git remote add upstream https://github.com/ai-job-search-dev/ai-job-search.git`) to keep receiving updates. Committing locally without pushing is also fine. The genuinely sensitive files (tracker, salary data, `documents/`, application archives) are gitignored and never enter git either way. An uncommitted working tree is the most common reason `git pull` refuses to merge at all (`Your local changes ... would be overwritten`). 2. **Preview what changed before pulling:** diff --git a/tests/test_changelog_structure.py b/tests/test_changelog_structure.py index 7590f869..3d6ee2ab 100644 --- a/tests/test_changelog_structure.py +++ b/tests/test_changelog_structure.py @@ -1,134 +1,35 @@ -"""Structural guard for CHANGELOG.md's [Unreleased] section. +"""CHANGELOG.md is frozen at 1.7.4; releases after it are documented in GitHub Releases. -Contributors edit one shared file by hand, and every PR inserts its entry near -the same line. Two failure shapes have reached master or a merge queue: - -- a second `### Fixed` heading added directly under `## [Unreleased]` because - the author did not see the existing one further down (#425, fixed by hand at - merge time), and -- entries placed above any `###` heading, or under a heading Keep a Changelog - does not define. - -`lint_skills.py` does not read the changelog, so nothing caught either. This -test does, on every PR. It only inspects [Unreleased]; released sections are -history and stay as they are. +Every PR used to add an entry under `## [Unreleased]`, and PRs opened before the +freeze still carry one. Most of those entries sit mid-section, so git merges +them cleanly into the frozen 1.7.4 section - a heading check would not notice. +Pinning the file's content does: any edit fails here and points the author at +the PR title, which is what the release notes are generated from. """ +import hashlib import unittest from pathlib import Path -REPO = Path(__file__).resolve().parent.parent -CHANGELOG = REPO / "CHANGELOG.md" +try: + from tests._upstream import is_upstream_checkout +except ImportError: # `python -m unittest discover -s tests` imports test modules top-level + from _upstream import is_upstream_checkout -KNOWN_HEADINGS = {"Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"} -CONFLICT_MARKERS = ("<<<<<<< ", "=======", ">>>>>>> ") +CHANGELOG = Path(__file__).resolve().parent.parent / "CHANGELOG.md" +FROZEN_SHA256 = "5a4f9ce8270ad872a3154fb872a7e287c27023d68a1b58c6415e2ec21b4b3815" -def unreleased_block(text: str) -> str: - """The lines between `## [Unreleased]` and the next `## [` heading. - - An absent heading (right after a release cut) yields an empty block: - nothing to check is not a defect.""" - start = text.find("## [Unreleased]") - if start == -1: - return "" - end = text.find("\n## [", start + 1) - return text[start:] if end == -1 else text[start:end] - - -def unreleased_problems(text: str) -> list[str]: - """Return a human-readable problem per structural defect in [Unreleased].""" - problems: list[str] = [] - seen: list[str] = [] - current: str | None = None - for lineno, line in enumerate(unreleased_block(text).splitlines(), 1): - if any(line.startswith(marker) for marker in CONFLICT_MARKERS): - problems.append(f"conflict marker on [Unreleased] line {lineno}: {line.strip()}") - continue - if line.startswith("### "): - name = line[4:].strip() - if name not in KNOWN_HEADINGS: - problems.append( - f"unknown heading '### {name}' in [Unreleased]; use one of {sorted(KNOWN_HEADINGS)}" - ) - if name in seen: - problems.append( - f"'### {name}' appears twice in [Unreleased] - fold the entry into the existing section" - ) - seen.append(name) - current = name - elif line.startswith("- ") and current is None: - problems.append(f"entry above any '###' heading in [Unreleased]: {line.strip()[:70]}") - return problems - - -CLEAN = """# Changelog - -## [Unreleased] - -### Added - -- **A new thing** - described. - -### Fixed - -- **A fixed thing** - described. - -## [1.0.0] - 2026-01-01 - -### Fixed - -- old entry -""" - - -class UnreleasedProblemsTests(unittest.TestCase): - def test_clean_section_reports_nothing(self): - self.assertEqual(unreleased_problems(CLEAN), []) - - def test_duplicate_heading_is_reported(self): - # The exact #425 shape: a second "### Fixed" inserted directly under - # [Unreleased], above "### Added", while "### Fixed" already exists below. - text = CLEAN.replace( - "## [Unreleased]\n\n### Added", - "## [Unreleased]\n\n### Fixed\n\n- **Entry in the wrong place** - described.\n\n### Added", +@unittest.skipIf(not is_upstream_checkout(), "the freeze guards the upstream changelog; forks may keep their own") +class FrozenChangelogTests(unittest.TestCase): + def test_changelog_is_unchanged(self): + # Line endings normalized so a CRLF checkout on Windows hashes the same. + content = CHANGELOG.read_bytes().replace(b"\r\n", b"\n") + self.assertEqual( + hashlib.sha256(content).hexdigest(), FROZEN_SHA256, + "CHANGELOG.md is frozen at 1.7.4: drop your edit and describe the change in the PR title " + "instead (release notes are generated from PR titles). A deliberate fix to the frozen text " + "updates FROZEN_SHA256 in the same PR.", ) - problems = unreleased_problems(text) - self.assertTrue(any("Fixed" in p and "twice" in p for p in problems), problems) - - def test_unknown_heading_is_reported(self): - text = CLEAN.replace("### Fixed", "### Fixes") - problems = unreleased_problems(text) - self.assertTrue(any("Fixes" in p for p in problems), problems) - - def test_entry_above_any_heading_is_reported(self): - text = CLEAN.replace( - "## [Unreleased]\n\n### Added", - "## [Unreleased]\n\n- **Orphan entry** - no heading above it.\n\n### Added", - ) - problems = unreleased_problems(text) - self.assertTrue(any("Orphan entry" in p for p in problems), problems) - - def test_conflict_markers_are_reported(self): - text = CLEAN.replace("### Fixed", "<<<<<<< HEAD\n### Fixed") - problems = unreleased_problems(text) - self.assertTrue(any("conflict marker" in p for p in problems), problems) - - def test_missing_unreleased_section_is_not_a_defect(self): - # Right after a release cut there may be no [Unreleased] heading at all - # (the 1.7.0 cut removed it). Nothing to check is not a failure. - text = "# Changelog\n\n## [1.7.1] - 2026-09-06\n\n### Fixed\n\n- **A fixed thing** - described.\n" - self.assertEqual(unreleased_problems(text), []) - - def test_released_sections_are_not_inspected(self): - # A duplicate heading in an old release is history, not a defect here. - text = CLEAN + "\n### Fixed\n\n- another old entry\n" - self.assertEqual(unreleased_problems(text), []) - - -class RealChangelogTests(unittest.TestCase): - def test_unreleased_section_is_well_formed(self): - text = CHANGELOG.read_text(encoding="utf-8") - self.assertEqual(unreleased_problems(text), []) if __name__ == "__main__": diff --git a/tests/test_release_workflow.py b/tests/test_release_workflow.py new file mode 100644 index 00000000..921ffd06 --- /dev/null +++ b/tests/test_release_workflow.py @@ -0,0 +1,33 @@ +"""The weekly release workflow can't run offline, so pin its safety properties. + +The version itself comes from commitizen (`cz bump --get-next`, .cz.toml). +""" +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +WORKFLOW = REPO / ".github" / "workflows" / "release.yml" + + +class ReleaseWorkflowTests(unittest.TestCase): + def setUp(self): + self.text = WORKFLOW.read_text(encoding="utf-8") + + def test_release_runs_only_on_upstream(self): + self.assertIn("if: github.repository == 'ai-job-search-dev/ai-job-search'", self.text) + + def test_write_access_is_granted_to_the_job_not_the_workflow(self): + top_level = self.text.split("\njobs:", 1)[0] + self.assertIn("permissions:\n contents: read", top_level) + self.assertIn("contents: write", self.text.split("\njobs:", 1)[1]) + + def test_actions_are_sha_pinned(self): + for line in self.text.splitlines(): + stripped = line.strip() + if stripped.startswith(("- uses:", "uses:")): + ref = stripped.split("uses:", 1)[1].strip() + self.assertRegex(ref.split("@", 1)[1].split()[0], r"^[0-9a-f]{40}$", f"action not SHA-pinned: {ref}") + + +if __name__ == "__main__": + unittest.main()