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.
This commit is contained in:
Jakob Stender Guldberg authored and GitHub committed 2026-10-31 14:28:12 +01:00
1 parent 83c788d779
commit 7f01d75251
9 files changed
+175 -131

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
[tool.commitizen]
name = "cz_conventional_commits"
version_provider = "scm"
tag_format = "v$version"
+4
View File
@@ -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. -->
<!-- The PR title becomes this change's line in the weekly release notes and
its type sets the version: feat = minor, type!: = major,
fix/refactor/perf = patch, docs/chore/ci alone = no release.
Don't edit CHANGELOG.md; it is frozen. -->
## What changed and why
+88
View File
@@ -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"
+20 -7
View File
@@ -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
+1
View File
@@ -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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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:**
+23 -122
View File
@@ -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__":
+33
View File
@@ -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()