From 7f01d752516d955a694271f75ee5534bc344873a Mon Sep 17 00:00:00 2001 From: Jakob Stender Guldberg Date: Sat, 31 Oct 2026 14:28:12 +0100 Subject: [PATCH] ci: weekly SemVer releases with generated notes; freeze CHANGELOG.md at 1.7.4 (#800) * ci: publish a weekly SemVer release with GitHub-generated notes Every Monday at 06:00 UTC the workflow tags master and publishes a GitHub Release whose notes list the merged PR titles. tools/next_version.py picks the version from the commits since the last release: type!: or a BREAKING CHANGE footer is major, feat is minor, anything else is patch. A manual run can override it. Weeks without commits are skipped, a master commit whose CI did not pass is never released, and forks never run it. * docs: point contributors and forks at weekly GitHub Releases PR titles are now the release notes and their type sets the version, so CONTRIBUTING and the PR template say so. README and SETUP describe releases as weekly checkpoints that passed CI instead of pointing at CHANGELOG.md. * chore(changelog): freeze at 1.7.4 and fail PRs that still edit it Rolls [Unreleased] into [1.7.4] and rewrites the intro to point at GitHub Releases. The structure test becomes a content pin: stale PRs carry entries that git would merge cleanly into the frozen section, so any edit now fails CI with a pointer to the PR title. * fix(release): address review findings on the weekly release Write access moves to the job; the override rejects existing or older versions; next_version.py reports a missing tag instead of a traceback and drops the unused rev argument; tests pin the fork guard and SHA-pinned checkout. The frozen changelog drops the date written ahead of the release, describes its guard accurately, and the guard skips on forks. * chore(changelog): re-pin the frozen hash after rebasing onto master * ci(release): compute the version with commitizen via setup-uv instead of an in-repo script A week of only docs, chore or ci commits now ships nothing on its own, matching commitizen's conventional-commit bump rules; fix, refactor and perf bump patch. --- .cz.toml | 4 + .github/PULL_REQUEST_TEMPLATE.md | 4 + .github/workflows/release.yml | 88 ++++++++++++++++++ CHANGELOG.md | 27 ++++-- CONTRIBUTING.md | 1 + README.md | 2 +- SETUP.md | 2 +- tests/test_changelog_structure.py | 145 +++++------------------------- tests/test_release_workflow.py | 33 +++++++ 9 files changed, 175 insertions(+), 131 deletions(-) create mode 100644 .cz.toml create mode 100644 .github/workflows/release.yml create mode 100644 tests/test_release_workflow.py 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()