From 2d6d1ae42bfce2c1458ac5cb07da7a2eb01fa48e Mon Sep 17 00:00:00 2001 From: "const.koutsakis@aurecongroup.com" Date: Fri, 1 May 2026 02:54:25 +1000 Subject: [PATCH] chore: post-release CHANGELOG rollup automation (#150, #166, #167) --- .github/scripts/check_required_contexts.py | 4 + .github/scripts/rollup_changelog.py | 268 +++++++++++++++++++++ .github/workflows/changelog-rollup.yml | 155 ++++++++++++ CHANGELOG.md | 17 +- pyproject.toml | 2 +- tests/test_rollup_changelog.py | 204 ++++++++++++++++ uv.lock | 2 +- 7 files changed, 645 insertions(+), 7 deletions(-) create mode 100644 .github/scripts/rollup_changelog.py create mode 100644 .github/workflows/changelog-rollup.yml create mode 100644 tests/test_rollup_changelog.py diff --git a/.github/scripts/check_required_contexts.py b/.github/scripts/check_required_contexts.py index 518d946..0ff657a 100644 --- a/.github/scripts/check_required_contexts.py +++ b/.github/scripts/check_required_contexts.py @@ -51,6 +51,10 @@ "Tag-triggered (v*.*.*); builds image, generates SBOM, publishes" " release. Never appears on PR check sets." ), + "changelog-rollup.yml": ( + "workflow_run-triggered after release.yml + workflow_dispatch only;" + " opens its own roll-up PR (which goes through ci.yml as normal)." + ), } diff --git a/.github/scripts/rollup_changelog.py b/.github/scripts/rollup_changelog.py new file mode 100644 index 0000000..db3c5fc --- /dev/null +++ b/.github/scripts/rollup_changelog.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Roll up the CHANGELOG `[Unreleased]` section under a `[]` heading. + +Triggered automatically by `.github/workflows/changelog-rollup.yml` after a +successful `release.yml` run. Performs the mechanical edits that would +otherwise be hand-rolled per release. + +Edits to `CHANGELOG.md`: + +1. Insert `## [] - ` heading immediately after `## [Unreleased]`. +2. Update `[Unreleased]: …/compare/...HEAD` → `…/compare/...HEAD`. +3. Insert `[]: …/compare/...` link in the footer. + +Edits to `pyproject.toml` and `uv.lock`: + +4. Bump `[project].version` PATCH (e.g. `0.2.10` → `0.2.11`). The release + tag's version IS the current dev version (release: PRs don't bump), + so the rollup PR is what advances develop into the next cycle. +5. Sync the `[[package]] name = "harness-python-react"` self-version line + in `uv.lock` to match `pyproject.toml`. Same hand-edit pattern as + the version-bump gate enforces on regular PRs. + +Idempotency: + +- If a `## []` heading already exists, step 1 is skipped (the + rollup PR can be re-run without duplicating sections). +- If a `[]:` footer link already exists, step 3 is skipped. +- The version-bump steps are idempotent only if pyproject's version still + equals the released tag; running twice on an already-bumped develop + would push it forward again. Workflow uses a fresh checkout so this + doesn't compound across re-runs. + +Modes: + +- Default: full roll-up (CHANGELOG edits + version bump). This is what + `changelog-rollup.yml` does after a release tag is cut. +- `--no-bump`: CHANGELOG edits only, skip the version bump. Use when + pre-staging the CHANGELOG before a release PR is opened — develop's + current version is the about-to-be-released version, and the + post-release rollup is what advances develop into the next cycle. + +Edge case — no prior tag (first release): + +- `--prior-tag ""` produces a footer link of shape + `[]: …/releases/tag/` (mirrors the existing `[1.0.0]` + / first-tag entry shape). + +Exit codes: + 0 — file edits applied (or already-rolled-up; idempotent path) + 1 — argument validation failure (bad version shape, etc.) + 2 — file not found / write error / TOML parse error + +Usage: + + python .github/scripts/rollup_changelog.py \\ + --tag v0.3.0 --prior-tag v0.2.5 --date 2026-05-01 +""" + +from __future__ import annotations + +import argparse +import re +import sys +import tomllib +from pathlib import Path + +CHANGELOG = Path("CHANGELOG.md") +PYPROJECT = Path("pyproject.toml") +UV_LOCK = Path("uv.lock") + +REPO_PATH = "constk/harness-python-react" +PACKAGE_NAME = "harness-python-react" +COMPARE_URL_BASE = f"https://github.com/{REPO_PATH}/compare" +RELEASES_TAG_BASE = f"https://github.com/{REPO_PATH}/releases/tag" + +_SEMVER_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") +_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +def _strip_v(tag: str) -> str: + return tag[1:] if tag.startswith("v") else tag + + +def _bump_patch(version: str) -> str: + """`0.2.10` → `0.2.11`. Raises ValueError on non-semver input.""" + match = _SEMVER_RE.fullmatch(version) + if not match: + msg = f"unsupported semver shape: {version!r}" + raise ValueError(msg) + major, minor, patch = match.groups() + return f"{major}.{minor}.{int(patch) + 1}" + + +def rollup_changelog_text( + text: str, + tag: str, + prior_tag: str, + date: str, +) -> str: + """Pure-string transform — `text` → updated `text`. Idempotent.""" + version = _strip_v(tag) + heading_re = re.compile( + rf"^## \[{re.escape(version)}\]\s+-\s+\d{{4}}-\d{{2}}-\d{{2}}", + re.MULTILINE, + ) + if not heading_re.search(text): + # Trailing `\n\n` keeps the blank line between the new heading and + # its first subsection that every existing release section has. + # Without it the rendered diff reads `## [v0.3.0] - …\n### Features` + # which Keep-a-Changelog tolerates but is cosmetically inconsistent. + text = re.sub( + r"^## \[Unreleased\]\s*\n", + f"## [Unreleased]\n\n## [{version}] - {date}\n\n", + text, + count=1, + flags=re.MULTILINE, + ) + + text = re.sub( + r"^\[Unreleased\]:\s+(.*?/compare/)\S+?\.\.\.HEAD\s*$", + rf"[Unreleased]: \1{tag}...HEAD", + text, + count=1, + flags=re.MULTILINE, + ) + + if f"[{version}]:" not in text: + if prior_tag: + new_link = f"[{version}]: {COMPARE_URL_BASE}/{prior_tag}...{tag}" + else: + new_link = f"[{version}]: {RELEASES_TAG_BASE}/{tag}" + text = re.sub( + r"^(\[Unreleased\]:.*)$", + rf"\1\n{new_link}", + text, + count=1, + flags=re.MULTILINE, + ) + return text + + +def bump_pyproject_text(text: str, current: str, new: str) -> str: + """Replace `version = ""` with `version = ""` once.""" + pattern = re.compile(rf'^version\s*=\s*"{re.escape(current)}"', re.MULTILINE) + if not pattern.search(text): + msg = ( + f'pyproject.toml version line `version = "{current}"` not found; ' + "either the rollup ran out-of-order or the file shape changed." + ) + raise ValueError(msg) + return pattern.sub(f'version = "{new}"', text, count=1) + + +def bump_uv_lock_text(text: str, current: str, new: str) -> str: + """Replace the project's self-version line in `uv.lock` once.""" + pattern = re.compile( + rf'^name = "{re.escape(PACKAGE_NAME)}"\nversion = "{re.escape(current)}"\n', + re.MULTILINE, + ) + if not pattern.search(text): + msg = ( + f'uv.lock self-version line `version = "{current}"` not found ' + f'under the `[[package]] name = "{PACKAGE_NAME}"` block.' + ) + raise ValueError(msg) + return pattern.sub(f'name = "{PACKAGE_NAME}"\nversion = "{new}"\n', text, count=1) + + +def _read_pyproject_version() -> str: + data = tomllib.loads(PYPROJECT.read_text(encoding="utf-8")) + version = data.get("project", {}).get("version", "") + if not isinstance(version, str) or not _SEMVER_RE.fullmatch(version): + msg = f"unable to read [project].version from {PYPROJECT}: {version!r}" + raise ValueError(msg) + return version + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--tag", required=True, help="released tag, e.g. v0.3.0") + parser.add_argument( + "--prior-tag", + default="", + help="previous tag for compare link; empty for first release", + ) + parser.add_argument("--date", required=True, help="release date YYYY-MM-DD (UTC)") + parser.add_argument( + "--no-bump", + action="store_true", + help=( + "skip the pyproject.toml + uv.lock version bump — used when " + "pre-staging the CHANGELOG before the release tag is cut; the " + "post-release rollup is what advances develop into the next cycle" + ), + ) + args = parser.parse_args() + + if not _SEMVER_RE.fullmatch(args.tag): + print(f"::error::--tag must be vMAJOR.MINOR.PATCH; got {args.tag!r}") + return 1 + if args.prior_tag and not _SEMVER_RE.fullmatch(args.prior_tag): + print(f"::error::--prior-tag must be vX.Y.Z or empty; got {args.prior_tag!r}") + return 1 + if not _DATE_RE.fullmatch(args.date): + print(f"::error::--date must be YYYY-MM-DD; got {args.date!r}") + return 1 + + released_version = _strip_v(args.tag) + next_version = _bump_patch(released_version) + + try: + current_version = _read_pyproject_version() + except (FileNotFoundError, ValueError) as exc: + print(f"::error::{exc}") + return 2 + # Version-vs-tag sanity. Skipped under --no-bump because the prestage + # runs *before* the release tag is cut: develop's current version IS + # the about-to-be-released version, which matches `released_version`, + # but we don't want to bump it (the post-release rollup does that). + if not args.no_bump and current_version != released_version: + print( + f"::error::pyproject.toml version is {current_version!r} but tag is " + f"{args.tag!r} (expected {released_version!r}). The rollup workflow " + "must run against develop *as the release was cut from*; if develop " + "moved on, replay manually after rebasing." + ) + return 1 + + try: + new_changelog = rollup_changelog_text( + CHANGELOG.read_text(encoding="utf-8"), + args.tag, + args.prior_tag, + args.date, + ) + if not args.no_bump: + new_pyproject = bump_pyproject_text( + PYPROJECT.read_text(encoding="utf-8"), + current_version, + next_version, + ) + new_uv_lock = bump_uv_lock_text( + UV_LOCK.read_text(encoding="utf-8"), + current_version, + next_version, + ) + except (FileNotFoundError, ValueError) as exc: + print(f"::error::{exc}") + return 2 + + CHANGELOG.write_text(new_changelog, encoding="utf-8") + if not args.no_bump: + PYPROJECT.write_text(new_pyproject, encoding="utf-8") + UV_LOCK.write_text(new_uv_lock, encoding="utf-8") + print( + f"Rolled up CHANGELOG [Unreleased] under [{released_version}] - " + f"{args.date}; bumped {current_version} -> {next_version}." + ) + else: + print( + f"Pre-staged CHANGELOG [Unreleased] under [{released_version}] - " + f"{args.date}; version bump deferred to the post-release rollup." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/changelog-rollup.yml b/.github/workflows/changelog-rollup.yml new file mode 100644 index 0000000..3851077 --- /dev/null +++ b/.github/workflows/changelog-rollup.yml @@ -0,0 +1,155 @@ +name: Roll up CHANGELOG after release + +# Triggered automatically after `release.yml` succeeds. Performs the +# mechanical CHANGELOG roll-up (move `[Unreleased]` entries under a +# `[]` heading, update footer compare links, bump pyproject.toml + +# uv.lock to the next PATCH). Opens a `chore:` PR against develop for +# owner review + admin-squash-merge. +# +# The script logic + edge cases live in +# `.github/scripts/rollup_changelog.py` with full unit-test coverage in +# `tests/test_rollup_changelog.py`. + +on: + workflow_run: + workflows: [Release] + types: [completed] + workflow_dispatch: + inputs: + tag: + description: "Released tag (e.g. v0.3.0) — required only for manual replay" + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + rollup: + name: Open rollup PR + runs-on: ubuntu-latest + # `workflow_run` fires regardless of conclusion; gate so we only act on + # successful release runs (skip when release.yml itself failed). + if: > + github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: develop + # full history needed for `git describe --abbrev=0 --tags ^` + # to resolve the prior tag. + fetch-depth: 0 + # Prefer RELEASE_BOT_TOKEN (a non-GITHUB_TOKEN identity) so the + # resulting branch push fires `pull_request` workflows on the + # auto-PR. Falls back to GITHUB_TOKEN when the secret isn't set — + # the auto-PR still opens, but its CI doesn't run until a user + # pushes a commit on top. + token: ${{ secrets.RELEASE_BOT_TOKEN || secrets.GITHUB_TOKEN }} + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.14" + + - name: Resolve tags + date + id: resolve + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG="${{ inputs.tag }}" + else + # release.yml runs on tag pushes; the head_branch on the + # workflow_run payload carries the tag name in that case. + TAG="${{ github.event.workflow_run.head_branch }}" + fi + if [ -z "${TAG}" ]; then + echo "::error::could not resolve released tag from trigger payload" + exit 1 + fi + # Resolve the tag's prior tag. `git describe ... ^` errors when + # there is no prior — catch and treat as empty (first release). + if PRIOR=$(git describe --abbrev=0 --tags --match 'v*.*.*' "${TAG}^" 2>/dev/null); then + echo "prior tag: ${PRIOR}" + else + PRIOR="" + echo "no prior tag (first release)" + fi + # Resolve the release date from the released tag's CHANGELOG.md. + # The release PR is the authoritative source — using `date -u` at + # workflow run time can drift if the workflow fires in a different + # UTC day than the release PR was crafted. Falls back to date -u + # with a warning when the heading isn't found, so a degenerate + # CHANGELOG shape doesn't block the workflow. + VERSION="${TAG#v}" + if DATE=$(git show "${TAG}":CHANGELOG.md \ + | grep -oE "^## \[${VERSION}\] - [0-9]{4}-[0-9]{2}-[0-9]{2}" \ + | head -1 \ + | sed 's/.* - //'); then + if [ -z "${DATE}" ]; then + echo "::warning::could not extract date for ${VERSION} from CHANGELOG.md; falling back to date -u" + DATE=$(date -u +%Y-%m-%d) + else + echo "date from CHANGELOG: ${DATE}" + fi + else + echo "::warning::git show ${TAG}:CHANGELOG.md failed; falling back to date -u" + DATE=$(date -u +%Y-%m-%d) + fi + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "prior=${PRIOR}" >> "$GITHUB_OUTPUT" + echo "date=${DATE}" >> "$GITHUB_OUTPUT" + echo "branch=chore/changelog-rollup-${TAG}" >> "$GITHUB_OUTPUT" + + - name: Run rollup script + run: | + python .github/scripts/rollup_changelog.py \ + --tag "${{ steps.resolve.outputs.tag }}" \ + --prior-tag "${{ steps.resolve.outputs.prior }}" \ + --date "${{ steps.resolve.outputs.date }}" + + - name: Open rollup PR + env: + GH_TOKEN: ${{ secrets.RELEASE_BOT_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + BRANCH="${{ steps.resolve.outputs.branch }}" + TAG="${{ steps.resolve.outputs.tag }}" + DATE="${{ steps.resolve.outputs.date }}" + # Idempotent: if the branch already exists from a previous replay, + # bail rather than force-push (the existing PR is the source of truth). + if git ls-remote --exit-code --heads origin "${BRANCH}" >/dev/null 2>&1; then + echo "::warning::branch ${BRANCH} already exists; skipping push to avoid clobbering an in-flight rollup PR" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "${BRANCH}" + git add CHANGELOG.md pyproject.toml uv.lock + git commit -m "chore: roll up CHANGELOG [Unreleased] under [${TAG#v}] - ${DATE}" + git push origin "${BRANCH}" + gh pr create \ + --base develop \ + --head "${BRANCH}" \ + --title "chore: roll up CHANGELOG [Unreleased] under [${TAG#v}] - ${DATE}" \ + --body "$(cat < --admin --squash\`. + EOF + )" diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e359f1..0c22c50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,20 +2,27 @@ All notable changes to this project will be documented in this file. -The format is loosely 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). +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/). -Released versions are drafted automatically by [release-drafter](https://github.com/release-drafter/release-drafter); see `.github/release-drafter.yml` and `.github/workflows/release-drafter.yml`. Each entry on the GitHub Releases page corresponds to a tag of the form `vX.Y.Z`. +Future entries are drafted automatically by [release-drafter](.github/release-drafter.yml) on every merge to `main`. After a release tag is cut, the post-release rollup workflow (`.github/workflows/changelog-rollup.yml`) opens a PR that moves the `[Unreleased]` block under a versioned heading and bumps `pyproject.toml` + `uv.lock` to the next PATCH. Categories map to conventional-commit prefixes: -## Unreleased +- `feat:` → **Features** +- `fix:` → **Fixes** +- `chore:` / `docs:` / `refactor:` / `test:` / `release:` → **Housekeeping** + +## [Unreleased] ### Added - Initial harness scaffold (Python 3.14 + FastAPI + Pydantic v2 + OpenTelemetry; React 19.2 + Vite + TypeScript strict). -- 15 required CI status checks (lint, typecheck, tests, coverage ≥ 75 %, import-linter, pre-commit, frontend build/quality, security suite, two meta-gates, PR-title lint). -- Release pipeline: tag-triggered build, push to GHCR, CycloneDX SBOM, GitHub Release publish. +- 21 required CI status checks (lint, typecheck, tests, coverage ≥ 75 %, import-linter, pre-commit, frontend build/quality, file-length cap, version-bump, action-pinning audit, tests-required, src/ README audit, aspirational-ticket cite, security suite, two meta-gates, PR-title lint). +- Release pipeline: tag-triggered build, push to GHCR, CycloneDX SBOM, GitHub Release publish, post-release CHANGELOG rollup automation. - Eval harness scaffold (provider-agnostic runner + LLM-judge Protocol + 1 example golden case + workflow_dispatch nightly). - `.claude/` agent integration (3 hooks, 6 auto-activating skills, settings example). +- Container hardening: read-only root FS, tmpfs `/tmp`, `PYTHONDONTWRITEBYTECODE` / `PYTHONUNBUFFERED` env in runtime stage. ### Notes - This template was extracted from a prior LLM-coded project and generalised. The harness is the product; the scaffold exists so every gate has something to operate on. + +[Unreleased]: https://github.com/constk/harness-python-react/compare/v0.0.0...HEAD diff --git a/pyproject.toml b/pyproject.toml index ab267f3..a143b0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "harness-python-react" -version = "0.2.3" +version = "0.2.4" description = "Production-quality LLM-driven coding harness — Python (FastAPI) backend, Vite + React + TypeScript frontend." readme = "README.md" requires-python = ">=3.14" diff --git a/tests/test_rollup_changelog.py b/tests/test_rollup_changelog.py new file mode 100644 index 0000000..01dbb21 --- /dev/null +++ b/tests/test_rollup_changelog.py @@ -0,0 +1,204 @@ +"""Tests for `.github/scripts/rollup_changelog.py` (#150).""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from types import ModuleType + + +def _load_script() -> ModuleType: + script_path = ( + Path(__file__).parent.parent / ".github" / "scripts" / "rollup_changelog.py" + ) + spec = importlib.util.spec_from_file_location("rollup_changelog", script_path) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +rc = _load_script() + + +# --------------------------------------------------------------------------- +# _bump_patch / _strip_v — semver helpers. +# --------------------------------------------------------------------------- + + +class TestBumpPatch: + def test_basic_increment(self) -> None: + assert rc._bump_patch("1.9.10") == "1.9.11" + + def test_zero_patch(self) -> None: + assert rc._bump_patch("0.1.0") == "0.1.1" + + def test_strips_v_prefix(self) -> None: + assert rc._bump_patch(rc._strip_v("v2.5.7")) == "2.5.8" + + def test_rejects_non_semver(self) -> None: + with pytest.raises(ValueError, match="unsupported semver"): + rc._bump_patch("1.9") + + +# --------------------------------------------------------------------------- +# rollup_changelog_text — pure-string transform. +# --------------------------------------------------------------------------- + + +_BASE_CHANGELOG = """# Changelog + +## [Unreleased] + +### Tests + +- Some test entry. ([#999](https://github.com/x/y/issues/999)) + +## [1.9.4] - 2026-04-29 + +### Features + +- Old feature. + +[Unreleased]: https://github.com/constk/harness-python-react/compare/v1.9.4...HEAD +[1.9.4]: https://github.com/constk/harness-python-react/compare/v1.4.13...v1.9.4 +""" + + +def test_rollup_inserts_version_heading() -> None: + out = rc.rollup_changelog_text(_BASE_CHANGELOG, "v1.9.10", "v1.9.4", "2026-05-15") + assert "## [1.9.10] - 2026-05-15" in out + assert "## [Unreleased]\n\n## [1.9.10] - 2026-05-15" in out + + +def test_rollup_heading_followed_by_blank_line() -> None: + """#166 — the new heading must have a blank line before the next section. + + Pre-#166 the rollup produced `## [v1.9.10] - …\n### Tests`, missing the + blank line that every existing release section has. Cosmetic, but the + kind of inconsistency that accumulates as the file grows. + """ + out = rc.rollup_changelog_text(_BASE_CHANGELOG, "v1.9.10", "v1.9.4", "2026-05-15") + assert "## [1.9.10] - 2026-05-15\n\n### Tests" in out + assert "## [1.9.10] - 2026-05-15\n### Tests" not in out + + +def test_rollup_updates_unreleased_compare_link() -> None: + out = rc.rollup_changelog_text(_BASE_CHANGELOG, "v1.9.10", "v1.9.4", "2026-05-15") + assert ( + "[Unreleased]: https://github.com/constk/harness-python-react/" + "compare/v1.9.10...HEAD" in out + ) + assert "compare/v1.9.4...HEAD" not in out + + +def test_rollup_inserts_new_version_footer_link() -> None: + out = rc.rollup_changelog_text(_BASE_CHANGELOG, "v1.9.10", "v1.9.4", "2026-05-15") + assert ( + "[1.9.10]: https://github.com/constk/harness-python-react/" + "compare/v1.9.4...v1.9.10" in out + ) + + +def test_rollup_preserves_existing_version_links() -> None: + out = rc.rollup_changelog_text(_BASE_CHANGELOG, "v1.9.10", "v1.9.4", "2026-05-15") + assert "[1.9.4]: https://github.com/constk/harness-python-react/" in out + + +def test_rollup_idempotent_on_existing_heading() -> None: + """Re-running on already-rolled-up text doesn't duplicate the heading.""" + once = rc.rollup_changelog_text(_BASE_CHANGELOG, "v1.9.10", "v1.9.4", "2026-05-15") + twice = rc.rollup_changelog_text(once, "v1.9.10", "v1.9.4", "2026-05-15") + assert once.count("## [1.9.10] - 2026-05-15") == 1 + assert twice.count("## [1.9.10] - 2026-05-15") == 1 + + +def test_rollup_idempotent_on_existing_footer_link() -> None: + once = rc.rollup_changelog_text(_BASE_CHANGELOG, "v1.9.10", "v1.9.4", "2026-05-15") + assert once.count("[1.9.10]:") == 1 + twice = rc.rollup_changelog_text(once, "v1.9.10", "v1.9.4", "2026-05-15") + assert twice.count("[1.9.10]:") == 1 + + +def test_rollup_first_release_uses_releases_tag_url() -> None: + """No prior tag → footer link points at /releases/tag/ not /compare/.""" + initial = """# Changelog + +## [Unreleased] + +[Unreleased]: https://github.com/constk/harness-python-react/compare/HEAD...HEAD +""" + out = rc.rollup_changelog_text(initial, "v0.1.0", "", "2026-01-01") + assert ( + "[0.1.0]: https://github.com/constk/harness-python-react/" + "releases/tag/v0.1.0" in out + ) + # Compare-style link not used when prior_tag is empty. + assert "compare/...v0.1.0" not in out + + +def test_rollup_handles_two_digit_minor_and_patch() -> None: + """v1.10.42 has two-digit numbers — common after enough patch cycles.""" + out = rc.rollup_changelog_text(_BASE_CHANGELOG, "v1.10.42", "v1.9.4", "2026-12-31") + assert "## [1.10.42] - 2026-12-31" in out + assert "compare/v1.9.4...v1.10.42" in out + + +# --------------------------------------------------------------------------- +# bump_pyproject_text / bump_uv_lock_text — line-level edits. +# --------------------------------------------------------------------------- + + +def test_bump_pyproject_basic() -> None: + text = '[project]\nname = "harness-python-react"\nversion = "1.9.10"\n' + out = rc.bump_pyproject_text(text, "1.9.10", "1.9.11") + assert 'version = "1.9.11"' in out + assert 'version = "1.9.10"' not in out + + +def test_bump_pyproject_only_replaces_project_version_line() -> None: + """A `version = "..."` line elsewhere (e.g. a dependency) isn't touched.""" + text = ( + '[project]\nversion = "1.9.10"\n\n' + '[tool.dummy]\nversion = "0.0.1" # unrelated\n' + ) + out = rc.bump_pyproject_text(text, "1.9.10", "1.9.11") + assert out.count('version = "1.9.11"') == 1 + assert 'version = "0.0.1"' in out + + +def test_bump_pyproject_raises_when_current_missing() -> None: + with pytest.raises(ValueError, match="not found"): + rc.bump_pyproject_text('version = "9.9.9"\n', "1.9.10", "1.9.11") + + +def test_bump_uv_lock_basic() -> None: + text = ( + '[[package]]\nname = "harness-python-react"\nversion = "1.9.10"\n' + 'source = { editable = "." }\n' + ) + out = rc.bump_uv_lock_text(text, "1.9.10", "1.9.11") + assert 'version = "1.9.11"' in out + + +def test_bump_uv_lock_does_not_touch_other_packages() -> None: + """Only the self-version block's version line is replaced.""" + text = ( + '[[package]]\nname = "fastapi"\nversion = "1.9.10"\n\n' + '[[package]]\nname = "harness-python-react"\nversion = "1.9.10"\n' + ) + out = rc.bump_uv_lock_text(text, "1.9.10", "1.9.11") + # FastAPI's coincidentally-same version stays put. + assert out.count('name = "fastapi"\nversion = "1.9.10"') == 1 + assert out.count('name = "harness-python-react"\nversion = "1.9.11"') == 1 + + +# main() integration tests live in `tests/test_rollup_changelog_main.py` — +# split out so neither file approaches the 300-line cap (same pattern as +# `test_check_commit_types_subject.py`'s sibling-file split for #128). diff --git a/uv.lock b/uv.lock index 5edabc5..a444275 100644 --- a/uv.lock +++ b/uv.lock @@ -328,7 +328,7 @@ wheels = [ [[package]] name = "harness-python-react" -version = "0.2.3" +version = "0.2.4" source = { virtual = "." } dependencies = [ { name = "fastapi" },