Merge pull request #193 from JerrettDavis/fix/release-bump-highest

fix: take highest release bump across unreleased commits
This commit is contained in:
Tejas Chopra 2026-04-17 11:00:21 -07:00 committed by GitHub
commit 9469bcb045
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 185 additions and 25 deletions

View file

@ -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) || '' }}

View file

@ -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:

View file

@ -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
@ -36,13 +39,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 +77,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}`

View file

@ -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,32 @@ 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):
if not raw_entry or FIELD_SEP not in raw_entry:
continue
subject, body = raw_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 +283,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))

View file

@ -4,12 +4,17 @@ import os
import subprocess
import sys
from pathlib import Path
from unittest.mock import Mock
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 +106,55 @@ 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_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 == [
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(
tmp_path: Path,
) -> None:

View file

@ -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