Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions .github/workflows/homebrew-formula.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Copyright AI-Catalog Contributors (https://github.com/Agent-Card/ai-catalog-cli)
# Copyright AGNTCY Contributors (https://github.com/agntcy)
Comment thread
arpad-csepi marked this conversation as resolved.
# SPDX-License-Identifier: Apache-2.0

---
name: homebrew-formula

on:
workflow_call:
inputs:
tag:
description: Release tag to render into Formula/ai-catalog.rb
required: true
type: string
workflow_dispatch:
inputs:
tag:
description: Release tag to render into Formula/ai-catalog.rb
required: true
type: string

concurrency:
group: ${{ github.workflow }}-${{ inputs.tag }}
cancel-in-progress: false

jobs:
refresh-formula:
name: Refresh Homebrew formula
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main

- name: Setup Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"

- name: Render Homebrew formula
env:
TAG: ${{ inputs.tag }}
run: python3 scripts/render_homebrew_formula.py --tag "$TAG"

- name: Open formula refresh PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag }}
FORMULA: Formula/ai-catalog.rb
run: |
if [ -z "$(git status --porcelain -- "$FORMULA")" ]; then
echo "Formula already up to date for $TAG"
exit 0
fi

branch="automation/homebrew-${TAG#v}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$branch"
git add "$FORMULA"
git commit -s -m "release: update Homebrew formula for $TAG"
git push --force --set-upstream origin "$branch"

open_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$branch" \
--base main --state open --json number --jq 'first(.[].number) // ""')
if [ -n "$open_pr" ]; then
echo "Updated existing PR #$open_pr" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi

if gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$branch" \
--title "release: update Homebrew formula for $TAG" \
--body "Repoints \`$FORMULA\` at the $TAG archives and their published SHA256 digests."
then
exit 0
fi

# GITHUB_TOKEN cannot open pull requests unless the organization
# allows it, so leave the branch and a compare link behind instead.
{
echo "### Homebrew formula for $TAG"
echo
echo "Pushed \`$branch\`, but could not open the PR. Open it here:"
echo
echo "https://github.com/$GITHUB_REPOSITORY/compare/main...$branch?expand=1"
} >> "$GITHUB_STEP_SUMMARY"
10 changes: 10 additions & 0 deletions .github/workflows/release-binaries.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,13 @@ jobs:
checksum: sha256
include: LICENSE,README.md
token: ${{ secrets.GITHUB_TOKEN }}

homebrew-formula:
name: Homebrew formula
needs: build-binaries
permissions:
contents: write
pull-requests: write
uses: ./.github/workflows/homebrew-formula.yaml
with:
tag: ${{ github.event.release.tag_name }}
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ The crate is named `ai-catalog-cli`; the installed binary is `ai-catalog`.

## Install

On macOS and Linux, install from the tap in this repository:

```sh
brew tap Agent-Card/ai-catalog-cli https://github.com/Agent-Card/ai-catalog-cli
brew trust Agent-Card/ai-catalog-cli
brew install ai-catalog
```

Prebuilt binaries for Linux, macOS, and Windows are attached to each
[release](https://github.com/Agent-Card/ai-catalog-cli/releases). Download the
archive for your platform, verify it against the accompanying `.sha256` file,
Expand Down
150 changes: 150 additions & 0 deletions scripts/render_homebrew_formula.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
# Copyright AI-Catalog Contributors (https://github.com/Agent-Card/ai-catalog-cli)
# Copyright AGNTCY Contributors (https://github.com/agntcy)
Comment thread
arpad-csepi marked this conversation as resolved.
# SPDX-License-Identifier: Apache-2.0

from __future__ import annotations

import argparse
import hashlib
import re
import textwrap
import tomllib
import urllib.request
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
MANIFEST = ROOT / "Cargo.toml"
DEFAULT_OUTPUT = ROOT / "Formula" / "ai-catalog.rb"
Comment thread
arpad-csepi marked this conversation as resolved.

TAG_PATTERN = re.compile(r"^v(?P<version>[0-9A-Za-z.+-]+)$")
BINARY_NAME = "ai-catalog"
CLASS_NAME = "AiCatalog"
USER_AGENT = "ai-catalog-cli-release-automation"

# Homebrew on_<os>/on_<cpu> block to release archive name. The gnu Linux
# archives are used rather than musl: Homebrew targets glibc distributions.
ARCHIVES = {
"macos": {"arm": "darwin-arm64", "intel": "darwin-amd64"},
"linux": {"arm": "linux-arm64-gnu", "intel": "linux-amd64-gnu"},
}

HEADER = textwrap.dedent(
"""\
# Copyright AI-Catalog Contributors (https://github.com/Agent-Card/ai-catalog-cli)
# Copyright AGNTCY Contributors (https://github.com/agntcy)
Comment thread
arpad-csepi marked this conversation as resolved.
# SPDX-License-Identifier: Apache-2.0

"""
)


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Render the Homebrew formula for a released CLI tag."
)
parser.add_argument("--tag", required=True, help="Release tag, e.g. v0.2.2")
parser.add_argument(
"--output",
type=Path,
default=DEFAULT_OUTPUT,
help=f"Formula output path (default: {DEFAULT_OUTPUT}).",
)
return parser.parse_args()


def fetch(url: str) -> urllib.request.addinfourl:
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
return urllib.request.urlopen(request, timeout=60)


def hash_archive(url: str) -> str:
digest = hashlib.sha256()
with fetch(url) as response:
while chunk := response.read(1024 * 1024):
digest.update(chunk)
return digest.hexdigest()


def archive_sha256(base_url: str, archive: str) -> str:
"""Prefer the digest the release published over re-hashing the archive."""
try:
with fetch(f"{base_url}/{BINARY_NAME}-{archive}.sha256") as response:
fields = response.read().decode("utf-8").split()
if fields:
return fields[0]
Comment thread
arpad-csepi marked this conversation as resolved.
except OSError:
pass
return hash_archive(f"{base_url}/{BINARY_NAME}-{archive}.tar.gz")


def ruby_string(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')


def arch_blocks(base_url: str, targets: dict[str, str]) -> str:
blocks = []
for cpu, archive in targets.items():
url = f"{base_url}/{BINARY_NAME}-{archive}.tar.gz"
Comment thread
arpad-csepi marked this conversation as resolved.
blocks.append(
f" on_{cpu} do\n"
f' url "{ruby_string(url)}"\n'
f' sha256 "{archive_sha256(base_url, archive)}"\n'
f" end"
)
return "\n\n".join(blocks)


def render_formula(tag: str) -> str:
match = TAG_PATTERN.match(tag)
if not match:
raise SystemExit(f"expected {TAG_PATTERN.pattern} tag, got: {tag}")
version = match.group("version")

with MANIFEST.open("rb") as handle:
package = tomllib.load(handle)["package"]

homepage = package["repository"].rstrip("/")
base_url = f"{homepage}/releases/download/{tag}"
git_url = homepage if homepage.endswith(".git") else f"{homepage}.git"

body = textwrap.dedent(
f"""\
class {CLASS_NAME} < Formula
desc "{ruby_string(package["description"])}"
homepage "{ruby_string(homepage)}"
version "{version}"
license "{ruby_string(package["license"])}"
head "{ruby_string(git_url)}", branch: "main"

__MACOS__

__LINUX__

def install
bin.install "{BINARY_NAME}"
end

test do
assert_match "{BINARY_NAME}", shell_output("#{{bin}}/{BINARY_NAME} --help")
end
end
"""
)

for placeholder, os_name in (("__MACOS__", "macos"), ("__LINUX__", "linux")):
blocks = arch_blocks(base_url, ARCHIVES[os_name])
body = body.replace(placeholder, f" on_{os_name} do\n{blocks}\n end")

return f"{HEADER}{body}"


def main() -> int:
args = parse_args()
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(render_formula(args.tag), encoding="utf-8")
return 0


if __name__ == "__main__":
raise SystemExit(main())