mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-02 13:12:50 +00:00
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:
1 parent
83c788d779
commit
7f01d75251
9 files changed
+175
-131
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
[tool.commitizen]
|
||||||
|
name = "cz_conventional_commits"
|
||||||
|
version_provider = "scm"
|
||||||
|
tag_format = "v$version"
|
||||||
@@ -4,6 +4,10 @@
|
|||||||
Those adaptations live in forks - see CONTRIBUTING.md - and get
|
Those adaptations live in forks - see CONTRIBUTING.md - and get
|
||||||
discovered via the pinned "Community forks & adaptations" discussion (#78).
|
discovered via the pinned "Community forks & adaptations" discussion (#78).
|
||||||
Check the "base repository" dropdown above before you continue. -->
|
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
|
## What changed and why
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,17 +1,20 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project are documented here. The format is based on
|
**This file is frozen at 1.7.4.** Later releases are published weekly and documented in
|
||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres
|
[GitHub Releases](https://github.com/ai-job-search-dev/ai-job-search/releases), with notes
|
||||||
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
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,
|
The entries below follow [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
|
||||||
prefer updating to a tagged release over pulling raw `master` (see
|
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
|
[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
|
`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
|
files a release touched; `python3 tools/check_upstream_updates.py` lists them with
|
||||||
per-file diff commands.
|
per-file diff commands.
|
||||||
|
|
||||||
## [Unreleased]
|
## [1.7.4]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -25,6 +28,16 @@ per-file diff commands.
|
|||||||
|
|
||||||
### Changed
|
### 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
|
- **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
|
`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
|
`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
|
- **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.
|
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.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.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.7.1]: https://github.com/ai-job-search-dev/ai-job-search/compare/v1.7.0...v1.7.1
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ This is a decision, not a dogma: if cross-runtime standards mature to the point
|
|||||||
|
|
||||||
## Practical notes
|
## 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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ To wipe your profile data and start fresh:
|
|||||||
|
|
||||||
### Staying up to date
|
### 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
|
## Tips for better results
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
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`).
|
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:**
|
2. **Preview what changed before pulling:**
|
||||||
|
|||||||
@@ -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
|
Every PR used to add an entry under `## [Unreleased]`, and PRs opened before the
|
||||||
the same line. Two failure shapes have reached master or a merge queue:
|
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.
|
||||||
- a second `### Fixed` heading added directly under `## [Unreleased]` because
|
Pinning the file's content does: any edit fails here and points the author at
|
||||||
the author did not see the existing one further down (#425, fixed by hand at
|
the PR title, which is what the release notes are generated from.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
|
import hashlib
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parent.parent
|
try:
|
||||||
CHANGELOG = REPO / "CHANGELOG.md"
|
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"}
|
CHANGELOG = Path(__file__).resolve().parent.parent / "CHANGELOG.md"
|
||||||
CONFLICT_MARKERS = ("<<<<<<< ", "=======", ">>>>>>> ")
|
FROZEN_SHA256 = "5a4f9ce8270ad872a3154fb872a7e287c27023d68a1b58c6415e2ec21b4b3815"
|
||||||
|
|
||||||
|
|
||||||
def unreleased_block(text: str) -> str:
|
@unittest.skipIf(not is_upstream_checkout(), "the freeze guards the upstream changelog; forks may keep their own")
|
||||||
"""The lines between `## [Unreleased]` and the next `## [` heading.
|
class FrozenChangelogTests(unittest.TestCase):
|
||||||
|
def test_changelog_is_unchanged(self):
|
||||||
An absent heading (right after a release cut) yields an empty block:
|
# Line endings normalized so a CRLF checkout on Windows hashes the same.
|
||||||
nothing to check is not a defect."""
|
content = CHANGELOG.read_bytes().replace(b"\r\n", b"\n")
|
||||||
start = text.find("## [Unreleased]")
|
self.assertEqual(
|
||||||
if start == -1:
|
hashlib.sha256(content).hexdigest(), FROZEN_SHA256,
|
||||||
return ""
|
"CHANGELOG.md is frozen at 1.7.4: drop your edit and describe the change in the PR title "
|
||||||
end = text.find("\n## [", start + 1)
|
"instead (release notes are generated from PR titles). A deliberate fix to the frozen text "
|
||||||
return text[start:] if end == -1 else text[start:end]
|
"updates FROZEN_SHA256 in the same PR.",
|
||||||
|
|
||||||
|
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in new issue
Block a user