diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..1d4620c --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,40 @@ +name: release + +on: + push: + tags: ["v*"] + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # HACS and Home Assistant both read the version from manifest.json, not + # from the tag. If the two disagree, HACS offers "v0.2.0" and installs + # something that reports itself as 0.1.0 -- which is exactly the state + # this repo was in before it had any releases at all. Fail the release + # rather than publish that. + - name: Check manifest.json version matches the tag + run: | + tag_version="${GITHUB_REF_NAME#v}" + manifest_version="$(python3 -c "import json; print(json.load(open('custom_components/openai_compatible/manifest.json'))['version'])")" + echo "tag: $GITHUB_REF_NAME -> $tag_version" + echo "manifest: $manifest_version" + if [ "$tag_version" != "$manifest_version" ]; then + echo "::error::tag ${GITHUB_REF_NAME} implies version ${tag_version}, but manifest.json says ${manifest_version}. Bump the manifest and re-tag." + exit 1 + fi + + - name: Create the GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" \ + --title "$GITHUB_REF_NAME" \ + --generate-notes \ + --verify-tag diff --git a/README.md b/README.md index 716512b..5fa819c 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,29 @@ pytest tests/ -v The suite is core's `openai_conversation` test suite, ported as a regression net, plus tests for the base-URL behavior this fork adds. +## Releases + +HACS falls back to serving the default branch when a repository has no releases, so +every download looks identical and there is no way to tell what is installed. Tagged +releases fix that, and Home Assistant shows `manifest.json`'s version on the +integration page. + +Versions are `0.x` while the config entry shape is still changing; `1.0.0` when it +settles. + +To cut one: + +```bash +python scripts/release.py 0.2.0 +git push origin main --follow-tags +``` + +The script bumps `manifest.json`, commits, and creates the annotated tag `v0.2.0`. It +deliberately does not push — pushing the tag is what publishes. That triggers +[`release.yaml`](.github/workflows/release.yaml), which refuses to publish if the tag +and the manifest version disagree (HACS reads the tag, HA reads the manifest; when they +drift you install "v0.2.0" and it reports 0.1.0) and then creates the GitHub release. + ## License [Apache-2.0](LICENSE), inherited from Home Assistant core. See [NOTICE](NOTICE) for diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..bdef4e1 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Cut a release: bump manifest.json, commit, tag. + +HACS shows the latest releases of a repository and, when there are none, +silently serves the default branch instead -- so every download looks +identical and there is no way to tell which code is installed. Home Assistant +reads the installed version from manifest.json. Those two have to agree, which +is what this script (and the check in .github/workflows/release.yaml) is for. + + python scripts/release.py 0.2.0 + +It stops short of pushing. Pushing the tag is what publishes the release, so +that stays an explicit step: + + git push origin main --follow-tags +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import re +import subprocess +import sys + +REPO = Path(__file__).resolve().parent.parent +MANIFEST = REPO / "custom_components" / "openai_compatible" / "manifest.json" + +# Same shape AwesomeVersion accepts and HACS sorts by, kept deliberately +# strict: a stray "v" or a trailing ".0.0" is how tag/manifest drift starts. +VERSION_RE = re.compile(r"^\d+\.\d+\.\d+([ab]\d+|rc\d+)?$") + + +def run(*args: str, capture: bool = False) -> str: + """Run a git command, failing loudly.""" + result = subprocess.run( + args, cwd=REPO, text=True, capture_output=capture, check=True + ) + return (result.stdout or "").strip() if capture else "" + + +def check_clean_checkout() -> None: + """Refuse to release from a dirty or diverged checkout.""" + if run("git", "status", "--porcelain", capture=True): + raise SystemExit("working tree is not clean; commit or stash first") + + branch = run("git", "rev-parse", "--abbrev-ref", "HEAD", capture=True) + if branch != "main": + raise SystemExit(f"releases are cut from main, not {branch}") + + run("git", "fetch", "--quiet", "origin", "main") + local = run("git", "rev-parse", "main", capture=True) + remote = run("git", "rev-parse", "origin/main", capture=True) + if local != remote: + raise SystemExit("main and origin/main differ; push or pull first") + + +def main() -> int: + """Bump, commit and tag.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version", help="new version, e.g. 0.2.0") + args = parser.parse_args() + version = args.version.removeprefix("v") + + if not VERSION_RE.match(version): + raise SystemExit(f"'{version}' is not a MAJOR.MINOR.PATCH version") + + tag = f"v{version}" + existing = run("git", "tag", "--list", tag, capture=True) + if existing: + raise SystemExit(f"tag {tag} already exists") + + check_clean_checkout() + + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + previous = manifest["version"] + if previous == version: + raise SystemExit(f"manifest.json is already {version}") + + manifest["version"] = version + MANIFEST.write_text( + json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + + run("git", "add", str(MANIFEST.relative_to(REPO))) + run("git", "commit", "--quiet", "-m", f"chore: release {tag}") + run("git", "tag", "--annotate", tag, "-m", tag) + + print(f"{previous} -> {version}, committed and tagged {tag}") + print("publish it with: git push origin main --follow-tags") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..06efbc0 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,38 @@ +"""manifest.json's version is what HACS and Home Assistant report.""" + +import json +from pathlib import Path + +from awesomeversion import AwesomeVersion +import pytest + +MANIFEST = ( + Path(__file__).resolve().parent.parent + / "custom_components" + / "openai_compatible" + / "manifest.json" +) + + +@pytest.fixture(name="manifest") +def manifest_fixture() -> dict: + """Return the integration manifest.""" + return json.loads(MANIFEST.read_text(encoding="utf-8")) + + +def test_version_is_present_and_parseable(manifest: dict) -> None: + """HACS validates the version with AwesomeVersion; so do we, locally.""" + version = manifest.get("version") + assert version, "manifest.json needs a version for HACS to install it" + assert AwesomeVersion(version).valid + + +def test_version_matches_the_release_script_format(manifest: dict) -> None: + """Keep the manifest in the shape scripts/release.py will accept, so a + release never fails on the version string itself.""" + from scripts.release import VERSION_RE + + assert VERSION_RE.match(manifest["version"]), ( + f"{manifest['version']} is not MAJOR.MINOR.PATCH; " + "scripts/release.py and the release workflow expect that shape" + )