diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fe70a5b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,43 @@ +name: Release + +# A Release is cut from `pyproject.toml`'s version, once, after CI has passed on +# the push to `main` that carries it. Releasing is a PR that bumps the version; +# nobody tags by hand. The decision lives in `scripts/cut_release.py`, under the +# test suite, rather than in this file. + +on: + workflow_run: + workflows: [CI] + types: [completed] + branches: [main] + +permissions: + contents: write + +# Two runs must not both see "no tag yet" and race to create it. +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + # A pull request's CI also runs on `main`'s workflow file; only a green push + # to `main` itself may release. + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' + # Nothing here needs macOS: it reads a TOML file and calls git and gh. + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Release this commit's version if it is new + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: python scripts/cut_release.py "$HEAD_SHA" diff --git a/app_bundle/inputs.py b/app_bundle/inputs.py index b3054c6..42753f3 100644 --- a/app_bundle/inputs.py +++ b/app_bundle/inputs.py @@ -17,6 +17,7 @@ import platform import plistlib +import tomllib from dataclasses import dataclass from pathlib import Path @@ -175,6 +176,10 @@ def lock_for(triple: str) -> Path: #: Where the shell's identity lives, and the only place it lives. INFO_PLIST = REPO_ROOT / "shell" / "Resources" / "Info.plist" +#: Where the product's version is written, and the only place it is written. +#: `Info.plist` carries no version of its own; the build stamps this one in. +PYPROJECT = REPO_ROOT / "pyproject.toml" + #: The SwiftPM product the shell builds. SHELL_PRODUCT = "GPTVoiceCodingShell" SHELL_PACKAGE = REPO_ROOT / "shell" @@ -209,3 +214,12 @@ def identity(plist: Path = INFO_PLIST) -> BundleIdentity: executable=read["CFBundleExecutable"], name=read["CFBundleName"], ) + + +def product_version(pyproject: Path = PYPROJECT) -> str: + """The product's version, as `pyproject.toml` writes it. Never invents a value.""" + with pyproject.open("rb") as handle: + version = tomllib.load(handle).get("project", {}).get("version") + if not version: + raise InputError(f"{pyproject} does not say [project] version") + return version diff --git a/app_bundle/run.py b/app_bundle/run.py index 87afb1a..be7d03a 100644 --- a/app_bundle/run.py +++ b/app_bundle/run.py @@ -264,6 +264,7 @@ def assemble(plan: BuildPlan) -> None: ], why="packaging the app icon", ) + identity["CFBundleShortVersionString"] = inputs.product_version() identity["CFBundleVersion"] = run( ["git", "rev-parse", "--short", "HEAD"], why="reading the source revision", diff --git a/docs/adr/0031-one-version-and-releases-cut-from-it.md b/docs/adr/0031-one-version-and-releases-cut-from-it.md new file mode 100644 index 0000000..3a170ec --- /dev/null +++ b/docs/adr/0031-one-version-and-releases-cut-from-it.md @@ -0,0 +1,48 @@ +# 31. One version, written in `pyproject.toml`, and Releases cut from it + +Date: 2026-09-16 · Status: Accepted + +The product had three version numbers and no Releases. The app said 0.1.0, from a literal in +`shell/Resources/Info.plist`. The engine said 0.0.0, from `pyproject.toml` and again from a literal +in `gpt_voicecoding/__init__.py`, and that is what Diagnostics, the control plane and codex +app-server's `clientInfo` were told. There was no version tag. `scripts/install.sh` built whatever +`main` held, so the number a user saw named no published state of the code. + +## Decision + +**`pyproject.toml`'s `version` is the only place the version is written.** + +- The engine reads it at import (`gpt_voicecoding/_version.py`): a source checkout reads its own + `pyproject.toml`, because an editable install's metadata keeps the version from its last + `pip install -e`; an installed tree, the app bundle among them, reads the metadata pip wrote from + that same file. +- The app build writes it into `CFBundleShortVersionString` (`app_bundle`), beside the git revision + it already wrote into `CFBundleVersion`. `Info.plist` has no version of its own. + +**A Release is cut by a workflow, never by hand.** Releasing is a PR that changes the version. +`.github/workflows/release.yml` runs when CI has passed on a push to `main`, and +`scripts/cut_release.py` creates `v` at that commit with GitHub's generated notes if the +tag does not exist yet. A push that leaves the version alone finds the tag and does nothing. The +version must be plain `MAJOR.MINOR.PATCH`. A tag whose Release was deleted is reported and left +alone. + +**The installer builds the latest Release.** It asks GitHub's API for the latest Release's tag and +builds that tree. `GPT_VOICECODING_REF` names another tag or branch instead (`main` for the owner +and testers). The installer is always `main`'s copy and builds the tree the ref names. + +Rejected: deriving the version from git tags (hatch-vcs). Between Releases it reads +`1.0.1.dev3+g1a2b3c4`, and CI, the installer and the build would all need full git history to +compute it. + +## Consequences + +- The first Release is `v1.0.0`, cut when this change lands. No earlier commit can carry it: that + tree would build an app at 0.1.0 around an engine at 0.0.0. +- A fix reaches users only when it is released, so a Release should follow soon after a fix that + users need. Cutting one is a one-line PR. +- The installer now needs `api.github.com` as well as `github.com`, and stops with a message when no + Release can be found. +- The existing checkout under `~/Library/Application Support/GPT-VoiceCoding/source` moves from the + `main` branch to a detached tag on its next install; a local edit that conflicts stops the + install with a message. +- The Control Plane protocol version (ADR 0016) is a separate number and is unchanged. diff --git a/docs/adr/README.md b/docs/adr/README.md index 19076af..bc8243f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -30,6 +30,7 @@ | [0028](0028-the-duty-card-is-the-at-computer-surface.md) | The Duty Card is the at-computer surface; the Control Panel is a window behind it, and the menu bar is a menu | | [0029](0029-the-control-window-owns-its-height.md) | The Control Window owns its height; nothing outside it measures content | | [0030](0030-each-live-call-audio-crossing-has-one-owner.md) | Each Live Call audio crossing has one owner: the speaker keeps listening continuous, the microphone keeps speaking fresh — **no bare buffer, no fixed prebuffer** | +| [0031](0031-one-version-and-releases-cut-from-it.md) | One version, written in `pyproject.toml`; a workflow cuts the Release when it changes, and the installer builds the latest Release | 0008 (headless direct-child launcher) and 0009 (a launch carries its Opening Instruction) were removed with the launcher when v1.0 became a bridge over Sessions the user starts ([#67](https://github.com/Simon-AI-coding/GPT-VoiceCoding/issues/67), [#68](https://github.com/Simon-AI-coding/GPT-VoiceCoding/issues/68)); they return with the launch map, from git history. diff --git a/docs/app-bundle.md b/docs/app-bundle.md index 8354281..e3a654b 100644 --- a/docs/app-bundle.md +++ b/docs/app-bundle.md @@ -241,6 +241,25 @@ Record the build revision, machine, language and observed result for each step. A skipped or blocked physical check stays unverified; test-suite success never fills it in. +## Releasing (ADR 0031) + +The version is written once, as `version` in `pyproject.toml`. The engine reads it from there +(or from the installed metadata), and the build stamps it into the app's +`CFBundleShortVersionString`, beside the git revision in `CFBundleVersion`. Diagnostics shows the +two together, e.g. `1.0.0 · 1a2b3c4`. + +To release, open a PR that changes that one line, e.g. `1.0.0` → `1.1.0`, and merge it. When CI +passes on `main`, `.github/workflows/release.yml` runs `scripts/cut_release.py`, which tags +`v1.1.0` at that commit and creates the GitHub Release with generated notes. A push that leaves +the version alone does nothing. The version must be plain `MAJOR.MINOR.PATCH`. + +The install command builds the latest Release. To build something else, name it: + +```bash +curl -fsSL https://raw.githubusercontent.com/Simon-AI-coding/GPT-VoiceCoding/main/scripts/install.sh | GPT_VOICECODING_REF=main sh +curl -fsSL https://raw.githubusercontent.com/Simon-AI-coding/GPT-VoiceCoding/main/scripts/install.sh | GPT_VOICECODING_REF=v1.0.0 sh +``` + ## Before a release: the microphone The one part a machine cannot finish. macOS shows the TCC prompt to a person. diff --git a/pyproject.toml b/pyproject.toml index 998a704..f7e4326 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "gpt-voicecoding" -version = "0.0.0" +version = "1.0.0" description = "Voice-controlling terminal coding agents through a realtime voice call." readme = "README.md" license = { file = "LICENSE" } @@ -12,7 +12,7 @@ requires-python = ">=3.12" authors = [{ name = "Simon-AI-coding" }] keywords = ["voice", "coding-agent", "claude-code", "codex", "realtime"] classifiers = [ - "Development Status :: 1 - Planning", + "Development Status :: 5 - Production/Stable", "Environment :: MacOS X", "License :: OSI Approved :: MIT License", "Operating System :: MacOS :: MacOS X", diff --git a/scripts/cut_release.py b/scripts/cut_release.py new file mode 100755 index 0000000..beef6b2 --- /dev/null +++ b/scripts/cut_release.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Cut the GitHub Release for the version a commit on `main` writes, once. + +Run by `.github/workflows/release.yml` after CI has passed on a push to `main`, +from a checkout of that commit: + + python3 scripts/cut_release.py + +`pyproject.toml` is the one place the version is written. Releasing is a PR that +changes it; when that PR lands and CI passes, this cuts `v` at that +commit with GitHub's generated notes. Every later push that leaves the version +alone finds the tag and does nothing. + +A tag without a Release — someone deleted the Release by hand — is reported and +left alone: recreating it would publish notes nobody asked for, and moving a tag +users may already have installed is worse. +""" + +from __future__ import annotations + +import enum +import re +import subprocess +import sys +import tomllib +from collections.abc import Callable +from pathlib import Path + +PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml" + +#: Plain `MAJOR.MINOR.PATCH`. The installer takes the latest Release, and a +#: pre-release spelling would need rules this workflow does not have yet. +RELEASABLE = re.compile(r"\d+\.\d+\.\d+") + +#: What `gh release view` prints when the tag has no Release. +NOT_FOUND = "release not found" + +Run = Callable[[list[str]], "subprocess.CompletedProcess[str]"] + + +class ReleaseError(Exception): + """The run cannot decide, or cannot do what it decided. The workflow goes red.""" + + +class Decision(enum.Enum): + CREATE = "create" + ALREADY_RELEASED = "already released" + TAG_WITHOUT_RELEASE = "tag without release" + + +def tag_for(version: str) -> str: + return f"v{version}" + + +def written_version(pyproject: Path = PYPROJECT) -> str: + with pyproject.open("rb") as handle: + return tomllib.load(handle).get("project", {}).get("version", "") + + +def check_releasable(version: str) -> None: + if not RELEASABLE.fullmatch(version): + raise ReleaseError(f"pyproject.toml's version {version!r} is not MAJOR.MINOR.PATCH") + + +def decide(version: str, *, tag_exists: bool, release_exists: bool) -> Decision: + check_releasable(version) + if not tag_exists: + return Decision.CREATE + if release_exists: + return Decision.ALREADY_RELEASED + return Decision.TAG_WITHOUT_RELEASE + + +def _shell(argv: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(argv, capture_output=True, text=True, check=False) + + +def _checked(run: Run, argv: list[str]) -> subprocess.CompletedProcess[str]: + done = run(argv) + if done.returncode != 0: + raise ReleaseError(f"`{' '.join(argv)}` failed: {done.stderr.strip()}") + return done + + +def _tag_exists(run: Run, tag: str) -> bool: + listed = _checked(run, ["git", "ls-remote", "--tags", "origin", f"refs/tags/{tag}"]) + return bool(listed.stdout.strip()) + + +def _release_exists(run: Run, tag: str) -> bool: + argv = ["gh", "release", "view", tag] + done = run(argv) + if done.returncode == 0: + return True + if NOT_FOUND in done.stderr: + return False + raise ReleaseError(f"`{' '.join(argv)}` failed: {done.stderr.strip()}") + + +def main(*, sha: str, pyproject: Path = PYPROJECT, run: Run = _shell) -> int: + version = written_version(pyproject) + check_releasable(version) + tag = tag_for(version) + tag_exists = _tag_exists(run, tag) + release_exists = tag_exists and _release_exists(run, tag) + decision = decide(version, tag_exists=tag_exists, release_exists=release_exists) + if decision is Decision.CREATE: + _checked( + run, + ["gh", "release", "create", tag, "--target", sha, "--title", tag, "--generate-notes"], + ) + print(f"Released {tag} at {sha}.") + elif decision is Decision.ALREADY_RELEASED: + print(f"{tag} is already released; nothing to do.") + else: + print(f"::warning::Tag {tag} exists but has no Release. Left alone; see this script.") + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 2: + sys.exit("usage: cut_release.py ") + try: + sys.exit(main(sha=sys.argv[1])) + except ReleaseError as error: + sys.exit(f"::error::{error}") diff --git a/scripts/install.sh b/scripts/install.sh index cf7f969..fb65f92 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -17,15 +17,38 @@ curl -fsIL --connect-timeout 10 --max-time 30 https://pypi.org/simple/ >/dev/nul fail 'Allow HTTPS access to PyPI, then run this command again.' repository=https://github.com/Simon-AI-coding/GPT-VoiceCoding -printf '%s\n' 'Preparing the source checkout…' product=${repository##*/} + +# What to build: the latest Release, unless GPT_VOICECODING_REF names a tag or a +# branch (e.g. GPT_VOICECODING_REF=main). The override is for the owner and +# testers; the README does not advertise it. +ref=${GPT_VOICECODING_REF:-} +if [ -z "$ref" ]; then + printf '%s\n' 'Finding the latest release…' + ref=$(curl -fsSL --connect-timeout 10 --max-time 30 \ + "https://api.github.com/repos/${repository#https://github.com/}/releases/latest" | + python3 -c 'import json, sys; print(json.load(sys.stdin)["tag_name"])' 2>/dev/null) || + fail 'Could not find a release on GitHub. Check your connection and run this command again in a few minutes.' +fi + +printf '%s\n' "Preparing the source checkout for ${ref}…" source_directory="$HOME/Library/Application Support/$product/source" mkdir -p "$(dirname "$source_directory")" if [ -d "$source_directory/.git" ]; then - git -C "$source_directory" pull --ff-only + git -C "$source_directory" fetch --quiet --tags origin +else + git clone --quiet "$repository.git" "$source_directory" +fi +# A tag builds as tagged; a branch builds as GitHub has it now, not as last fetched. +if git -C "$source_directory" rev-parse --verify --quiet "refs/tags/$ref^{commit}" >/dev/null; then + target="refs/tags/$ref" +elif git -C "$source_directory" rev-parse --verify --quiet "refs/remotes/origin/$ref^{commit}" >/dev/null; then + target="refs/remotes/origin/$ref" else - git clone "$repository.git" "$source_directory" + fail "There is no release or branch named $ref." fi +git -C "$source_directory" -c advice.detachedHead=false checkout --quiet --detach "$target" || + fail "Could not switch $source_directory to $ref. If you changed files there, move them away and run this command again." "$source_directory/scripts/build-app.sh" bundle_name=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleName' "$source_directory/shell/Resources/Info.plist") diff --git a/shell/Resources/Info.plist b/shell/Resources/Info.plist index 2e7c5c6..9f00de4 100644 --- a/shell/Resources/Info.plist +++ b/shell/Resources/Info.plist @@ -32,8 +32,6 @@ APPL CFBundleIconFile app-icon.icns - CFBundleShortVersionString - 0.1.0 CFBundleVersion 1 LSMinimumSystemVersion diff --git a/src/gpt_voicecoding/__init__.py b/src/gpt_voicecoding/__init__.py index 66901e6..1da024a 100644 --- a/src/gpt_voicecoding/__init__.py +++ b/src/gpt_voicecoding/__init__.py @@ -17,6 +17,8 @@ changing any of it. """ +from gpt_voicecoding import _version + __all__ = ["__version__"] -__version__ = "0.0.0" +__version__ = _version.read() diff --git a/src/gpt_voicecoding/_version.py b/src/gpt_voicecoding/_version.py new file mode 100644 index 0000000..25143c3 --- /dev/null +++ b/src/gpt_voicecoding/_version.py @@ -0,0 +1,37 @@ +"""Where the engine learns its own version. + +`pyproject.toml` is the one place the version is written. A source checkout +reads that file directly, because an editable install's metadata keeps whatever +version was current at its last `pip install -e` and would go stale after a +bump. An installed tree — the app bundle — has no `pyproject.toml` beside the +package, and reads the metadata pip wrote from that same file at build time. +""" + +from __future__ import annotations + +import tomllib +from collections.abc import Callable +from importlib import metadata +from pathlib import Path + +#: The distribution name in `pyproject.toml`, and the one the metadata is under. +DISTRIBUTION = "gpt-voicecoding" + +#: Where `pyproject.toml` sits when this package runs from a checkout: +#: `src/gpt_voicecoding/` is two levels below the repository root. +CHECKOUT = Path(__file__).resolve().parents[2] + + +def read( + *, + checkout: Path = CHECKOUT, + installed: Callable[[str], str] = metadata.version, +) -> str: + """This product's version: the checkout's `pyproject.toml`, else the install's metadata.""" + candidate = checkout / "pyproject.toml" + if candidate.is_file(): + with candidate.open("rb") as handle: + project = tomllib.load(handle).get("project", {}) + if project.get("name") == DISTRIBUTION: + return project["version"] + return installed(DISTRIBUTION) diff --git a/tests/test_app_bundle.py b/tests/test_app_bundle.py index 8a5e065..359f56e 100644 --- a/tests/test_app_bundle.py +++ b/tests/test_app_bundle.py @@ -48,7 +48,7 @@ def work(): assert ": done" not in capsys.readouterr().out -def test_shell_resources_and_source_revision_travel_in_the_bundle(tmp_path, monkeypatch): +def test_shell_resources_version_and_source_revision_travel_in_the_bundle(tmp_path, monkeypatch): built = tmp_path / "products" built.mkdir() (built / inputs.SHELL_PRODUCT).write_bytes(MACH_O) @@ -66,6 +66,7 @@ def command(argv, *, why, cwd=None): bundle_run.assemble(plan) info = plistlib.loads((plan.app / "Contents/Info.plist").read_bytes()) assert info["CFBundleVersion"] == "abc1234" + assert info["CFBundleShortVersionString"] == inputs.product_version() assert info["ShellResourceBundle"] == resources.name for mark in resources.glob("*.svg"): assert ( @@ -73,6 +74,27 @@ def command(argv, *, why, cwd=None): ).read_bytes() == mark.read_bytes() +def test_the_product_version_is_read_from_pyproject(tmp_path): + written = tmp_path / "pyproject.toml" + written.write_text('[project]\nname = "gpt-voicecoding"\nversion = "7.8.9"\n') + + assert inputs.product_version(written) == "7.8.9" + + +def test_a_pyproject_without_a_version_stops_the_build(tmp_path): + written = tmp_path / "pyproject.toml" + written.write_text('[project]\nname = "gpt-voicecoding"\n') + + with pytest.raises(inputs.InputError, match="version"): + inputs.product_version(written) + + +def test_the_shell_plist_does_not_carry_its_own_version(): + shipped = plistlib.loads(inputs.INFO_PLIST.read_bytes()) + + assert "CFBundleShortVersionString" not in shipped + + def make_engine_tree(root: Path) -> None: """A miniature of the assembled engine tree, with every shape that has bitten. diff --git a/tests/test_cut_release.py b/tests/test_cut_release.py new file mode 100644 index 0000000..ae945e0 --- /dev/null +++ b/tests/test_cut_release.py @@ -0,0 +1,154 @@ +"""The release workflow's one decision: whether a commit on `main` gets a Release. + +`.github/workflows/release.yml` runs `scripts/cut_release.py` after CI passes on +a push to `main`. The version is whatever `pyproject.toml` writes at that commit; +a Release is cut the first time a version is seen and never again. +""" + +from __future__ import annotations + +import importlib.util +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "cut_release.py" +_spec = importlib.util.spec_from_file_location("cut_release", SCRIPT) +cut_release = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(cut_release) + + +def test_a_new_version_is_released(): + assert cut_release.decide("1.0.0", tag_exists=False, release_exists=False) is ( + cut_release.Decision.CREATE + ) + + +def test_a_version_already_released_is_left_alone(): + assert cut_release.decide("1.0.0", tag_exists=True, release_exists=True) is ( + cut_release.Decision.ALREADY_RELEASED + ) + + +def test_a_tag_whose_release_was_deleted_is_reported_not_recreated(): + assert cut_release.decide("1.0.0", tag_exists=True, release_exists=False) is ( + cut_release.Decision.TAG_WITHOUT_RELEASE + ) + + +@pytest.mark.parametrize("version", ["1.0", "v1.0.0", "1.0.0rc1", "1.0.0.dev3", "", "1.0.0 "]) +def test_a_version_that_is_not_plain_major_minor_patch_stops_the_run(version): + with pytest.raises(cut_release.ReleaseError, match="version"): + cut_release.decide(version, tag_exists=False, release_exists=False) + + +def test_an_unreleasable_version_stops_before_asking_git_or_github(tmp_path): + shell = Recorder(tag_exists=False, release_exists=False) + + with pytest.raises(cut_release.ReleaseError, match="version"): + cut_release.main(sha="deadbeef", pyproject=pyproject(tmp_path, "1.0"), run=shell) + assert shell.calls == [] + + +def test_the_tag_is_the_version_with_a_v(): + assert cut_release.tag_for("1.2.3") == "v1.2.3" + + +class Recorder: + """Stands in for `git` and `gh`, answering from a fixed state.""" + + def __init__(self, *, tag_exists: bool, release_exists: bool): + self.tag_exists = tag_exists + self.release_exists = release_exists + self.calls: list[list[str]] = [] + + def __call__(self, argv: list[str]) -> subprocess.CompletedProcess[str]: + self.calls.append(argv) + if argv[:2] == ["git", "ls-remote"]: + out = "abc\trefs/tags/v1.0.0\n" if self.tag_exists else "" + return subprocess.CompletedProcess(argv, 0, stdout=out, stderr="") + if argv[:3] == ["gh", "release", "view"]: + if self.release_exists: + return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") + return subprocess.CompletedProcess(argv, 1, stdout="", stderr="release not found") + return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") + + +def pyproject(root: Path, version: str) -> Path: + written = root / "pyproject.toml" + written.write_text(f'[project]\nname = "gpt-voicecoding"\nversion = "{version}"\n') + return written + + +def test_a_run_for_a_new_version_creates_the_release_at_that_commit(tmp_path, capsys): + shell = Recorder(tag_exists=False, release_exists=False) + + code = cut_release.main(sha="deadbeef", pyproject=pyproject(tmp_path, "1.0.0"), run=shell) + + assert code == 0 + assert shell.calls[-1] == [ + "gh", + "release", + "create", + "v1.0.0", + "--target", + "deadbeef", + "--title", + "v1.0.0", + "--generate-notes", + ] + assert "v1.0.0" in capsys.readouterr().out + + +def test_a_run_for_a_released_version_creates_nothing(tmp_path): + shell = Recorder(tag_exists=True, release_exists=True) + + code = cut_release.main(sha="deadbeef", pyproject=pyproject(tmp_path, "1.0.0"), run=shell) + + assert code == 0 + assert not any(call[:3] == ["gh", "release", "create"] for call in shell.calls) + + +def test_a_run_that_finds_an_orphan_tag_warns_and_creates_nothing(tmp_path, capsys): + shell = Recorder(tag_exists=True, release_exists=False) + + code = cut_release.main(sha="deadbeef", pyproject=pyproject(tmp_path, "1.0.0"), run=shell) + + assert code == 0 + assert not any(call[:3] == ["gh", "release", "create"] for call in shell.calls) + assert "::warning::" in capsys.readouterr().out + + +def test_a_failed_create_fails_the_run(tmp_path): + def failing(argv): + if argv[:3] == ["gh", "release", "create"]: + return subprocess.CompletedProcess(argv, 1, stdout="", stderr="HTTP 403") + return Recorder(tag_exists=False, release_exists=False)(argv) + + with pytest.raises(cut_release.ReleaseError, match="HTTP 403"): + cut_release.main(sha="deadbeef", pyproject=pyproject(tmp_path, "1.0.0"), run=failing) + + +def test_a_release_lookup_that_fails_for_another_reason_fails_the_run(tmp_path): + def offline(argv): + if argv[:3] == ["gh", "release", "view"]: + return subprocess.CompletedProcess(argv, 1, stdout="", stderr="connection refused") + return Recorder(tag_exists=True, release_exists=False)(argv) + + with pytest.raises(cut_release.ReleaseError, match="connection refused"): + cut_release.main(sha="deadbeef", pyproject=pyproject(tmp_path, "1.0.0"), run=offline) + + +def test_a_tag_lookup_that_fails_fails_the_run(tmp_path): + def offline(argv): + return subprocess.CompletedProcess(argv, 128, stdout="", stderr="could not read") + + with pytest.raises(cut_release.ReleaseError, match="could not read"): + cut_release.main(sha="deadbeef", pyproject=pyproject(tmp_path, "1.0.0"), run=offline) + + +def test_the_repository_version_is_releasable(): + written = Path(__file__).resolve().parents[1] / "pyproject.toml" + + cut_release.decide(cut_release.written_version(written), tag_exists=False, release_exists=False) diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..eb68c86 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,52 @@ +"""The product has one version, and `pyproject.toml` is where it is written. + +The engine reports it on the control plane and to codex app-server, the app +build stamps it into `Info.plist`, and the release workflow tags it. None of +them carries a copy. +""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +import gpt_voicecoding +from gpt_voicecoding import _version + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def written_version() -> str: + with (REPO_ROOT / "pyproject.toml").open("rb") as handle: + return tomllib.load(handle)["project"]["version"] + + +def pyproject(root: Path, *, name: str, version: str) -> None: + (root / "pyproject.toml").write_text(f'[project]\nname = "{name}"\nversion = "{version}"\n') + + +def test_the_engine_reports_the_version_pyproject_writes(): + assert gpt_voicecoding.__version__ == written_version() + + +def test_a_source_checkout_reads_its_own_pyproject_not_stale_install_metadata(tmp_path): + pyproject(tmp_path, name=_version.DISTRIBUTION, version="7.8.9") + + def stale(_name): + return "0.0.0" + + assert _version.read(checkout=tmp_path, installed=stale) == "7.8.9" + + +def test_an_installed_tree_without_a_checkout_reads_the_package_metadata(tmp_path): + def installed(name): + assert name == _version.DISTRIBUTION + return "7.8.9" + + assert _version.read(checkout=tmp_path, installed=installed) == "7.8.9" + + +def test_another_projects_pyproject_is_not_mistaken_for_this_one(tmp_path): + pyproject(tmp_path, name="something-else", version="4.4.4") + + assert _version.read(checkout=tmp_path, installed=lambda _name: "7.8.9") == "7.8.9"