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/.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..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 @@ -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}` diff --git a/headroom/release_version.py b/headroom/release_version.py index 9a431b9a8..951a8bfc0 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,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)) diff --git a/tests/test_release_version.py b/tests/test_release_version.py index 1325cbf51..52bb0fcac 100644 --- a/tests/test_release_version.py +++ b/tests/test_release_version.py @@ -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: 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