diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8facd2f..d570437 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,7 @@ jobs: timeout-minutes: 15 outputs: release_commit: ${{ steps.release-source.outputs.release_commit }} + homebrew_eligible: ${{ steps.release-plan.outputs.homebrew_eligible }} env: TAG_NAME: ${{ inputs.tag }} steps: @@ -69,9 +70,29 @@ jobs: python scripts/release.py release-artifacts - name: Verify GitHub release inputs + id: release-plan run: | + plan_file="$RUNNER_TEMP/github-release-plan.txt" uv run --locked --extra dev \ python scripts/release.py github-release-plan \ + --expected-tag "$TAG_NAME" > "$plan_file" + cat "$plan_file" + mapfile -t plan < "$plan_file" + if [[ "${#plan[@]}" -ne 3 \ + || ! "${plan[0]}" =~ ^prerelease=(true|false)$ \ + || ! "${plan[1]}" =~ ^latest=(true|false)$ \ + || ! "${plan[2]}" =~ ^notes_start_tag=(v[0-9A-Za-z._!-]+)?$ ]] + then + echo "The verified release plan returned malformed output." >&2 + exit 1 + fi + echo "homebrew_eligible=${plan[1]#latest=}" >> "$GITHUB_OUTPUT" + + - name: Prepare Homebrew tap formula + if: steps.release-plan.outputs.homebrew_eligible == 'true' + run: | + uv run --locked --extra dev \ + python scripts/release.py homebrew-formula \ --expected-tag "$TAG_NAME" - name: Upload verified release bundle @@ -87,6 +108,17 @@ jobs: overwrite: true retention-days: 30 + - name: Upload verified Homebrew formula + if: steps.release-plan.outputs.homebrew_eligible == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: homebrew-formula + path: .release/homebrew/Formula/crewplane.rb + include-hidden-files: true + if-no-files-found: error + overwrite: true + retention-days: 30 + github-release: name: github-release needs: [verify] @@ -134,3 +166,92 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: scripts/publish_github_release.sh dist + + homebrew-pr: + name: homebrew-pr + needs: [verify, github-release] + if: needs.verify.outputs.homebrew_eligible == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + env: + TAG_NAME: ${{ inputs.tag }} + SOURCE_COMMIT: ${{ needs.verify.outputs.release_commit }} + steps: + - name: Check out verified release commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ needs.verify.outputs.release_commit }} + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + version-file: "packaging/uv-bootstrap-version.txt" + enable-cache: false + + - name: Install dependencies + run: uv sync --locked --extra dev + + - name: Download verified release bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: release-bundle + path: . + + - name: Download verified Homebrew formula + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: homebrew-formula + path: .release/homebrew/Formula + + - name: Create Homebrew tap token + id: tap-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.HOMEBREW_UPDATER_CLIENT_ID }} + private-key: ${{ secrets.HOMEBREW_UPDATER_PRIVATE_KEY }} + owner: crewplaneai + repositories: homebrew-crewplane + permission-contents: write + permission-pull-requests: write + + - name: Check out Homebrew tap + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + repository: crewplaneai/homebrew-crewplane + ref: main + fetch-depth: 0 + path: homebrew-tap + token: ${{ steps.tap-token.outputs.token }} + persist-credentials: false + + - name: Configure Homebrew tap authentication + env: + GH_TOKEN: ${{ steps.tap-token.outputs.token }} + APP_SLUG: ${{ steps.tap-token.outputs.app-slug }} + run: | + app_user="${APP_SLUG}[bot]" + app_user_id="$(gh api "/users/$app_user" --jq .id)" + git -C homebrew-tap config user.name "$app_user" + git -C homebrew-tap config user.email \ + "${app_user_id}+${app_user}@users.noreply.github.com" + gh auth setup-git + + - name: Publish Homebrew pull request + env: + GH_TOKEN: ${{ steps.tap-token.outputs.token }} + run: | + uv run --locked --extra dev \ + python scripts/release.py publish-homebrew-pr \ + --expected-tag "$TAG_NAME" \ + --source-commit "$SOURCE_COMMIT" \ + --tap-root homebrew-tap \ + --execute diff --git a/CHANGELOG.md b/CHANGELOG.md index 2317de0..f70ae3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable user-facing changes are recorded here. ## [Unreleased] +### Changed + +- Stable releases now open a Homebrew tap pull request automatically; bottle + publication remains a manual `brew pr-pull` step. + ## [0.2.0] - 2026-08-23 ### Breaking Changes diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 22e4da7..796a620 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -84,9 +84,10 @@ Current CI policy: - Default branch: `master`. - The supported platform matrix is defined in [Supported Platforms](#supported-platforms). -- Production publishing is local-only. Follow the - [Release Workflow](#release-workflow). GitHub Actions does not publish - production PyPI or npm packages and does not need their credentials. +- Production PyPI and npm publishing is local-only. Follow the + [Release Workflow](#release-workflow). After `make release` publishes the + packages and Git tag, the source repository's release Action publishes the + GitHub Release and opens the Homebrew pull request. - Workflow actions and `uv` are version-pinned. `packaging/uv-bootstrap.json` is the source of truth for the `uv` version and installer checksums. The updater generates `packaging/uv-bootstrap-version.txt` from this manifest for @@ -164,9 +165,10 @@ During the public-alpha `0.x` period, support the current schema only. Persisted ## Release Workflow -Production releases have two publication phases: publish the packages and Git -tag locally, then publish the GitHub Release. Use `make help` for target -details. +Production releases publish PyPI, npm, and the Git tag locally. The release +Action then publishes the GitHub Release and opens a Homebrew pull request for +the newest stable release. Publishing the tested bottles remains a manual +`brew pr-pull` step. ### 1. Prepare and validate @@ -200,22 +202,28 @@ For a non-interactive npm release that requires two-factor authentication, set ### 3. Publish the GitHub Release -Immediately after `make release` pushes the tag, dispatch -`.github/workflows/release.yml` from `master`. Use the full Git tag, including -the `v` prefix, as the required `tag` input: for package version `0.1.4`, enter -`v0.1.4`, not `0.1.4`. Do this before `master` advances: dispatching from -another ref, backfilling a historical tag, or dispatching after a newer commit -reaches `master` is unsupported. +After `make release` pushes the tag, run the source repository's `release` +GitHub Action from `master`. Enter the full Git tag, including the `v` prefix: +for package version `0.1.4`, enter `v0.1.4`. +Dispatch it before `master` advances; the tag must point to the currently +selected `master` commit. -The workflow verifies the tagged release and publishes the GitHub Release. It -does not publish the production PyPI or npm packages. Prereleases are never -marked as GitHub `Latest`; a stable release is marked `Latest` only when it is -the highest published stable version on PyPI. +The Action publishes the GitHub Release. For the newest stable release, it also +automatically opens a pull request in `crewplaneai/homebrew-crewplane`. -### 4. Publish Homebrew separately +### 4. Publish the tested Homebrew pull request -Copy the prepared formula into the Homebrew tap, run the tap's audit and test -steps, and push the tap update. +1. Wait for both the macOS and Linux `brew test-bot` checks to pass and upload + their bottles. +2. Do **not** click the pull request's normal Merge button. +3. In `homebrew-crewplane`, run the `brew pr-pull` Action with: + + - The pull request number. + - Preferably the pull request's current head SHA, which prevents publishing + a revision that was not tested. + +The `brew pr-pull` Action collects the tested bottles, updates the formula's +bottle metadata, and pushes the completed release to `main`. ### Recover an interrupted release diff --git a/Makefile b/Makefile index 932b0f1..681a6af 100644 --- a/Makefile +++ b/Makefile @@ -85,7 +85,7 @@ help: ' NPM_DIST_TAG_OTP npm one-time password for npm dist-tag add in non-TTY mode' \ ' NPM_PUBLISH_ARGS Extra arguments passed to npm publish and dist-tag' \ '' \ - 'Homebrew tap publishing is separate: copy the prepared formula into the tap, audit/test there, and push the tap update.' + 'Eligible GitHub releases open a Homebrew tap PR; publish it with the tap brew pr-pull workflow after checks pass.' setup: $(INSTALL_CMD) diff --git a/packaging/homebrew/Formula/crewplane.rb b/packaging/homebrew/Formula/crewplane.rb index 968e680..0345e37 100644 --- a/packaging/homebrew/Formula/crewplane.rb +++ b/packaging/homebrew/Formula/crewplane.rb @@ -11,6 +11,7 @@ class Crewplane < Formula depends_on "maturin" => :build depends_on "rust" => :build + depends_on "libyaml" depends_on "python@3.13" resource "hatchling" do diff --git a/scripts/release.py b/scripts/release.py index 59b6df7..b7d7580 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -7,7 +7,7 @@ from pathlib import Path from markdown_it import MarkdownIt -from release import build, publish, smoke +from release import build, homebrew, publish, smoke from release.state import ( CommandRunner, ReleaseError, @@ -59,9 +59,16 @@ def parse_args(argv: list[str]) -> argparse.Namespace: "--expected-tag", help="The tag expected by the release workflow. Must match context.version.tag.", ) + homebrew_formula_parser = subparsers.add_parser("homebrew-formula") + homebrew_formula_parser.add_argument("--expected-tag", required=True) for command in ("publish-pypi", "publish-npm", "finalize"): command_parser = subparsers.add_parser(command) command_parser.add_argument("--execute", action="store_true") + homebrew_pr_parser = subparsers.add_parser("publish-homebrew-pr") + homebrew_pr_parser.add_argument("--expected-tag", required=True) + homebrew_pr_parser.add_argument("--source-commit", required=True) + homebrew_pr_parser.add_argument("--tap-root", type=Path, required=True) + homebrew_pr_parser.add_argument("--execute", action="store_true") return parser.parse_args(argv) @@ -90,6 +97,9 @@ def dispatch(args: argparse.Namespace, root: Path, runner: CommandRunner) -> int if command == "github-release-plan": publish.print_github_release_plan(root, runner, args.expected_tag) return 0 + if command == "homebrew-formula": + homebrew.prepare_formula(root, args.expected_tag) + return 0 if command == "confirm": publish.confirm_release(root) return 0 @@ -99,6 +109,14 @@ def dispatch(args: argparse.Namespace, root: Path, runner: CommandRunner) -> int return publish.publish_npm(root, runner, bool(args.execute)) if command == "finalize": return publish.finalize_release(root, runner, bool(args.execute)) + if command == "publish-homebrew-pr": + options = homebrew.HomebrewPrOptions( + expected_tag=args.expected_tag, + source_commit=args.source_commit, + tap_root=args.tap_root, + execute=bool(args.execute), + ) + return homebrew.publish_formula_pull_request(root, runner, options) if command == "package-build": build.package_build(root, runner) return 0 diff --git a/scripts/release/build.py b/scripts/release/build.py index c2a2f1e..fdfac9a 100644 --- a/scripts/release/build.py +++ b/scripts/release/build.py @@ -284,7 +284,6 @@ def print_homebrew_instructions(context: ReleaseContext) -> None: print("Homebrew formula ready:") print(f" {formula}") print("") - print("Copy it to:") - print(f" crewplaneai/homebrew-crewplane/Formula/{context.package_name}.rb") - print("") - print("Then run brew audit/test in the tap repository and push the tap update.") + print("After publishing the Git tag, run the GitHub release workflow.") + print("Eligible releases open a Homebrew tap pull request automatically.") + print("After its checks pass, publish it with the tap's brew pr-pull workflow.") diff --git a/scripts/release/homebrew.py b/scripts/release/homebrew.py new file mode 100644 index 0000000..01f346e --- /dev/null +++ b/scripts/release/homebrew.py @@ -0,0 +1,673 @@ +from __future__ import annotations + +import json +import os +import re +import urllib.parse +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path + +from packaging.version import InvalidVersion, Version + +from .state import ( + CommandRunner, + PypiFile, + PypiRelease, + ReleaseContext, + ReleaseError, + ReleaseManifest, + manifest_context_issues, + query_pypi_release, + read_formula_state, + read_manifest, + read_release_context, + verify_formula_state_for_release, + verify_pypi_artifacts, +) + +TAP_OWNER = "crewplaneai" +TAP_REPOSITORY = f"{TAP_OWNER}/homebrew-crewplane" +TAP_BASE_BRANCH = "main" +TAP_FORMULA_PATH = Path("Formula/crewplane.rb") +TAP_FORMULA_ARTIFACT = Path(".release/homebrew/Formula/crewplane.rb") +AUTOMATION_MARKER = "" +SOURCE_REPOSITORY = "crewplaneai/crewplane" + + +class HomebrewEligibility(StrEnum): + ELIGIBLE = "eligible" + PRERELEASE = "prerelease" + SUPERSEDED = "superseded" + + +@dataclass(frozen=True) +class HomebrewRelease: + context: ReleaseContext + manifest: ReleaseManifest + pypi: PypiRelease + sdist: PypiFile + formula: str + + +@dataclass(frozen=True) +class HomebrewPrOptions: + expected_tag: str + source_commit: str + tap_root: Path + execute: bool + + +@dataclass(frozen=True) +class PullRequestSnapshot: + number: int + state: str + merged_at: str | None + url: str + body: str + head_sha: str + head_branch: str + base_branch: str + title: str + + +def prepare_formula(root: Path, expected_tag: str) -> Path: + release = verified_homebrew_release(root, expected_tag) + require_eligible_release(release) + output = root / TAP_FORMULA_ARTIFACT + ensure_safe_formula_output(root, output) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(release.formula, encoding="utf-8") + print(f"Prepared Homebrew tap formula: {TAP_FORMULA_ARTIFACT}") + return output + + +def publish_formula_pull_request( + root: Path, runner: CommandRunner, options: HomebrewPrOptions +) -> int: + if not options.execute: + print("Dry run only. Re-run with --execute in the GitHub release workflow.") + return 1 + validate_source_identity(root, runner, options) + release = verified_homebrew_release(root, options.expected_tag) + eligibility = release_eligibility(release.context, release.pypi) + if eligibility != HomebrewEligibility.ELIGIBLE: + print_homebrew_skip(release.context, eligibility) + return 0 + verify_prepared_formula(root, release.formula) + require_github_authentication() + tap_root = resolve_tap_root(root, options.tap_root) + verify_tap_checkout(tap_root, runner) + return update_pull_request(tap_root, runner, release, options.source_commit) + + +def verified_homebrew_release(root: Path, expected_tag: str) -> HomebrewRelease: + context = read_release_context(root) + if expected_tag != context.version.tag: + raise ReleaseError( + f"expected tag {expected_tag!r} does not match {context.version.tag!r}" + ) + manifest = read_manifest(root) + issues = manifest_context_issues(context, manifest) + formula_state = read_formula_state(context) + issues.extend(verify_formula_state_for_release(context, formula_state, manifest)) + pypi = query_pypi_release(context) + issues.extend(verify_pypi_artifacts(context, pypi, manifest)) + if issues: + raise ReleaseError( + "Homebrew formula verification failed:\n " + "\n ".join(issues) + ) + sdist = pypi.files[context.sdist_filename] + validate_pypi_sdist(context, sdist) + source = formula_state.path.read_text(encoding="utf-8") + formula = render_formula(source, sdist.url, sdist.sha256) + return HomebrewRelease(context, manifest, pypi, sdist, formula) + + +def release_eligibility( + context: ReleaseContext, pypi: PypiRelease +) -> HomebrewEligibility: + if context.version.is_prerelease: + return HomebrewEligibility.PRERELEASE + try: + latest = Version(pypi.latest_stable) + except InvalidVersion as error: + raise ReleaseError("PyPI has no verifiable stable Homebrew release") from error + target = Version(context.version.python) + if latest < target: + raise ReleaseError( + "PyPI reports a stable release older than the target version" + ) + if latest > target: + return HomebrewEligibility.SUPERSEDED + return HomebrewEligibility.ELIGIBLE + + +def require_eligible_release(release: HomebrewRelease) -> None: + eligibility = release_eligibility(release.context, release.pypi) + if eligibility != HomebrewEligibility.ELIGIBLE: + raise ReleaseError( + f"Homebrew formula generation skipped for {eligibility.value} release " + f"{release.context.version.project}" + ) + + +def validate_pypi_sdist(context: ReleaseContext, sdist: PypiFile) -> None: + if sdist.package_type != "sdist": + raise ReleaseError("PyPI release metadata does not identify the sdist") + if sdist.yanked: + raise ReleaseError("PyPI sdist is yanked") + parsed = urllib.parse.urlsplit(sdist.url) + decoded_path = urllib.parse.unquote(parsed.path) + canonical_path = re.fullmatch( + rf"/packages/[0-9a-f]{{2}}/[0-9a-f]{{2}}/[0-9a-f]{{20,}}/" + rf"{re.escape(context.sdist_filename)}", + decoded_path, + ) + if ( + parsed.scheme != "https" + or parsed.netloc != "files.pythonhosted.org" + or parsed.query + or parsed.fragment + or canonical_path is None + ): + raise ReleaseError( + "PyPI sdist does not use a canonical files.pythonhosted.org URL" + ) + + +def render_formula(source: str, sdist_url: str, sha256: str) -> str: + rendered = replace_one_formula_line( + source, r'^ url "[^"]+"$', f' url "{sdist_url}"', "url" + ) + rendered = replace_one_formula_line( + rendered, r'^ sha256 "[a-f0-9]{64}"$', f' sha256 "{sha256}"', "sha256" + ) + rendered = remove_one_formula_line(rendered, r'^ version "[^"]+"\n', "version") + if rendered.count(' depends_on "libyaml"') != 1: + raise ReleaseError( + 'Homebrew formula must contain one depends_on "libyaml" line' + ) + if re.search(r"^ bottle do$", rendered, re.MULTILINE): + raise ReleaseError("source Homebrew formula must not contain bottle metadata") + return rendered + + +def replace_one_formula_line( + text: str, pattern: str, replacement: str, label: str +) -> str: + matches = re.findall(pattern, text, re.MULTILINE) + if len(matches) != 1: + raise ReleaseError( + f"Homebrew formula must contain exactly one top-level {label}" + ) + return re.sub(pattern, replacement, text, count=1, flags=re.MULTILINE) + + +def remove_one_formula_line(text: str, pattern: str, label: str) -> str: + matches = re.findall(pattern, text, re.MULTILINE) + if len(matches) != 1: + raise ReleaseError( + f"Homebrew formula must contain exactly one top-level {label}" + ) + return re.sub(pattern, "", text, count=1, flags=re.MULTILINE) + + +def formula_without_bottle_block(formula: str) -> str: + pattern = re.compile( + r"^ bottle do\n(?:(?: [^\n]*)?\n)*?^ end\n(?:\n)?", + re.MULTILINE, + ) + matches = tuple(pattern.finditer(formula)) + if len(matches) > 1: + raise ReleaseError("Homebrew formula contains multiple bottle blocks") + return pattern.sub("", formula, count=1) + + +def formula_version(formula: str, package_name: str) -> Version: + match = re.search(r'^ url "([^"]+)"$', formula, re.MULTILINE) + if match is None: + raise ReleaseError("tap formula is missing its top-level URL") + filename = Path( + urllib.parse.unquote(urllib.parse.urlsplit(match.group(1)).path) + ).name + prefix = f"{package_name}-" + suffix = ".tar.gz" + if not filename.startswith(prefix) or not filename.endswith(suffix): + raise ReleaseError( + "tap formula URL does not contain the expected sdist filename" + ) + try: + return Version(filename[len(prefix) : -len(suffix)]) + except InvalidVersion as error: + raise ReleaseError("tap formula URL contains an invalid version") from error + + +def ensure_safe_formula_output(root: Path, output: Path) -> None: + resolved_root = root.resolve() + if not output.resolve(strict=False).is_relative_to(resolved_root): + raise ReleaseError("Homebrew formula output escapes the source repository") + if output.is_symlink() or (output.exists() and not output.is_file()): + raise ReleaseError(f"Homebrew formula output is not a regular file: {output}") + + +def verify_prepared_formula(root: Path, expected: str) -> None: + path = root / TAP_FORMULA_ARTIFACT + ensure_safe_formula_output(root, path) + if not path.is_file(): + raise ReleaseError( + f"prepared Homebrew formula is missing: {TAP_FORMULA_ARTIFACT}" + ) + if path.read_text(encoding="utf-8") != expected: + raise ReleaseError( + "prepared Homebrew formula does not match fresh PyPI metadata" + ) + + +def validate_source_identity( + root: Path, runner: CommandRunner, options: HomebrewPrOptions +) -> None: + if re.fullmatch(r"[0-9a-f]{40}", options.source_commit) is None: + raise ReleaseError("source commit must be a full lowercase Git SHA") + head = runner.run(["git", "rev-parse", "HEAD"], cwd=root).stdout.strip() + tag_commit = runner.run( + ["git", "rev-parse", f"refs/tags/{options.expected_tag}^{{commit}}"], cwd=root + ).stdout.strip() + if head != options.source_commit or tag_commit != options.source_commit: + raise ReleaseError( + "source checkout, release tag, and verified commit do not match" + ) + + +def require_github_authentication() -> None: + if not os.environ.get("GH_TOKEN"): + raise ReleaseError("GH_TOKEN must contain a Homebrew tap GitHub App token") + + +def resolve_tap_root(root: Path, requested: Path) -> Path: + if requested.is_absolute(): + raise ReleaseError("Homebrew tap checkout path must be relative") + resolved_root = root.resolve() + tap_root = (root / requested).resolve() + if not tap_root.is_relative_to(resolved_root) or tap_root == resolved_root: + raise ReleaseError("Homebrew tap checkout must be inside the source workspace") + if not tap_root.is_dir(): + raise ReleaseError(f"Homebrew tap checkout is missing: {requested}") + return tap_root + + +def verify_tap_checkout(tap_root: Path, runner: CommandRunner) -> None: + origin = runner.run(["git", "remote", "get-url", "origin"], cwd=tap_root).stdout + normalized = origin.strip().removesuffix(".git") + accepted = { + f"https://github.com/{TAP_REPOSITORY}", + f"git@github.com:{TAP_REPOSITORY}", + } + if normalized not in accepted: + raise ReleaseError(f"Homebrew tap origin is not {TAP_REPOSITORY}") + status = runner.run( + ["git", "status", "--porcelain=v1", "--untracked-files=all"], cwd=tap_root + ).stdout + if status: + raise ReleaseError("Homebrew tap checkout must be clean before publication") + + +def update_pull_request( + tap_root: Path, + runner: CommandRunner, + release: HomebrewRelease, + source_commit: str, +) -> int: + base_sha = fetch_tap_main(tap_root, runner) + current_formula = git_output( + runner, tap_root, ["git", "show", f"{base_sha}:{TAP_FORMULA_PATH}"] + ) + target_version = Version(release.context.version.python) + current_version = formula_version(current_formula, release.context.package_name) + if current_version > target_version: + print_homebrew_skip(release.context, HomebrewEligibility.SUPERSEDED) + return 0 + if current_version == target_version: + if formula_without_bottle_block(current_formula) == release.formula: + print( + f"Homebrew tap already contains crewplane {release.context.version.project}." + ) + return 0 + raise ReleaseError("tap formula has conflicting changes for the target version") + + branch = f"automation/crewplane-{release.context.version.project}" + title = f"crewplane {release.context.version.project}" + body = pull_request_body(release, source_commit) + pull_request = query_pull_request(tap_root, runner, branch) + remote_sha = remote_branch_sha(tap_root, runner, branch) + validate_existing_automation( + tap_root, runner, pull_request, remote_sha, branch, title, body, release.formula + ) + + if remote_sha: + parent_sha = git_output( + runner, tap_root, ["git", "show", "-s", "--format=%P", remote_sha] + ).strip() + if parent_sha == base_sha and pull_request is not None: + if pull_request.state == "CLOSED": + reopen_pull_request(tap_root, runner, pull_request.number) + verify_published_pull_request( + tap_root, runner, branch, title, body, remote_sha + ) + print(f"Verified existing Homebrew pull request: {pull_request.url}") + return 0 + + new_sha = commit_formula_update( + tap_root, runner, branch, base_sha, remote_sha, title, release.formula + ) + if fetch_tap_main(tap_root, runner) != base_sha: + raise ReleaseError("Homebrew tap main changed during publication; rerun safely") + if pull_request is None: + create_pull_request(tap_root, runner, branch, title, body) + elif pull_request.state == "CLOSED": + reopen_pull_request(tap_root, runner, pull_request.number) + snapshot = verify_published_pull_request( + tap_root, runner, branch, title, body, new_sha + ) + print(f"Published Homebrew pull request: {snapshot.url}") + return 0 + + +def fetch_tap_main(tap_root: Path, runner: CommandRunner) -> str: + runner.run( + [ + "git", + "fetch", + "--quiet", + "--no-tags", + "origin", + f"refs/heads/{TAP_BASE_BRANCH}", + ], + cwd=tap_root, + ) + sha = git_output(runner, tap_root, ["git", "rev-parse", "FETCH_HEAD"]).strip() + if re.fullmatch(r"[0-9a-f]{40}", sha) is None: + raise ReleaseError("could not resolve Homebrew tap main") + return sha + + +def remote_branch_sha(tap_root: Path, runner: CommandRunner, branch: str) -> str: + reference = f"refs/heads/{branch}" + output = git_output( + runner, tap_root, ["git", "ls-remote", "--heads", "origin", reference] + ).strip() + if not output: + return "" + fields = output.split("\t") + if ( + len(fields) != 2 + or fields[1] != reference + or re.fullmatch(r"[0-9a-f]{40}", fields[0]) is None + ): + raise ReleaseError("Homebrew automation branch query returned malformed output") + runner.run( + ["git", "fetch", "--quiet", "--no-tags", "origin", reference], cwd=tap_root + ) + fetched_sha = git_output( + runner, tap_root, ["git", "rev-parse", "FETCH_HEAD"] + ).strip() + if fetched_sha != fields[0]: + raise ReleaseError("Homebrew automation branch changed during inspection") + return fetched_sha + + +def query_pull_request( + tap_root: Path, runner: CommandRunner, branch: str +) -> PullRequestSnapshot | None: + result = runner.run( + [ + "gh", + "api", + f"repos/{TAP_REPOSITORY}/pulls", + "--method", + "GET", + "--field", + "state=all", + "--field", + f"head={TAP_OWNER}:{branch}", + "--field", + f"base={TAP_BASE_BRANCH}", + "--field", + "per_page=2", + ], + cwd=tap_root, + ) + return parse_pull_request_snapshot(result.stdout) + + +def parse_pull_request_snapshot(payload: str) -> PullRequestSnapshot | None: + try: + records = json.loads(payload) + except json.JSONDecodeError as error: + raise ReleaseError( + "Homebrew pull request query returned invalid JSON" + ) from error + if not isinstance(records, list): + raise ReleaseError("Homebrew pull request query returned malformed data") + if not records: + return None + if len(records) != 1: + raise ReleaseError("Homebrew automation branch has multiple pull requests") + record = records[0] + if not isinstance(record, dict): + raise ReleaseError("Homebrew pull request query returned malformed data") + head = record.get("head") + base = record.get("base") + if not isinstance(head, dict) or not isinstance(base, dict): + raise ReleaseError("Homebrew pull request query returned malformed data") + head_repository = head.get("repo") + if not isinstance(head_repository, dict): + raise ReleaseError("Homebrew pull request query returned incomplete data") + body = record.get("body") + if not isinstance(body, str) or not body.startswith(AUTOMATION_MARKER): + raise ReleaseError("existing Homebrew pull request lacks the automation marker") + values = { + "state": record.get("state"), + "url": record.get("html_url"), + "head_sha": head.get("sha"), + "head_branch": head.get("ref"), + "head_repository": head_repository.get("full_name"), + "base_branch": base.get("ref"), + "title": record.get("title"), + } + if any(not isinstance(value, str) for value in values.values()): + raise ReleaseError("Homebrew pull request query returned incomplete data") + number = record.get("number") + merged_at = record.get("merged_at") + if ( + not isinstance(number, int) + or isinstance(number, bool) + or number <= 0 + or values["state"] not in {"open", "closed"} + or values["head_repository"] != TAP_REPOSITORY + or (merged_at is not None and not isinstance(merged_at, str)) + ): + raise ReleaseError("Homebrew pull request query returned invalid data") + return PullRequestSnapshot( + number=number, + state=values["state"].upper(), + merged_at=merged_at, + url=values["url"], + body=body, + head_sha=values["head_sha"], + head_branch=values["head_branch"], + base_branch=values["base_branch"], + title=values["title"], + ) + + +def validate_existing_automation( + tap_root: Path, + runner: CommandRunner, + pull_request: PullRequestSnapshot | None, + remote_sha: str, + branch: str, + title: str, + body: str, + expected_formula: str, +) -> None: + if pull_request is not None: + if pull_request.merged_at is not None: + raise ReleaseError( + "Homebrew pull request is merged but tap main is inconsistent" + ) + if ( + pull_request.head_branch != branch + or pull_request.base_branch != TAP_BASE_BRANCH + or pull_request.title != title + or pull_request.body != body + ): + raise ReleaseError("existing Homebrew pull request metadata was changed") + if remote_sha and pull_request.head_sha != remote_sha: + raise ReleaseError("Homebrew pull request head does not match its branch") + if not remote_sha and pull_request.state == "OPEN": + raise ReleaseError("open Homebrew pull request is missing its branch") + if not remote_sha: + return + subject = git_output( + runner, tap_root, ["git", "show", "-s", "--format=%s", remote_sha] + ).strip() + if subject != title: + raise ReleaseError( + "existing Homebrew branch is not owned by release automation" + ) + parents = git_output( + runner, tap_root, ["git", "show", "-s", "--format=%P", remote_sha] + ).split() + if len(parents) != 1: + raise ReleaseError("existing Homebrew automation branch is not a single commit") + changed = git_output( + runner, tap_root, ["git", "diff", "--name-only", parents[0], remote_sha] + ).splitlines() + if changed != [str(TAP_FORMULA_PATH)]: + raise ReleaseError( + "existing Homebrew automation branch changes unexpected files" + ) + branch_formula = git_output( + runner, tap_root, ["git", "show", f"{remote_sha}:{TAP_FORMULA_PATH}"] + ) + if branch_formula != expected_formula: + raise ReleaseError( + "existing Homebrew automation branch has conflicting formula content" + ) + + +def commit_formula_update( + tap_root: Path, + runner: CommandRunner, + branch: str, + base_sha: str, + remote_sha: str, + title: str, + formula: str, +) -> str: + runner.run(["git", "switch", "--force-create", branch, base_sha], cwd=tap_root) + formula_path = tap_root / TAP_FORMULA_PATH + if formula_path.is_symlink() or not formula_path.is_file(): + raise ReleaseError("tap formula target is not a regular file") + formula_path.write_text(formula, encoding="utf-8") + status = git_output( + runner, + tap_root, + ["git", "status", "--porcelain=v1", "--untracked-files=all"], + ).splitlines() + if status != [f" M {TAP_FORMULA_PATH}"]: + raise ReleaseError("Homebrew update would change files outside the formula") + runner.run(["git", "add", "--", str(TAP_FORMULA_PATH)], cwd=tap_root) + staged = git_output( + runner, tap_root, ["git", "diff", "--cached", "--name-only"] + ).splitlines() + if staged != [str(TAP_FORMULA_PATH)]: + raise ReleaseError("Homebrew commit contains unexpected files") + runner.run(["git", "commit", "-m", title], cwd=tap_root) + new_sha = git_output(runner, tap_root, ["git", "rev-parse", "HEAD"]).strip() + lease = f"--force-with-lease=refs/heads/{branch}:{remote_sha}" + runner.run( + ["git", "push", lease, "origin", f"HEAD:refs/heads/{branch}"], cwd=tap_root + ) + return new_sha + + +def create_pull_request( + tap_root: Path, runner: CommandRunner, branch: str, title: str, body: str +) -> None: + runner.run( + [ + "gh", + "pr", + "create", + "--repo", + TAP_REPOSITORY, + "--base", + TAP_BASE_BRANCH, + "--head", + branch, + "--title", + title, + "--body", + body, + ], + cwd=tap_root, + ) + + +def reopen_pull_request(tap_root: Path, runner: CommandRunner, number: int) -> None: + runner.run( + ["gh", "pr", "reopen", str(number), "--repo", TAP_REPOSITORY], cwd=tap_root + ) + + +def verify_published_pull_request( + tap_root: Path, + runner: CommandRunner, + branch: str, + title: str, + body: str, + head_sha: str, +) -> PullRequestSnapshot: + snapshot = query_pull_request(tap_root, runner, branch) + if snapshot is None: + raise ReleaseError("Homebrew pull request is missing after publication") + if ( + snapshot.state != "OPEN" + or snapshot.merged_at is not None + or snapshot.title != title + or snapshot.body != body + or snapshot.head_sha != head_sha + or snapshot.head_branch != branch + or snapshot.base_branch != TAP_BASE_BRANCH + ): + raise ReleaseError("Homebrew pull request does not match the published update") + return snapshot + + +def pull_request_body(release: HomebrewRelease, source_commit: str) -> str: + tag = release.context.version.tag + return ( + f"{AUTOMATION_MARKER}\n" + f"Automated Homebrew update for Crewplane `{release.context.version.project}`.\n\n" + f"- Source release: https://github.com/{SOURCE_REPOSITORY}/releases/tag/{tag}\n" + f"- Source commit: `{source_commit}`\n" + f"- PyPI sdist: {release.sdist.url}\n" + f"- SHA-256: `{release.sdist.sha256}`\n\n" + "After the Homebrew checks pass, publish this PR with the tap's " + "`brew pr-pull` workflow.\n" + ) + + +def print_homebrew_skip( + context: ReleaseContext, eligibility: HomebrewEligibility +) -> None: + print( + f"Skipping Homebrew pull request for {context.version.project}: " + f"release is {eligibility.value}." + ) + + +def git_output(runner: CommandRunner, cwd: Path, command: list[str]) -> str: + return runner.run(command, cwd=cwd).stdout diff --git a/scripts/release/state_types.py b/scripts/release/state_types.py index ec6180b..8e51a6e 100644 --- a/scripts/release/state_types.py +++ b/scripts/release/state_types.py @@ -231,6 +231,9 @@ class PypiFile: filename: str size: int sha256: str + url: str = "" + package_type: str = "" + yanked: bool = False @dataclass(frozen=True) @@ -493,10 +496,24 @@ def query_pypi_release(context: ReleaseContext) -> PypiRelease: digests = file_payload.get("digests") if not filename or not isinstance(digests, dict) or "sha256" not in digests: raise ReleaseError(f"PyPI file metadata is incomplete for {version_key!r}") + if filename in files_by_name: + raise ReleaseError( + f"PyPI release {version_key!r} includes duplicate file {filename!r}" + ) + url = file_payload.get("url", "") + package_type = file_payload.get("packagetype", "") + yanked = file_payload.get("yanked", False) + if not isinstance(url, str) or not isinstance(package_type, str): + raise ReleaseError(f"PyPI file metadata is incomplete for {version_key!r}") + if not isinstance(yanked, bool): + raise ReleaseError(f"PyPI file metadata is malformed for {version_key!r}") files_by_name[filename] = PypiFile( filename=filename, size=int(file_payload.get("size", 0)), sha256=str(digests["sha256"]), + url=url, + package_type=package_type, + yanked=yanked, ) return PypiRelease( exists=True, diff --git a/tests/integration/observability/test_compact_runtime_integration.py b/tests/integration/observability/test_compact_runtime_integration.py index aa21146..107e453 100644 --- a/tests/integration/observability/test_compact_runtime_integration.py +++ b/tests/integration/observability/test_compact_runtime_integration.py @@ -39,6 +39,7 @@ CONFIG_TEMPLATE_PATH = Path(__file__).with_name("fixtures") / "config.yml" FAKE_TMUX_BIND_ARG_THRESHOLD = 800 +FAKE_TMUX_OBSERVER_START_TIMEOUT_SECONDS = 30.0 def provider(name: str, role: ProviderRole = ProviderRole.EXECUTOR) -> ProviderSpec: @@ -514,6 +515,10 @@ def test_compact_runtime_live_tmux_startup_uses_short_script_backed_bindings( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setattr( + "crewplane.observability.runtime.OBSERVER_START_TIMEOUT_SECONDS", + FAKE_TMUX_OBSERVER_START_TIMEOUT_SECONDS, + ) workflow = build_compact_linear_workflow(tmp_path) fake_tmux_path = tmp_path / "fake-tmux" fake_tmux_log_path = tmp_path / "fake-tmux-log.jsonl" @@ -554,7 +559,7 @@ def test_compact_runtime_live_tmux_startup_uses_short_script_backed_bindings( refresh_per_second=0, warning_sink=warnings.append, ) as hub: - assert hub.active_observer_count == 1 + assert hub.active_observer_count == 1, warnings plan, secret_context = compile_plan_for_components( config=config, workflow=workflow, diff --git a/tests/unit/packaging/release_tool_support.py b/tests/unit/packaging/release_tool_support.py index 846900b..1b9d7aa 100644 --- a/tests/unit/packaging/release_tool_support.py +++ b/tests/unit/packaging/release_tool_support.py @@ -122,6 +122,7 @@ def write_minimal_repo(root: Path, version: str = "1.2.3-alpha.4") -> None: ' version "0.0.0"', f' sha256 "{"0" * 64}"', ' head "https://github.com/crewplaneai/crewplane.git", branch: "main"', + ' depends_on "libyaml"', ' resource "hatchling" do', ' url "https://example.com/hatchling-0.0.0.tar.gz"', f' sha256 "{"f" * 64}"', @@ -314,8 +315,28 @@ def matching_pypi( True, context.version.python, { - sdist.filename: state.PypiFile(sdist.filename, sdist.size, sdist.sha256), - wheel.filename: state.PypiFile(wheel.filename, wheel.size, wheel.sha256), + sdist.filename: state.PypiFile( + filename=sdist.filename, + size=sdist.size, + sha256=sdist.sha256, + url=( + "https://files.pythonhosted.org/packages/aa/bb/" + f"{'c' * 60}/{sdist.filename}" + ), + package_type="sdist", + yanked=False, + ), + wheel.filename: state.PypiFile( + filename=wheel.filename, + size=wheel.size, + sha256=wheel.sha256, + url=( + "https://files.pythonhosted.org/packages/dd/ee/" + f"{'f' * 60}/{wheel.filename}" + ), + package_type="bdist_wheel", + yanked=False, + ), }, latest_stable=( latest_stable diff --git a/tests/unit/packaging/test_release_surfaces.py b/tests/unit/packaging/test_release_surfaces.py index 44c0619..f798bb0 100644 --- a/tests/unit/packaging/test_release_surfaces.py +++ b/tests/unit/packaging/test_release_surfaces.py @@ -533,6 +533,8 @@ def test_release_script_exposes_stateful_commands() -> None: "check", "verify-complete", "github-release-plan", + "homebrew-formula", + "publish-homebrew-pr", "publish-pypi", "publish-npm", "finalize", @@ -666,8 +668,8 @@ def test_production_release_workflow_reuses_release_tool_without_pypi_publish() "Release tag must point to the dispatched master commit." in (release_source_guard["run"]) ) - assert workflow.count("TAG_NAME: ${{ inputs.tag }}") == 2 - assert workflow.count("fetch-depth: 0") == 2 + assert workflow.count("TAG_NAME: ${{ inputs.tag }}") == 3 + assert workflow.count("fetch-depth: 0") == 4 assert workflow.count("git fetch --quiet --no-tags origin refs/heads/master") == 1 assert workflow.count("git merge-base --is-ancestor") == 1 assert "ref: refs/tags/${{ inputs.tag }}" in workflow @@ -679,7 +681,7 @@ def test_production_release_workflow_reuses_release_tool_without_pypi_publish() assert "group: github-release-publication" in workflow assert "cancel-in-progress: false" in workflow assert "queue: max" in workflow - assert workflow.count("uses: actions/checkout@") == 2 + assert workflow.count("uses: actions/checkout@") == 4 assert "github.event.inputs" not in workflow assert "github.ref_name" not in workflow assert "path: tooling" not in workflow @@ -689,8 +691,11 @@ def test_production_release_workflow_reuses_release_tool_without_pypi_publish() assert workflow.count("python scripts/release.py github-release-plan") == 1 assert "python scripts/release.py github-release-plan" in release_script assert "github-release-metadata" not in workflow - assert workflow.count("needs.verify.outputs.release_commit") == 1 - assert "steps.release-plan.outputs" not in workflow + assert workflow.count("needs.verify.outputs.release_commit") == 3 + assert ( + "homebrew_eligible: ${{ steps.release-plan.outputs.homebrew_eligible }}" + in workflow + ) assert "name: release-bundle" in workflow assert "dist/*" in workflow assert ".release/npm/*.tgz" in workflow @@ -698,6 +703,14 @@ def test_production_release_workflow_reuses_release_tool_without_pypi_publish() assert "include-hidden-files: true" in workflow assert "overwrite: true" in workflow assert "scripts/publish_github_release.sh dist" in workflow + assert "python scripts/release.py homebrew-formula" in workflow + assert "python scripts/release.py publish-homebrew-pr" in workflow + homebrew_upload = next( + step + for step in workflow_config["jobs"]["verify"]["steps"] + if step["name"] == "Upload verified Homebrew formula" + ) + assert homebrew_upload["with"]["include-hidden-files"] == "true" assert "release_flags=(--prerelease --latest=false)" in release_script assert "release_flags=(--prerelease=false --latest)" in release_script assert "release_flags=(--prerelease=false --latest=false)" in release_script @@ -728,7 +741,33 @@ def test_production_release_workflow_reuses_release_tool_without_pypi_publish() assert "--verify-tag" in release_script assert "GH_REPO: ${{ github.repository }}" in workflow assert '--repo "$repository"' in release_script - assert workflow.count("contents: write") == 1 + assert workflow_config["jobs"]["github-release"]["permissions"] == { + "contents": "write" + } + homebrew_job = workflow_config["jobs"]["homebrew-pr"] + assert homebrew_job["needs"] == ["verify", "github-release"] + assert homebrew_job["if"] == ("needs.verify.outputs.homebrew_eligible == 'true'") + assert homebrew_job["permissions"] == {"contents": "read"} + homebrew_steps = {step["name"]: step for step in homebrew_job["steps"]} + token_step = homebrew_steps["Create Homebrew tap token"] + assert token_step["uses"].startswith("actions/create-github-app-token@") + assert token_step["with"] == { + "client-id": "${{ vars.HOMEBREW_UPDATER_CLIENT_ID }}", + "private-key": "${{ secrets.HOMEBREW_UPDATER_PRIVATE_KEY }}", + "owner": "crewplaneai", + "repositories": "homebrew-crewplane", + "permission-contents": "write", + "permission-pull-requests": "write", + } + tap_checkout = homebrew_steps["Check out Homebrew tap"] + assert tap_checkout["with"]["repository"] == "crewplaneai/homebrew-crewplane" + assert tap_checkout["with"]["token"] == "${{ steps.tap-token.outputs.token }}" + assert tap_checkout["with"]["persist-credentials"] == "false" + assert ( + "gh auth setup-git" + in homebrew_steps["Configure Homebrew tap authentication"]["run"] + ) + assert "--execute" in homebrew_steps["Publish Homebrew pull request"]["run"] assert "uv build" not in workflow assert "urllib.request" not in workflow assert "pypa/gh-action-pypi-publish" not in workflow @@ -1622,6 +1661,7 @@ def test_homebrew_formula_uses_normalized_python_artifact_and_virtualenv() -> No assert f'depends_on "python@{default_python}"' in formula assert 'depends_on "maturin" => :build' in formula assert 'depends_on "rust" => :build' in formula + assert 'depends_on "libyaml"' in formula assert 'branch: "master"' in formula assert f'def python3\n "python{default_python}"\n end' in formula assert "virtualenv_create(libexec, python3)" in formula diff --git a/tests/unit/packaging/test_release_tool_dispatch.py b/tests/unit/packaging/test_release_tool_dispatch.py index 8e4b820..8430558 100644 --- a/tests/unit/packaging/test_release_tool_dispatch.py +++ b/tests/unit/packaging/test_release_tool_dispatch.py @@ -83,6 +83,13 @@ def test_dispatches_build_commands( True, 0, ), + ( + ("homebrew-formula", "--expected-tag", EXPECTED_TAG), + "homebrew.prepare_formula", + False, + True, + 0, + ), (("confirm",), "publish.confirm_release", False, False, 0), (("changelog-check",), "changelog_check", False, False, 0), ), @@ -142,6 +149,43 @@ def test_dispatches_publish_commands_with_execute_flag( handler.assert_called_once_with(tmp_path, runner, execute) +@pytest.mark.parametrize("execute", (False, True), ids=("plan", "execute")) +def test_dispatches_homebrew_pr_publication_with_typed_options( + release_script: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + execute: bool, +) -> None: + handler = _mock_handler( + release_script, monkeypatch, "homebrew.publish_formula_pull_request" + ) + runner = object() + arguments = [ + "publish-homebrew-pr", + "--expected-tag", + EXPECTED_TAG, + "--source-commit", + "a" * 40, + "--tap-root", + "homebrew-tap", + ] + if execute: + arguments.append("--execute") + + result = release_script.dispatch( + release_script.parse_args(arguments), tmp_path, runner + ) + + assert result == HANDLER_RESULT + options = release_script.homebrew.HomebrewPrOptions( + expected_tag=EXPECTED_TAG, + source_commit="a" * 40, + tap_root=Path("homebrew-tap"), + execute=execute, + ) + handler.assert_called_once_with(tmp_path, runner, options) + + @pytest.mark.parametrize( ("command", "handler_path"), ( diff --git a/tests/unit/packaging/test_release_tool_homebrew.py b/tests/unit/packaging/test_release_tool_homebrew.py new file mode 100644 index 0000000..eb7bed7 --- /dev/null +++ b/tests/unit/packaging/test_release_tool_homebrew.py @@ -0,0 +1,505 @@ +from __future__ import annotations + +import json +import os +import subprocess +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path + +import pytest + +from scripts.release import homebrew, state +from tests.helpers import isolated_git as _isolated_git_support +from tests.unit.packaging.release_tool_support import ( + constant, + matching_pypi, + release_state_fixture, + write_manifest, +) + +isolated_git = _isolated_git_support.isolated_git + + +class LocalTapRunner(state.CommandRunner): + def __init__(self) -> None: + self.pull_request: dict[str, object] | None = None + self.gh_calls: list[tuple[str, ...]] = [] + + def run( + self, + command: Sequence[str], + cwd: Path, + env: Mapping[str, str] | None = None, + timeout: int | None = state.COMMAND_TIMEOUT_SECONDS, + capture_output: bool = True, + check: bool = True, + ) -> state.CommandResult: + command_tuple = tuple(command) + if command_tuple == ("git", "remote", "get-url", "origin"): + return state.CommandResult( + command_tuple, + 0, + "https://github.com/crewplaneai/homebrew-crewplane.git\n", + "", + ) + if command_tuple[:2] == ("gh", "api"): + self.gh_calls.append(command_tuple) + records = [] if self.pull_request is None else [self.pull_request] + return state.CommandResult(command_tuple, 0, json.dumps(records), "") + if command_tuple[:3] == ("gh", "pr", "create"): + self.gh_calls.append(command_tuple) + branch = command_value(command_tuple, "--head") + title = command_value(command_tuple, "--title") + body = command_value(command_tuple, "--body") + head_sha = super().run(["git", "rev-parse", "HEAD"], cwd=cwd).stdout.strip() + self.pull_request = { + "number": 7, + "state": "open", + "merged_at": None, + "html_url": ( + "https://github.com/crewplaneai/homebrew-crewplane/pull/7" + ), + "body": body, + "head": { + "sha": head_sha, + "ref": branch, + "repo": {"full_name": homebrew.TAP_REPOSITORY}, + }, + "base": {"ref": "main"}, + "title": title, + } + return state.CommandResult( + command_tuple, 0, str(self.pull_request["html_url"]), "" + ) + if command_tuple[:3] == ("gh", "pr", "reopen"): + self.gh_calls.append(command_tuple) + assert self.pull_request is not None + self.pull_request["state"] = "open" + return state.CommandResult(command_tuple, 0, "", "") + result = super().run( + command, + cwd=cwd, + env=env, + timeout=timeout, + capture_output=capture_output, + check=check, + ) + if command_tuple[:2] == ("git", "push") and self.pull_request is not None: + head = self.pull_request["head"] + assert isinstance(head, dict) + head["sha"] = ( + super().run(["git", "rev-parse", "HEAD"], cwd=cwd).stdout.strip() + ) + return result + + +@dataclass(frozen=True) +class PublicationSetup: + context: state.ReleaseContext + options: homebrew.HomebrewPrOptions + runner: LocalTapRunner + tap: Path + origin: Path + seed: Path + + +def command_value(command: tuple[str, ...], option: str) -> str: + return command[command.index(option) + 1] + + +def git(root: Path, *arguments: str) -> str: + return subprocess.run( + ["git", *arguments], + cwd=root, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def initialize_source_repository(root: Path, tag: str) -> str: + git(root, "init", "--initial-branch=master") + git(root, "config", "user.name", "Release Test") + git(root, "config", "user.email", "release@example.com") + git(root, "add", ".") + git(root, "commit", "-m", "release source") + git(root, "tag", tag) + return git(root, "rev-parse", "HEAD") + + +def initialize_tap_checkout(root: Path, formula: str) -> tuple[Path, Path, Path]: + origin = root / "tap-origin.git" + seed = root / "tap-seed" + tap = root / "homebrew-tap" + git(root, "init", "--bare", "--initial-branch=main", str(origin)) + seed.mkdir() + git(seed, "init", "--initial-branch=main") + git(seed, "config", "user.name", "Tap Test") + git(seed, "config", "user.email", "tap@example.com") + (seed / "Formula").mkdir() + (seed / "Formula" / "crewplane.rb").write_text(formula, encoding="utf-8") + git(seed, "add", ".") + git(seed, "commit", "-m", "initial formula") + git(seed, "remote", "add", "origin", str(origin)) + git(seed, "push", "origin", "main") + git(root, "clone", str(origin), str(tap)) + git(tap, "config", "user.name", "crewplane-release[bot]") + git(tap, "config", "user.email", "release-bot@example.com") + return tap, origin, seed + + +def prepared_release( + root: Path, +) -> tuple[state.ReleaseContext, state.ReleaseManifest, state.PypiRelease]: + context, manifest, _formula, _git = release_state_fixture(root, "1.2.3") + state.sync_homebrew_formula_metadata( + context, manifest.artifact("pypi_sdist").sha256 + ) + write_manifest(root, manifest) + release = matching_pypi(context, manifest) + return context, manifest, release + + +def publication_setup(root: Path, monkeypatch: pytest.MonkeyPatch) -> PublicationSetup: + context, _manifest, release = prepared_release(root) + source_commit = initialize_source_repository(root, context.version.tag) + monkeypatch.setattr(homebrew, "query_pypi_release", constant(release)) + homebrew.prepare_formula(root, context.version.tag) + tap, origin, seed = initialize_tap_checkout( + root, + """class Crewplane < Formula + url "https://files.pythonhosted.org/packages/aa/bb/cccccccccccccccccccc/crewplane-1.0.0.tar.gz" + sha256 "1111111111111111111111111111111111111111111111111111111111111111" +end +""", + ) + runner = LocalTapRunner() + monkeypatch.setenv("GH_TOKEN", "installation-token") + options = homebrew.HomebrewPrOptions( + expected_tag=context.version.tag, + source_commit=source_commit, + tap_root=tap.relative_to(root), + execute=True, + ) + return PublicationSetup(context, options, runner, tap, origin, seed) + + +def test_prepare_homebrew_formula_uses_verified_canonical_pypi_sdist( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + context, manifest, release = prepared_release(tmp_path) + source_formula = tmp_path / "packaging" / "homebrew" / "Formula" / "crewplane.rb" + source_text = source_formula.read_text(encoding="utf-8") + monkeypatch.setattr(homebrew, "query_pypi_release", constant(release)) + + output = homebrew.prepare_formula(tmp_path, context.version.tag) + + rendered = output.read_text(encoding="utf-8") + sdist = release.files[context.sdist_filename] + assert output == tmp_path / homebrew.TAP_FORMULA_ARTIFACT + assert f' url "{sdist.url}"' in rendered + assert f' sha256 "{manifest.artifact("pypi_sdist").sha256}"' in rendered + assert '\n version "' not in rendered + assert ' depends_on "libyaml"' in rendered + assert rendered.count(' resource "') == source_text.count(' resource "') + assert source_formula.read_text(encoding="utf-8") == source_text + + +@pytest.mark.parametrize( + ("mutate", "expected_error"), + ( + ( + lambda release, context: release.files.__setitem__( + context.sdist_filename, + state.PypiFile( + filename=context.sdist_filename, + size=10, + sha256="a" * 64, + url=f"https://example.com/{context.sdist_filename}", + package_type="sdist", + yanked=False, + ), + ), + "canonical files.pythonhosted.org URL", + ), + ( + lambda release, context: release.files.__setitem__( + context.sdist_filename, + state.PypiFile( + filename=context.sdist_filename, + size=10, + sha256="0" * 64, + url=( + "https://files.pythonhosted.org/packages/aa/bb/" + f"{'c' * 60}/{context.sdist_filename}" + ), + package_type="sdist", + yanked=False, + ), + ), + "PyPI hash mismatch", + ), + ( + lambda release, context: release.files.__setitem__( + context.sdist_filename, + state.PypiFile( + filename=context.sdist_filename, + size=10, + sha256="a" * 64, + url=( + "https://files.pythonhosted.org/packages/aa/bb/" + f"{'c' * 60}/{context.sdist_filename}" + ), + package_type="sdist", + yanked=True, + ), + ), + "yanked", + ), + ), +) +def test_prepare_homebrew_formula_rejects_untrusted_sdist_metadata( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + mutate, + expected_error: str, +) -> None: + context, _manifest, release = prepared_release(tmp_path) + mutate(release, context) + monkeypatch.setattr(homebrew, "query_pypi_release", constant(release)) + + with pytest.raises(state.ReleaseError, match=expected_error): + homebrew.prepare_formula(tmp_path, context.version.tag) + + assert not (tmp_path / homebrew.TAP_FORMULA_ARTIFACT).exists() + + +@pytest.mark.parametrize( + ("version", "latest_stable", "expected"), + ( + ("1.2.3-alpha.1", "1.2.2", homebrew.HomebrewEligibility.PRERELEASE), + ("1.2.3", "1.2.4", homebrew.HomebrewEligibility.SUPERSEDED), + ("1.2.3", "1.2.3", homebrew.HomebrewEligibility.ELIGIBLE), + ), +) +def test_homebrew_eligibility_tracks_latest_stable_release( + tmp_path: Path, + version: str, + latest_stable: str, + expected: homebrew.HomebrewEligibility, +) -> None: + context, manifest, _formula, _git = release_state_fixture(tmp_path, version) + release = matching_pypi(context, manifest, latest_stable=latest_stable) + + assert homebrew.release_eligibility(context, release) == expected + + +def test_pypi_lookup_retains_homebrew_sdist_metadata( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + context, manifest, _formula, _git = release_state_fixture(tmp_path, "1.2.3") + artifact = manifest.artifact("pypi_sdist") + canonical_url = ( + f"https://files.pythonhosted.org/packages/aa/bb/{'c' * 60}/{artifact.filename}" + ) + payload = { + "releases": { + context.version.python: [ + { + "filename": artifact.filename, + "size": artifact.size, + "digests": {"sha256": artifact.sha256}, + "url": canonical_url, + "packagetype": "sdist", + "yanked": False, + } + ] + } + } + monkeypatch.setattr(state.state_types, "fetch_registry_json", constant(payload)) + + release = state.query_pypi_release(context) + + assert release.files[artifact.filename] == state.PypiFile( + filename=artifact.filename, + size=artifact.size, + sha256=artifact.sha256, + url=canonical_url, + package_type="sdist", + yanked=False, + ) + + +def test_homebrew_formula_comparison_allows_only_bottle_metadata() -> None: + expected = """class Crewplane < Formula + url "https://files.pythonhosted.org/packages/aa/crewplane-1.2.3.tar.gz" + head "https://github.com/crewplaneai/crewplane.git", branch: "master" + + depends_on "libyaml" +end +""" + bottled = """class Crewplane < Formula + url "https://files.pythonhosted.org/packages/aa/crewplane-1.2.3.tar.gz" + head "https://github.com/crewplaneai/crewplane.git", branch: "master" + + bottle do + sha256 cellar: :any_skip_relocation, arm64_sequoia: "abc" + end + + depends_on "libyaml" +end +""" + + assert homebrew.formula_without_bottle_block(bottled) == expected + assert homebrew.formula_without_bottle_block(expected) == expected + + +def test_pull_request_snapshot_rejects_ambiguous_or_unowned_prs() -> None: + owned = { + "number": 7, + "state": "open", + "merged_at": None, + "html_url": "https://github.com/crewplaneai/homebrew-crewplane/pull/7", + "body": homebrew.AUTOMATION_MARKER + "\nbody", + "head": { + "sha": "a" * 40, + "ref": "automation/crewplane-1.2.3", + "repo": {"full_name": homebrew.TAP_REPOSITORY}, + }, + "base": {"ref": "main"}, + "title": "crewplane 1.2.3", + } + + snapshot = homebrew.parse_pull_request_snapshot(json.dumps([owned])) + + assert snapshot is not None + assert snapshot.number == 7 + with pytest.raises(state.ReleaseError, match="multiple pull requests"): + homebrew.parse_pull_request_snapshot(json.dumps([owned, owned])) + owned["head"]["repo"]["full_name"] = "attacker/homebrew-crewplane" + with pytest.raises(state.ReleaseError, match="invalid data"): + homebrew.parse_pull_request_snapshot(json.dumps([owned])) + owned["head"]["repo"]["full_name"] = homebrew.TAP_REPOSITORY + owned["body"] = "edited by hand" + with pytest.raises(state.ReleaseError, match="automation marker"): + homebrew.parse_pull_request_snapshot(json.dumps([owned])) + + +@pytest.mark.usefixtures("isolated_git") +def test_publish_homebrew_pr_creates_one_branch_and_is_idempotent( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + setup = publication_setup(tmp_path, monkeypatch) + + first_result = homebrew.publish_formula_pull_request( + tmp_path, setup.runner, setup.options + ) + second_result = homebrew.publish_formula_pull_request( + tmp_path, setup.runner, setup.options + ) + + assert first_result == second_result == 0 + branch = f"automation/crewplane-{setup.context.version.project}" + branch_formula = git( + setup.origin, "show", f"refs/heads/{branch}:{homebrew.TAP_FORMULA_PATH}" + ) + expected_formula = (tmp_path / homebrew.TAP_FORMULA_ARTIFACT).read_text( + encoding="utf-8" + ) + assert branch_formula + "\n" == expected_formula + assert git(setup.origin, "rev-parse", "refs/heads/main") != git( + setup.origin, "rev-parse", f"refs/heads/{branch}" + ) + assert setup.runner.pull_request is not None + assert setup.runner.pull_request["state"] == "open" + assert str(setup.runner.pull_request["body"]).startswith(homebrew.AUTOMATION_MARKER) + create_calls = [ + call for call in setup.runner.gh_calls if call[:3] == ("gh", "pr", "create") + ] + assert len(create_calls) == 1 + query_calls = [call for call in setup.runner.gh_calls if call[:2] == ("gh", "api")] + assert query_calls + assert all( + f"head={homebrew.TAP_OWNER}:{branch}" in call and "per_page=2" in call + for call in query_calls + ) + assert os.environ["GH_TOKEN"] == "installation-token" + + +@pytest.mark.usefixtures("isolated_git") +def test_publish_homebrew_pr_recovers_missing_and_closed_pull_requests( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + setup = publication_setup(tmp_path, monkeypatch) + homebrew.publish_formula_pull_request(tmp_path, setup.runner, setup.options) + + setup.runner.pull_request = None + homebrew.publish_formula_pull_request(tmp_path, setup.runner, setup.options) + assert setup.runner.pull_request is not None + setup.runner.pull_request["state"] = "closed" + homebrew.publish_formula_pull_request(tmp_path, setup.runner, setup.options) + + create_calls = [ + call for call in setup.runner.gh_calls if call[:3] == ("gh", "pr", "create") + ] + reopen_calls = [ + call for call in setup.runner.gh_calls if call[:3] == ("gh", "pr", "reopen") + ] + assert len(create_calls) == 2 + assert len(reopen_calls) == 1 + assert setup.runner.pull_request["state"] == "open" + + +@pytest.mark.usefixtures("isolated_git") +def test_publish_homebrew_pr_rebases_stale_automation_branch( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + setup = publication_setup(tmp_path, monkeypatch) + homebrew.publish_formula_pull_request(tmp_path, setup.runner, setup.options) + branch = f"automation/crewplane-{setup.context.version.project}" + original_branch_sha = git(setup.origin, "rev-parse", f"refs/heads/{branch}") + (setup.seed / "README.md").write_text("tap documentation\n", encoding="utf-8") + git(setup.seed, "add", "README.md") + git(setup.seed, "commit", "-m", "document tap") + git(setup.seed, "push", "origin", "main") + + result = homebrew.publish_formula_pull_request( + tmp_path, setup.runner, setup.options + ) + + current_main = git(setup.origin, "rev-parse", "refs/heads/main") + updated_branch = git(setup.origin, "rev-parse", f"refs/heads/{branch}") + branch_parent = git(setup.origin, "show", "-s", "--format=%P", updated_branch) + assert result == 0 + assert updated_branch != original_branch_sha + assert branch_parent == current_main + assert setup.runner.pull_request is not None + head = setup.runner.pull_request["head"] + assert isinstance(head, dict) + assert head["sha"] == updated_branch + + +@pytest.mark.usefixtures("isolated_git") +def test_publish_homebrew_pr_rejects_modified_automation_branch( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + setup = publication_setup(tmp_path, monkeypatch) + homebrew.publish_formula_pull_request(tmp_path, setup.runner, setup.options) + branch = f"automation/crewplane-{setup.context.version.project}" + formula_path = setup.tap / homebrew.TAP_FORMULA_PATH + formula_path.write_text( + formula_path.read_text(encoding="utf-8") + "# manual change\n", + encoding="utf-8", + ) + git(setup.tap, "add", str(homebrew.TAP_FORMULA_PATH)) + git(setup.tap, "commit", "-m", "manual change") + git(setup.tap, "push", "--force", "origin", f"HEAD:refs/heads/{branch}") + modified_sha = git(setup.tap, "rev-parse", "HEAD") + assert setup.runner.pull_request is not None + head = setup.runner.pull_request["head"] + assert isinstance(head, dict) + head["sha"] = modified_sha + + with pytest.raises(state.ReleaseError, match="not owned by release automation"): + homebrew.publish_formula_pull_request(tmp_path, setup.runner, setup.options) diff --git a/tests/unit/packaging/test_release_tool_state.py b/tests/unit/packaging/test_release_tool_state.py index 436c522..9e7ce11 100644 --- a/tests/unit/packaging/test_release_tool_state.py +++ b/tests/unit/packaging/test_release_tool_state.py @@ -349,7 +349,9 @@ def test_prepare_refuses_existing_remote_versions( def test_prepare_builds_offline_wheelhouse( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], ) -> None: context, manifest, _formula, _git = release_state_fixture(tmp_path) missing_releases = ( @@ -376,7 +378,11 @@ def record_wheelhouse(_context: object, _runner: object) -> None: build.prepare_release(tmp_path, FakeRunner()) + output = capsys.readouterr().out assert calls == ["wheelhouse"] + assert "brew pr-pull" in output + assert "Copy it to:" not in output + assert "push the tap update" not in output def test_release_artifact_build_does_not_download_wheelhouse(