From 8872b8be3b76637925e209562ccc50c4580977b1 Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Thu, 16 Apr 2026 23:38:29 -0500 Subject: [PATCH 1/3] fix: take highest release bump across unreleased commits Determine the release bump from all unreleased commits since the previous release tag and apply the highest required semantic version increment. This keeps feat commits at a minor bump unless a breaking change requires major, even when later patch-level commits are present. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 20 ------- docs/content/docs/releases.mdx | 6 +-- headroom/release_version.py | 98 +++++++++++++++++++++++++++++++++- tests/test_release_version.py | 44 +++++++++++++++ 4 files changed, 143 insertions(+), 25 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63a06ad97..768d2e6bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,36 +61,16 @@ jobs: height: ${{ steps.ver.outputs.height }} bump: ${{ steps.ver.outputs.bump }} previous_tag: ${{ steps.ver.outputs.previous_tag }} - commit_message: ${{ steps.bump.outputs.commit_message }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Detect bump level - id: bump - run: | - python - <<'PYEOF' - import subprocess, os, re - - msg = subprocess.run(["git", "log", "-1", "--format=%s"], capture_output=True, text=True, check=False).stdout.strip() - body = subprocess.run(["git", "log", "-1", "--format=%B"], capture_output=True, text=True, check=False).stdout.strip() - - is_major = bool(re.search(r'^feat!', msg)) or "BREAKING CHANGE" in body - is_minor = bool(re.search(r'^feat:', msg)) and not is_major - level = "major" if is_major else ("minor" if is_minor else "patch") - - with open(os.environ["GITHUB_OUTPUT"], "a") as f: - f.write(f"commit_message={msg}\n") - f.write(f"level={level}\n") - PYEOF - - name: Compute semantic version from canonical + release history id: ver run: | python headroom/release_version.py env: - LEVEL: ${{ steps.bump.outputs.level }} MANUAL_VER: ${{ github.event.inputs.version }} build: diff --git a/docs/content/docs/releases.mdx b/docs/content/docs/releases.mdx index a49df2936..44685e31b 100644 --- a/docs/content/docs/releases.mdx +++ b/docs/content/docs/releases.mdx @@ -36,13 +36,13 @@ The workflow uses a **canonical + commit-height** algorithm that eliminates infi ## Conventional Commits & Semantic Bumping -The workflow analyzes the **latest commit** to determine the bump level: +The workflow analyzes the **unreleased commits since the previous release tag** and applies the highest required bump level: | Commit | Bump | Git Tag Example | npm Version | |--------|------|-----------------|-------------| | `fix:`, `ci:`, `chore:`, `perf:`, `refactor:` | patch | `v0.5.25.3` | `0.5.26` | | `feat:` | minor | `v0.6.0.0` | `0.6.0` | -| `feat!:` or `feat:` + `BREAKING CHANGE` in body | major | `v1.0.0.0` | `1.0.0` | +| Any conventional commit with `!` or any commit with `BREAKING CHANGE` in the body | major | `v1.0.0.0` | `1.0.0` | Commits are linted in CI via `commitlint` using `@commitlint/config-conventional`. @@ -74,7 +74,7 @@ detect-version → build → publish-pypi **Note:** The workflow never commits back to the repo. `pyproject.toml` is updated manually before merging. ### detect-version -Reads canonical version from `pyproject.toml`, counts commits since last `v{canonical}.*` tag, determines bump level from the latest commit message, and computes both the git tag version (`v{canonical}.{height}`) and npm version (3-part semver). +Reads canonical version from `pyproject.toml`, counts commits since the previous release tag, chooses the highest bump level across that unreleased commit range, and computes both the git tag version (`v{canonical}.{height}`) and npm version (3-part semver). ### build 1. Syncs version across TypeScript package files via `scripts/version-sync.py --version {npm_version}` diff --git a/headroom/release_version.py b/headroom/release_version.py index 9a431b9a8..b6268819c 100644 --- a/headroom/release_version.py +++ b/headroom/release_version.py @@ -11,6 +11,14 @@ from pathlib import Path SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") RELEASE_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)(?:\.(\d+))?$") +CONVENTIONAL_COMMIT_RE = re.compile( + r"^(feat|fix|ci|chore|perf|refactor|docs|style|test)(\(.+\))?(!)?:\s*(.+)$" +) +BREAKING_CHANGE_RE = re.compile(r"^BREAKING CHANGE:\s*(.+)$", re.MULTILINE) +FIELD_SEP = "\x1f" +RECORD_SEP = "\x1e" +GIT_LOG_FORMAT = "%s%x1f%b%x1e" +BUMP_PRIORITY = {"patch": 0, "minor": 1, "major": 2} @dataclass(frozen=True, order=True) @@ -72,6 +80,14 @@ class ReleaseTag: raw: str = "" +@dataclass(frozen=True) +class CommitInfo: + """Commit subject/body pair used for bump detection.""" + + subject: str + body: str = "" + + def parse_release_tag(tag: str) -> ReleaseTag: """Parse a release tag, preserving legacy fourth-component ordering.""" @@ -105,6 +121,53 @@ def find_latest_release_tag(tags: Sequence[str]) -> str | None: return candidates[0].raw +def _merge_summary(subject: str, body: str) -> str: + """Return the first meaningful body line for merge commits.""" + + if not subject.startswith("Merge "): + return "" + for line in body.splitlines(): + stripped = line.strip() + if stripped: + return stripped + return "" + + +def classify_commit_bump(commit: CommitInfo) -> str: + """Classify one commit using conventional commit semantics.""" + + merge_summary = _merge_summary(commit.subject, commit.body) + candidates = [commit.subject] + if merge_summary: + candidates.insert(0, merge_summary) + + has_breaking_change = bool(BREAKING_CHANGE_RE.search(commit.body)) + for candidate in candidates: + match = CONVENTIONAL_COMMIT_RE.match(candidate) + if not match: + continue + if has_breaking_change or bool(match.group(3)): + return "major" + if match.group(1) == "feat": + return "minor" + return "patch" + + if has_breaking_change: + return "major" + return "patch" + + +def determine_bump_level(commits: Sequence[CommitInfo]) -> str: + """Return the highest required bump across a commit range.""" + + level = "patch" + for commit in commits: + candidate = classify_commit_bump(commit) + if BUMP_PRIORITY[candidate] > BUMP_PRIORITY[level]: + level = candidate + return level + + def compute_release_version( canonical_version: str, level: str, @@ -167,6 +230,33 @@ def list_release_tags(root: Path) -> list[str]: return [tag.strip() for tag in result.stdout.splitlines() if tag.strip()] +def list_release_commits(root: Path, previous_tag: str) -> list[CommitInfo]: + """List commit subject/body pairs since the previous release tag.""" + + cmd = ["git", "log", "--first-parent", f"--pretty=format:{GIT_LOG_FORMAT}"] + if previous_tag: + cmd.append(f"{previous_tag}..HEAD") + else: + cmd.append("HEAD") + + result = subprocess.run( + cmd, + cwd=root, + check=True, + capture_output=True, + text=True, + ) + + commits: list[CommitInfo] = [] + for raw_entry in result.stdout.split(RECORD_SEP): + entry = raw_entry.strip() + if not entry or FIELD_SEP not in entry: + continue + subject, body = entry.split(FIELD_SEP, 1) + commits.append(CommitInfo(subject=subject.strip(), body=body.strip())) + return commits + + def commit_height_since(root: Path, previous_tag: str) -> str: """Count commits since the previous release tag for changelog/debug outputs.""" @@ -194,12 +284,16 @@ def write_github_outputs(info: ReleaseVersionInfo, output_path: str) -> None: def main() -> None: root = Path.cwd() manual_version = os.environ.get("MANUAL_VER", "").strip() - level = os.environ.get("LEVEL", "patch").strip() or "patch" + tags = list_release_tags(root) + previous_tag = find_latest_release_tag(tags) or "" + level = os.environ.get("LEVEL", "").strip() + if not level: + level = determine_bump_level(list_release_commits(root, previous_tag)) info = compute_release_version( canonical_version=get_canonical_version(root), level=level, - tags=list_release_tags(root), + tags=tags, manual_version=manual_version, ) info = replace(info, height=commit_height_since(root, info.previous_tag)) diff --git a/tests/test_release_version.py b/tests/test_release_version.py index 1325cbf51..81f2751db 100644 --- a/tests/test_release_version.py +++ b/tests/test_release_version.py @@ -8,8 +8,12 @@ from pathlib import Path import pytest from headroom.release_version import ( + CommitInfo, + classify_commit_bump, compute_release_version, + determine_bump_level, find_latest_release_tag, + list_release_commits, normalize_release_tag, parse_release_tag, ) @@ -101,6 +105,46 @@ def test_parse_release_tag_preserves_legacy_height_for_sorting() -> None: assert tag.legacy_height == 3 +def test_classify_commit_bump_treats_breaking_change_as_major() -> None: + assert ( + classify_commit_bump( + CommitInfo(subject="fix(api)!: change response shape", body=""), + ) + == "major" + ) + + +def test_determine_bump_level_uses_greatest_commit_level() -> None: + commits = [ + CommitInfo(subject="fix: patch one", body=""), + CommitInfo(subject="feat: add capability", body=""), + CommitInfo(subject="chore: maintenance", body=""), + ] + + assert determine_bump_level(commits) == "minor" + + +def test_determine_bump_level_prefers_major_over_minor_and_patch() -> None: + commits = [ + CommitInfo(subject="fix: patch one", body=""), + CommitInfo(subject="feat: add capability", body=""), + CommitInfo( + subject="docs: update migration guide", + body="BREAKING CHANGE: the API changed", + ), + ] + + assert determine_bump_level(commits) == "major" + + +def test_list_release_commits_returns_first_parent_range() -> None: + commits = list_release_commits(ROOT, "") + + assert commits + assert isinstance(commits[0], CommitInfo) + assert commits[0].subject + + def test_release_version_script_runs_directly_without_importing_headroom_package( tmp_path: Path, ) -> None: From 799e1c3a304685c7df412bb3f8159c8434b4781c Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Thu, 16 Apr 2026 23:46:06 -0500 Subject: [PATCH 2/3] fix: preserve empty-body commits in release bump parsing Do not strip the raw git-log record before splitting on the field separator, because commits with empty bodies lose their delimiter and get dropped entirely. Add a deterministic unit test for empty-body parsing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- headroom/release_version.py | 5 ++--- tests/test_release_version.py | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/headroom/release_version.py b/headroom/release_version.py index b6268819c..951a8bfc0 100644 --- a/headroom/release_version.py +++ b/headroom/release_version.py @@ -249,10 +249,9 @@ def list_release_commits(root: Path, previous_tag: str) -> list[CommitInfo]: commits: list[CommitInfo] = [] for raw_entry in result.stdout.split(RECORD_SEP): - entry = raw_entry.strip() - if not entry or FIELD_SEP not in entry: + if not raw_entry or FIELD_SEP not in raw_entry: continue - subject, body = entry.split(FIELD_SEP, 1) + subject, body = raw_entry.split(FIELD_SEP, 1) commits.append(CommitInfo(subject=subject.strip(), body=body.strip())) return commits diff --git a/tests/test_release_version.py b/tests/test_release_version.py index 81f2751db..52bb0fcac 100644 --- a/tests/test_release_version.py +++ b/tests/test_release_version.py @@ -4,6 +4,7 @@ import os import subprocess import sys from pathlib import Path +from unittest.mock import Mock import pytest @@ -137,12 +138,21 @@ def test_determine_bump_level_prefers_major_over_minor_and_patch() -> None: assert determine_bump_level(commits) == "major" -def test_list_release_commits_returns_first_parent_range() -> None: +def test_list_release_commits_parses_empty_body_entries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + run = Mock() + run.return_value = Mock( + stdout="feat: add capability\x1f\x1efix: patch bug\x1fbody text\x1e", + ) + monkeypatch.setattr("headroom.release_version.subprocess.run", run) + commits = list_release_commits(ROOT, "") - assert commits - assert isinstance(commits[0], CommitInfo) - assert commits[0].subject + assert commits == [ + CommitInfo(subject="feat: add capability", body=""), + CommitInfo(subject="fix: patch bug", body="body text"), + ] def test_release_version_script_runs_directly_without_importing_headroom_package( From bde7aa9c30333af3424225cfb45bacfcd39c4a6b Mon Sep 17 00:00:00 2001 From: JerrettDavis Date: Fri, 17 Apr 2026 12:17:49 -0500 Subject: [PATCH 3/3] fix: align docker image versions with releases Derive the exact Docker image version from the release tag or manual workflow input, sync versioned files in the build workspace before the image build, and publish an explicit matching image tag. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/docker.yml | 28 ++++++++++++++++++++++++++++ docs/content/docs/releases.mdx | 3 +++ wiki/docker-install.md | 2 ++ 3 files changed, 33 insertions(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 5eaf83986..f7311cd47 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -2,6 +2,10 @@ name: Docker on: workflow_dispatch: + inputs: + version: + description: "Version to stamp into the image contents and exact image tag" + required: false release: types: [published] @@ -41,6 +45,29 @@ jobs: steps: - uses: actions/checkout@v6 + - name: Determine image version + id: version + env: + MANUAL_VERSION: ${{ github.event.inputs.version }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + version="${MANUAL_VERSION#v}" + if [ -z "$version" ] && [ -n "$RELEASE_TAG" ]; then + version="${RELEASE_TAG#v}" + fi + printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" + + - name: Set up Python + if: steps.version.outputs.version != '' + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Sync versioned files for image build + if: steps.version.outputs.version != '' + run: | + python scripts/version-sync.py --version ${{ steps.version.outputs.version }} + - name: Set up QEMU uses: docker/setup-qemu-action@v4 @@ -62,6 +89,7 @@ jobs: tags: | type=ref,event=branch,suffix=${{ matrix.variant != '' && format('-{0}', matrix.variant) || '' }} type=ref,event=pr,suffix=${{ matrix.variant != '' && format('-{0}', matrix.variant) || '' }} + type=raw,value=${{ steps.version.outputs.version }},enable=${{ steps.version.outputs.version != '' }},suffix=${{ matrix.variant != '' && format('-{0}', matrix.variant) || '' }} type=semver,pattern={{version}},suffix=${{ matrix.variant != '' && format('-{0}', matrix.variant) || '' }} type=semver,pattern={{major}}.{{minor}},suffix=${{ matrix.variant != '' && format('-{0}', matrix.variant) || '' }} type=semver,pattern={{major}},suffix=${{ matrix.variant != '' && format('-{0}', matrix.variant) || '' }} diff --git a/docs/content/docs/releases.mdx b/docs/content/docs/releases.mdx index 44685e31b..6d43b8e3a 100644 --- a/docs/content/docs/releases.mdx +++ b/docs/content/docs/releases.mdx @@ -7,6 +7,8 @@ description: Automated release pipeline with semantic versioning, multi-package Headroom uses a unified GitHub Actions workflow (`.github/workflows/release.yml`) that automatically publishes all three packages, generates changelogs from conventional commits, and creates GitHub Releases. +Publishing a GitHub Release also triggers `.github/workflows/docker.yml`, which builds GHCR images tagged with the same semantic version and syncs the image contents to that exact release version before build. + ## Packages & Registries | Package | Type | Registry | Environment Variable | @@ -15,6 +17,7 @@ Headroom uses a unified GitHub Actions workflow (`.github/workflows/release.yml` | `headroom-ai` | TypeScript SDK | npmjs.org | `NPM_SDK_PACKAGE` | | `headroom-openclaw` | TypeScript plugin | npmjs.org | `NPM_OPENCLAW_PACKAGE` | | `headroom-openclaw` | TypeScript plugin | GitHub Package Registry | — | +| `ghcr.io/chopratejas/headroom` | Docker image | GitHub Container Registry | — | ## Version Strategy diff --git a/wiki/docker-install.md b/wiki/docker-install.md index 9093c90c5..634fcc9b4 100644 --- a/wiki/docker-install.md +++ b/wiki/docker-install.md @@ -41,6 +41,8 @@ The wrapper keeps Headroom inside Docker and mounts host state back into the con Port `8787` stays the default, so `http://localhost:8787` works the same way as a native install. +Published releases also push versioned GHCR tags such as `ghcr.io/chopratejas/headroom:0.5.26`, and those images are built with the same synced package version used for the matching PyPI and npm release. + ## How the wrapper behaves ### Native Headroom commands