mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
Merge pull request #193 from JerrettDavis/fix/release-bump-highest
fix: take highest release bump across unreleased commits
This commit is contained in:
commit
9469bcb045
6 changed files with 185 additions and 25 deletions
28
.github/workflows/docker.yml
vendored
28
.github/workflows/docker.yml
vendored
|
|
@ -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) || '' }}
|
||||
|
|
|
|||
20
.github/workflows/release.yml
vendored
20
.github/workflows/release.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue