From 8b95f0076a2aa0392ad9d06f13a5eed8f64dcc74 Mon Sep 17 00:00:00 2001 From: Fausto David Suarez Rosario Date: Tue, 2 Jun 2026 19:39:13 +0200 Subject: [PATCH] Automate Homebrew tap formula updates --- .github/workflows/homebrew-formula.yml | 137 +++++++++++++ CONTRIBUTING.md | 7 + Formula/smith.rb | 211 --------------------- scripts/update_homebrew_formula.py | 135 +++++++++++++ tests/unit/test_update_homebrew_formula.py | 105 ++++++++++ 5 files changed, 384 insertions(+), 211 deletions(-) create mode 100644 .github/workflows/homebrew-formula.yml delete mode 100644 Formula/smith.rb create mode 100644 scripts/update_homebrew_formula.py create mode 100644 tests/unit/test_update_homebrew_formula.py diff --git a/.github/workflows/homebrew-formula.yml b/.github/workflows/homebrew-formula.yml new file mode 100644 index 0000000..5bea7bc --- /dev/null +++ b/.github/workflows/homebrew-formula.yml @@ -0,0 +1,137 @@ +name: Homebrew Tap Formula + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "Release tag to pin" + required: true + type: string + revision: + description: "Optional release commit SHA to verify against the tag" + required: false + type: string + +permissions: + contents: read + +jobs: + update-tap-formula: + runs-on: macos-latest + steps: + - name: Resolve release pin + id: release + env: + DISPATCH_TAG: ${{ github.event.inputs.tag }} + DISPATCH_REVISION: ${{ github.event.inputs.revision }} + run: | + set -euo pipefail + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + tag="${DISPATCH_TAG}" + revision="${DISPATCH_REVISION}" + else + tag="${GITHUB_REF_NAME}" + revision="" + fi + if [[ ! "${tag}" =~ ^v[0-9A-Za-z][0-9A-Za-z._-]*$ ]]; then + echo "::error::Release tag must start with v and contain only letters, numbers, dots, underscores, and hyphens." + exit 1 + fi + if [[ -n "${revision}" && ! "${revision}" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::Revision override must be a 40-character lowercase git SHA." + exit 1 + fi + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + echo "revision=${revision}" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 0 + + - name: Fetch release tag + env: + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: git fetch --force origin "refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" + + - name: Resolve release metadata + id: metadata + env: + RELEASE_TAG: ${{ steps.release.outputs.tag }} + REVISION_OVERRIDE: ${{ steps.release.outputs.revision }} + run: | + set -euo pipefail + resolved_revision="$(git rev-list -n 1 "${RELEASE_TAG}")" + if [[ -n "${REVISION_OVERRIDE}" && "${REVISION_OVERRIDE}" != "${resolved_revision}" ]]; then + echo "::error::Revision override ${REVISION_OVERRIDE} does not match ${RELEASE_TAG} at ${resolved_revision}." + exit 1 + fi + git show "${RELEASE_TAG}:pyproject.toml" > release-pyproject.toml + echo "revision=${resolved_revision}" >> "$GITHUB_OUTPUT" + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Clone Homebrew tap + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + set -euo pipefail + if [[ -z "${HOMEBREW_TAP_TOKEN:-}" ]]; then + echo "::error::HOMEBREW_TAP_TOKEN is required to update faustodavid/homebrew-tap." + exit 1 + fi + git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/faustodavid/homebrew-tap.git" homebrew-tap + + - name: Update tap formula pin + id: formula + env: + RELEASE_TAG: ${{ steps.release.outputs.tag }} + RELEASE_REVISION: ${{ steps.metadata.outputs.revision }} + run: | + set -euo pipefail + python scripts/update_homebrew_formula.py \ + --pyproject release-pyproject.toml \ + --formula homebrew-tap/Formula/smith.rb \ + --tag "${RELEASE_TAG}" \ + --revision "${RELEASE_REVISION}" + if git -C homebrew-tap diff --quiet -- Formula/smith.rb; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Validate formula syntax + run: ruby -c homebrew-tap/Formula/smith.rb + + - name: Validate formula with Homebrew + env: + HOMEBREW_NO_INSTALL_CLEANUP: "1" + run: | + set -euo pipefail + brew tap-new smith/ci + cp homebrew-tap/Formula/smith.rb "$(brew --repo smith/ci)/Formula/smith.rb" + brew audit --strict --formula smith/ci/smith + brew install --build-from-source smith/ci/smith + brew test smith/ci/smith + + - name: Commit tap formula update + if: ${{ steps.formula.outputs.changed == 'true' }} + env: + RELEASE_TAG: ${{ steps.release.outputs.tag }} + run: | + set -euo pipefail + cd homebrew-tap + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Formula/smith.rb + git commit -m "Update smith formula for ${RELEASE_TAG}" + git push origin HEAD:main + + - name: Report current formula + if: ${{ steps.formula.outputs.changed == 'false' }} + run: echo "Tap formula is already current for ${{ steps.release.outputs.tag }}." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b73e5f..691ac61 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,6 +14,13 @@ Run before opening a PR: make check ``` +## Releases + +The Homebrew formula source of truth is `faustodavid/homebrew-tap/Formula/smith.rb`. +When a `v*` tag is pushed in this repo, the Homebrew Tap Formula workflow updates +that tap formula's tag and revision from the release tag. The workflow requires a +`HOMEBREW_TAP_TOKEN` secret with write access to `faustodavid/homebrew-tap`. + ## Contract Stability - Keep CLI flags, positional args, and exit codes stable. diff --git a/Formula/smith.rb b/Formula/smith.rb deleted file mode 100644 index 923f0d9..0000000 --- a/Formula/smith.rb +++ /dev/null @@ -1,211 +0,0 @@ -# frozen_string_literal: true - -# Formula for Smith. -class Smith < Formula - include Language::Python::Virtualenv - - desc "Read-only source-of-truth investigation CLI for AI agents" - homepage "https://github.com/faustodavid/smith" - url "https://github.com/faustodavid/smith.git", - tag: "v0.1.1", - revision: "0a4bc1eadf0d0a20fb4af3ed20c3974d33fcc63d" - license "MIT" - head "https://github.com/faustodavid/smith.git", branch: "main" - - depends_on "pkgconf" => :build - depends_on "cryptography" - depends_on "libffi" - depends_on "libyaml" - depends_on "python@3.14" - depends_on "ripgrep" - - resource "setuptools" do - url "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl" - sha256 "a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb" - end - - resource "packaging" do - url "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl" - sha256 "29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484" - end - - resource "wheel" do - url "https://files.pythonhosted.org/packages/87/22/b76d483683216dde3d67cba61fb2444be8d5be289bf628c13fc0fd90e5f9/wheel-0.46.3-py3-none-any.whl" - sha256 "4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d" - end - - resource "flit-core" do - url "https://files.pythonhosted.org/packages/f2/65/b6ba90634c984a4fcc02c7e3afe523fef500c4980fec67cc27536ee50acf/flit_core-3.12.0-py3-none-any.whl" - sha256 "e7a0304069ea895172e3c7bb703292e992c5d1555dd1233ab7b5621b5b69e62c" - end - - resource "cython" do - url "https://files.pythonhosted.org/packages/e5/41/54fd429ff8147475fc24ca43246f85d78fb4e747c27f227e68f1594648f1/cython-3.2.3-py3-none-any.whl" - sha256 "06a1317097f540d3bb6c7b81ed58a0d8b9dbfa97abf39dfd4c22ee87a6c7241e" - end - - resource "pathspec" do - url "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl" - sha256 "a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08" - end - - resource "pluggy" do - url "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl" - sha256 "e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" - end - - resource "trove-classifiers" do - url "https://files.pythonhosted.org/packages/7c/a4/81502f486f01db95bc8320646a8a12511f5e556cb63d5e224d91816605c4/trove_classifiers-2026.6.1.19-py3-none-any.whl" - sha256 "ab4c4ec93cc4a4e7815fa759906e05e6bb3f2fbd92ea0f897288c6a43efd15b3" - end - - resource "hatchling" do - url "https://files.pythonhosted.org/packages/08/e7/ae38d7a6dfba0533684e0b2136817d667588ae3ec984c1a4e5df5eb88482/hatchling-1.27.0-py3-none-any.whl" - sha256 "d3a2f3567c4f926ea39849cdf924c7e99e6686c9c8e288ae1037c8fa2a5d937b" - end - - resource "setuptools-scm" do - url "https://files.pythonhosted.org/packages/ab/ac/8f96ba9b4cfe3e4ea201f23f4f97165862395e9331a424ed325ae37024a8/setuptools_scm-8.3.1-py3-none-any.whl" - sha256 "332ca0d43791b818b841213e76b1971b7711a960761c5bea5fc5cdb5196fbce3" - end - - resource "hatch-vcs" do - url "https://files.pythonhosted.org/packages/82/0f/6cbd9976160bc334add63bc2e7a58b1433a31b34b7cda6c5de6dd983d9a7/hatch_vcs-0.4.0-py3-none-any.whl" - sha256 "b8a2b6bee54cf6f9fc93762db73890017ae59c9081d1038a41f16235ceaf8b2c" - end - - resource "azure-core" do - url "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz" - sha256 "f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a" - end - - resource "azure-identity" do - url "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz" - sha256 "ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6" - end - - resource "certifi" do - url "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz" - sha256 "69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d" - end - - resource "charset-normalizer" do - url "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz" - sha256 "ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5" - end - - resource "idna" do - url "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz" - sha256 "5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f" - end - - resource "msal" do - url "https://files.pythonhosted.org/packages/9a/99/d840198ecf6e8057bbc937f129ae940404485d736cda73253bbff9537f01/msal-1.37.0.tar.gz" - sha256 "1b1672a33ee467c1d70b341bb16cafd51bb3c817147a95b93263794b03971bec" - end - - resource "msal-extensions" do - url "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz" - sha256 "c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4" - end - - resource "pyjwt" do - url "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz" - sha256 "41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423" - end - - resource "pyyaml" do - url "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz" - sha256 "d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f" - end - - resource "requests" do - url "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz" - sha256 "f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed" - end - - resource "toon-format" do - url "https://files.pythonhosted.org/packages/fa/15/d23d6d3e36aa4ec96dd5692bc7715fe17015b669e8f0d1c5c7fa906a3ceb/toon_format-0.9.0b1.tar.gz" - sha256 "8f391dd6ad9677c78366bd8eb6762d064a2183f67b9b7da1f348fdb6ee8738e7" - end - - resource "truststore" do - url "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz" - sha256 "9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301" - end - - resource "typing-extensions" do - url "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz" - sha256 "0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466" - end - - resource "urllib3" do - url "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz" - sha256 "231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c" - end - - def install - venv = virtualenv_create(libexec, "python3.14") - bootstrap_resources = %w[ - setuptools - packaging - wheel - flit-core - cython - pathspec - pluggy - trove-classifiers - hatchling - setuptools-scm - hatch-vcs - ] - bootstrap_resources.each do |resource_name| - venv.pip_install resource(resource_name), build_isolation: false - end - venv.pip_install resources.reject { |r| bootstrap_resources.include?(r.name) }, build_isolation: false - venv.pip_install_and_link buildpath, build_isolation: false - pkgshare.install "skills" - - (bin/"smith-install-skill").write <<~BASH - #!/bin/bash - set -euo pipefail - - source_dir="#{opt_pkgshare}/skills/smith" - target_dir="${SMITH_SKILL_DIR:-$HOME/.agents/skills/smith}" - - if [[ ! -d "$source_dir" ]]; then - echo "Smith skill source not found: $source_dir" >&2 - exit 1 - fi - - rm -rf "$target_dir" - mkdir -p "$(dirname "$target_dir")" - ln -s "$source_dir" "$target_dir" - echo "Smith skill linked to: $target_dir" - BASH - chmod 0555, bin/"smith-install-skill" - end - - def caveats - <<~EOS - To link or refresh the Smith skill: - smith-install-skill - - By default it links to ~/.agents/skills/smith. - To choose another destination: - SMITH_SKILL_DIR=/path/to/skills/smith smith-install-skill - EOS - end - - test do - assert_match "usage:", shell_output("#{bin}/smith --help") - assert_path_exists pkgshare/"skills/smith/SKILL.md" - - skill_dir = testpath/"skills/smith" - with_env("SMITH_SKILL_DIR" => skill_dir.to_s) do - assert_match "Smith skill linked to:", shell_output(bin/"smith-install-skill") - end - assert_predicate skill_dir, :symlink? - assert_path_exists skill_dir/"SKILL.md" - end -end diff --git a/scripts/update_homebrew_formula.py b/scripts/update_homebrew_formula.py new file mode 100644 index 0000000..e3f7927 --- /dev/null +++ b/scripts/update_homebrew_formula.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +import tomllib +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_PYPROJECT = REPO_ROOT / "pyproject.toml" +PIN_RE = re.compile( + r'(?Purl "https://github\.com/faustodavid/smith\.git",\n\s+tag:\s+)"(?P[^"]+)"' + r'(?P,\n\s+revision:\s+)"(?P[0-9a-f]{40})"', + re.MULTILINE, +) +SHA_RE = re.compile(r"^[0-9a-f]{40}$") +TAG_RE = re.compile(r"^v[0-9A-Za-z][0-9A-Za-z._-]*$") + + +class FormulaUpdateError(ValueError): + pass + + +def load_project_version(path: Path = DEFAULT_PYPROJECT) -> str: + data = tomllib.loads(path.read_text(encoding="utf-8")) + version = data.get("project", {}).get("version") + if not isinstance(version, str) or not version: + raise FormulaUpdateError(f"project.version is missing from {path}") + return version + + +def expected_tag(version: str) -> str: + return f"v{version}" + + +def resolve_tag_revision(tag: str, repo_root: Path = REPO_ROOT) -> str: + try: + result = subprocess.run( + ["git", "rev-list", "-n", "1", tag], + cwd=repo_root, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + except subprocess.CalledProcessError as exc: + stderr = exc.stderr.strip() or f"git rev-list could not resolve {tag}" + raise FormulaUpdateError(stderr) from exc + + revision = result.stdout.strip() + validate_revision(revision) + return revision + + +def validate_revision(revision: str) -> None: + if not SHA_RE.fullmatch(revision): + raise FormulaUpdateError(f"revision must be a 40-character lowercase git SHA, got {revision!r}") + + +def validate_tag(tag: str) -> None: + if not TAG_RE.fullmatch(tag): + raise FormulaUpdateError(f"tag must start with v and contain only letters, numbers, dots, underscores, and hyphens, got {tag!r}") + + +def parse_formula_pin(text: str) -> tuple[str, str]: + match = PIN_RE.search(text) + if not match: + raise FormulaUpdateError("could not find the Smith formula url tag/revision pin") + return match.group("tag"), match.group("revision") + + +def update_formula_text(text: str, tag: str, revision: str) -> str: + validate_tag(tag) + validate_revision(revision) + + def replace(match: re.Match[str]) -> str: + return f'{match.group("prefix")}"{tag}"{match.group("middle")}"{revision}"' + + updated, count = PIN_RE.subn(replace, text, count=1) + if count != 1: + raise FormulaUpdateError("could not find the Smith formula url tag/revision pin") + return updated + + +def update_formula(path: Path, tag: str, revision: str, *, check: bool) -> bool: + text = path.read_text(encoding="utf-8") + updated = update_formula_text(text, tag, revision) + changed = updated != text + if check and changed: + current_tag, current_revision = parse_formula_pin(text) + raise FormulaUpdateError( + f"{path} pins {current_tag}@{current_revision}; expected {tag}@{revision}. " + "Run scripts/update_homebrew_formula.py --formula to refresh it." + ) + if changed: + path.write_text(updated, encoding="utf-8") + return changed + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Update a Homebrew formula to pin the current release tag and commit.") + parser.add_argument("--formula", type=Path, required=True, help="Formula path to update, usually homebrew-tap/Formula/smith.rb.") + parser.add_argument("--pyproject", type=Path, default=DEFAULT_PYPROJECT, help="pyproject.toml path used for the default tag.") + parser.add_argument("--version", help="Project version to convert to a v-prefixed tag. Defaults to pyproject.toml.") + parser.add_argument("--tag", help="Release tag to pin. Defaults to v{project.version}.") + parser.add_argument("--revision", help="Release commit SHA. Defaults to resolving the selected tag with git.") + parser.add_argument("--check", action="store_true", help="Fail if the formula is not already up to date.") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + + version = args.version or load_project_version(args.pyproject) + tag = args.tag or expected_tag(version) + validate_tag(tag) + project_tag = expected_tag(version) + if tag != project_tag: + raise FormulaUpdateError(f"tag {tag!r} does not match project.version {version!r}; expected {project_tag!r}") + + revision = args.revision or resolve_tag_revision(tag) + changed = update_formula(args.formula, tag, revision, check=args.check) + status = "updated" if changed else "already current" + print(f"{args.formula}: {status} at {tag}@{revision}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except FormulaUpdateError as exc: + print(f"error: {exc}", file=sys.stderr) + raise SystemExit(1) diff --git a/tests/unit/test_update_homebrew_formula.py b/tests/unit/test_update_homebrew_formula.py new file mode 100644 index 0000000..8c3ae5d --- /dev/null +++ b/tests/unit/test_update_homebrew_formula.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from typing import Any + +import pytest + + +def _load_formula_module() -> Any: + repo_root = Path(__file__).resolve().parents[2] + module_path = repo_root / "scripts" / "update_homebrew_formula.py" + spec = importlib.util.spec_from_file_location("update_homebrew_formula", module_path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +FORMULA = """# frozen_string_literal: true + +class Smith < Formula + url "https://github.com/faustodavid/smith.git", + tag: "v0.1.0", + revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +end +""" + + +def test_update_formula_text_replaces_only_release_pin() -> None: + updater = _load_formula_module() + + updated = updater.update_formula_text(FORMULA, "v0.1.1", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + + assert 'tag: "v0.1.1"' in updated + assert 'revision: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"' in updated + assert "https://github.com/faustodavid/smith.git" in updated + + +def test_update_formula_check_fails_when_formula_is_stale(tmp_path: Path) -> None: + updater = _load_formula_module() + formula = tmp_path / "smith.rb" + formula.write_text(FORMULA, encoding="utf-8") + + with pytest.raises(updater.FormulaUpdateError, match="pins v0.1.0@"): + updater.update_formula(formula, "v0.1.1", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", check=True) + + assert 'tag: "v0.1.0"' in formula.read_text(encoding="utf-8") + + +def test_update_formula_is_idempotent_when_current(tmp_path: Path) -> None: + updater = _load_formula_module() + formula = tmp_path / "smith.rb" + formula.write_text(FORMULA, encoding="utf-8") + + changed = updater.update_formula(formula, "v0.1.0", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", check=True) + + assert changed is False + assert formula.read_text(encoding="utf-8") == FORMULA + + +def test_load_project_version_reads_pyproject(tmp_path: Path) -> None: + updater = _load_formula_module() + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + """ +[project] +name = "smith" +version = "1.2.3" +""".lstrip(), + encoding="utf-8", + ) + + assert updater.load_project_version(pyproject) == "1.2.3" + + +def test_main_rejects_tag_that_does_not_match_project_version(tmp_path: Path) -> None: + updater = _load_formula_module() + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nversion = "1.2.3"\n', encoding="utf-8") + formula = tmp_path / "smith.rb" + formula.write_text(FORMULA, encoding="utf-8") + + with pytest.raises(updater.FormulaUpdateError, match="does not match project.version"): + updater.main( + [ + "--pyproject", + str(pyproject), + "--formula", + str(formula), + "--tag", + "v1.2.4", + "--revision", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ] + ) + + +def test_update_formula_rejects_unsafe_tag() -> None: + updater = _load_formula_module() + + with pytest.raises(updater.FormulaUpdateError, match="tag must start with v"): + updater.update_formula_text(FORMULA, 'v1.2.3"; echo nope', "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")