build: add release tagging, version checks and a release script
This repo has no tags and no GitHub releases, and manifest.json has read "1.0.0" since the integration was vendored -- unchanged across 20 commits, including today's breaking reshape of the config flow. HACS falls back to serving the default branch when there are no releases, so every download is indistinguishable from the last and there is no way to tell what is running. - scripts/release.py bumps manifest.json, commits and creates an annotated tag in one step. It refuses a dirty tree, a branch other than main, a main that differs from origin/main, a malformed version and an existing tag. It stops short of pushing, since pushing the tag is what publishes. - .github/workflows/release.yaml triggers on v* tags and refuses to publish when the tag and manifest.json disagree. That mismatch is the failure worth guarding: HACS offers the tag while Home Assistant reports the manifest, so you install "v0.2.0" and the integration page says 0.1.0. It then creates the release with gh (no third-party action). - tests/test_version.py keeps the manifest version parseable by AwesomeVersion (what HACS validates with) and in the shape the release script accepts, so a release never fails on the version string itself. Scheme: 0.x while the config entry shape is still in motion -- it changed twice today -- and 1.0.0 when it settles. Documented in the README. Local: 169 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NxJYWgGJtCw13qUHZtG8ki
This commit is contained in:
1 parent
e7fcb1d7a1
commit
4a347369cc
4 files changed
+197
No files matched your search
@@ -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
|
||||||
@@ -171,6 +171,29 @@ pytest tests/ -v
|
|||||||
The suite is core's `openai_conversation` test suite, ported as a regression net, plus
|
The suite is core's `openai_conversation` test suite, ported as a regression net, plus
|
||||||
tests for the base-URL behavior this fork adds.
|
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
|
## License
|
||||||
|
|
||||||
[Apache-2.0](LICENSE), inherited from Home Assistant core. See [NOTICE](NOTICE) for
|
[Apache-2.0](LICENSE), inherited from Home Assistant core. See [NOTICE](NOTICE) for
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
Reference in new issue
Block a user