Skip to content
Open
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
109 changes: 109 additions & 0 deletions .github/workflows/homebrew.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
name: Homebrew packages

on:
pull_request:
paths:
- ".github/workflows/homebrew.yml"
- "README.md"
- "homebrew/Formula/**"
- "homebrew/Casks/**"
- "scripts/homebrew_release.py"
- "scripts/homebrew_test_support.py"
- "scripts/check_homebrew_formula.py"
- "scripts/test_homebrew_formula.py"
- "scripts/check_homebrew_cask.py"
- "scripts/test_homebrew_cask.py"
- "scripts/test_homebrew_release.py"
push:
branches:
- dev
paths:
- ".github/workflows/homebrew.yml"
- "README.md"
- "homebrew/Formula/**"
- "homebrew/Casks/**"
- "scripts/homebrew_release.py"
- "scripts/homebrew_test_support.py"
- "scripts/check_homebrew_formula.py"
- "scripts/test_homebrew_formula.py"
- "scripts/check_homebrew_cask.py"
- "scripts/test_homebrew_cask.py"
- "scripts/test_homebrew_release.py"
workflow_dispatch:

permissions:
contents: read

concurrency:
group: homebrew-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true

jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Test Homebrew validators
run: |
python -m unittest \
scripts.test_homebrew_formula \
scripts.test_homebrew_cask \
scripts.test_homebrew_release
- name: Validate Formula against the official release
run: python scripts/check_homebrew_formula.py
- name: Validate Cask against the official release
run: python scripts/check_homebrew_cask.py
- name: Check patch whitespace
env:
EVENT_NAME: ${{ github.event_name }}
BEFORE: ${{ github.event.before }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if [ "$EVENT_NAME" = "pull_request" ]; then
git diff --check "${BASE_SHA}...HEAD"
elif [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ]; then
git diff --check "${BEFORE}..HEAD"
else
git diff --check HEAD^ HEAD
fi

local-tap:
needs: validate
strategy:
fail-fast: false
matrix:
os:
- macos-14
- macos-14-arm64
runs-on: ${{ matrix.os }}
timeout-minutes: 20
env:
HOMEBREW_NO_AUTO_UPDATE: "1"
HOMEBREW_NO_INSTALL_CLEANUP: "1"
steps:
- uses: actions/checkout@v4
- name: Audit and install from a temporary local tap
run: |
set -euo pipefail
brew tap-new --no-git codeaf-ci/local
tap_dir="$(brew --repository)/Library/Taps/codeaf-ci/homebrew-local"
mkdir -p "$tap_dir/Formula" "$tap_dir/Casks"
cp homebrew/Formula/codeaf.rb "$tap_dir/Formula/codeaf.rb"
cp homebrew/Casks/codeaf.rb "$tap_dir/Casks/codeaf.rb"

brew audit --formula --strict codeaf-ci/local/codeaf
brew install --formula codeaf-ci/local/codeaf
brew test --formula codeaf-ci/local/codeaf
brew uninstall --formula codeaf-ci/local/codeaf

brew audit --cask --strict codeaf-ci/local/codeaf
brew install --cask codeaf-ci/local/codeaf
brew test --cask codeaf-ci/local/codeaf
brew uninstall --cask codeaf-ci/local/codeaf
brew untap codeaf-ci/local
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,31 @@ curl -fsSL https://agentfield.ai/get/codeaf | bash
codeaf
```

Homebrew Formula and Cask definitions use the official prebuilt release
binaries. The Formula covers macOS (Intel and Apple Silicon) and Linux (amd64
and arm64); the Cask covers both macOS architectures. Both install the `codeaf`
command-line program, not an app bundle.

**Homebrew installation is not published yet:** there is no public tap. In
particular, Homebrew 7 requires a tap for Formula installation, so the old
standalone raw-Formula URL is not a supported install command. Do not use it;
install with the supported script above until a public tap is announced. The
Homebrew packages do not target Windows.

Maintainers can audit and install both definitions from a temporary local tap
on macOS. Run these commands from a checkout of the repository; the Formula and
Cask install the same command, so uninstall one before installing the other:

```bash
brew tap agentfield/codeaf
brew install codeaf
```

## What a factory is

CodeAF is a factory, a different way to work. It is a factory because it
schedules work, and it is a factory because it is a factory. It is a factory
because it is a factory.
<details>
<summary>Pin a version, or build from source</summary>

Expand Down
27 changes: 27 additions & 0 deletions homebrew/Casks/codeaf.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@

cask "codeaf" do
version "0.8.0"

sha256 arm: "19b55289b5d8bf3345b5e3fae0682cc75ed0a05f5207f772f9f60c6d303589bf",
intel: "f926117df3f9d369bfdaa1960060f1d17bec8df6b3fdc1f25f9b6a9fba18cef2"

url "https://github.com/Agent-Field/codeaf/releases/download/v#{version}/codeaf-darwin-#{Hardware::CPU.arm? ? "arm64" : "amd64"}"
name "codeaf"
desc "Terminal coding harness built for open models"
homepage "https://github.com/Agent-Field/codeaf"

binary "codeaf-darwin-#{Hardware::CPU.arm? ? "arm64" : "amd64"}", target: "codeaf"

postflight do
set_permissions 0755, staged_path/"codeaf-darwin-*"
end

livecheck do
url :url
strategy :github_latest
end

test do
assert_match "codeaf ", shell_output("#{bin}/codeaf --version")
end
end
40 changes: 40 additions & 0 deletions homebrew/Formula/codeaf.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
class Codeaf < Formula
desc "Terminal coding harness built for open models"
homepage "https://github.com/Agent-Field/codeaf"
license "Apache-2.0"

on_macos do
on_intel do
url "https://github.com/Agent-Field/codeaf/releases/download/v0.8.0/codeaf-darwin-amd64"
sha256 "f926117df3f9d369bfdaa1960060f1d17bec8df6b3fdc1f25f9b6a9fba18cef2"
end
on_arm do
url "https://github.com/Agent-Field/codeaf/releases/download/v0.8.0/codeaf-darwin-arm64"
sha256 "19b55289b5d8bf3345b5e3fae0682cc75ed0a05f5207f772f9f60c6d303589bf"
end
end

on_linux do
on_intel do
url "https://github.com/Agent-Field/codeaf/releases/download/v0.8.0/codeaf-linux-amd64"
sha256 "1d99c52d4dbba1d62599d820bbd62342240c4585c3577fca959a16ee2a4d8f72"
end
on_arm do
url "https://github.com/Agent-Field/codeaf/releases/download/v0.8.0/codeaf-linux-arm64"
sha256 "aee2d1b60f81d5b375b7df4db25fea580e8683880074f6896db6212022f26baa"
end
end

def install
executables = Dir["codeaf-*"].select { |path| File.file?(path) }
raise "expected exactly one codeaf release executable" unless executables.length == 1

bin.install executables.first => "codeaf"
chmod 0755, bin/"codeaf"
end

test do
assert_path_exists bin/"codeaf"
assert_match "codeaf ", shell_output("#{bin}/codeaf --version")
end
end
171 changes: 171 additions & 0 deletions scripts/check_homebrew_cask.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Check the Homebrew Cask against the latest official stable release."""

from __future__ import annotations

import argparse
import pathlib
import re
import sys

from homebrew_release import (
ReleaseValidationError,
load_release_inputs,
validate_release_manifest,
)

ROOT = pathlib.Path(__file__).resolve().parents[1]
DEFAULT_CASK = ROOT / "homebrew" / "Casks" / "codeaf.rb"
OFFICIAL_REPOSITORY = "Agent-Field/codeaf"
CASK_ASSETS = {
"arm": "codeaf-darwin-arm64",
"intel": "codeaf-darwin-amd64",
}
VERSION_PATTERN = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
SHA256_BLOCK_PATTERN = re.compile(
r'^\s*sha256\s+arm:\s*"([0-9a-fA-F]{64})",\s*\n'
r'\s*intel:\s*"([0-9a-fA-F]{64})"\s*$',
re.MULTILINE,
)
ARCH_URL = (
r'^\s*url\s+"https://github\.com/Agent-Field/codeaf/releases/download/'
r'v#\{version\}/codeaf-darwin-#\{Hardware::CPU\.arm\? \? '
r'"arm64" : "amd64"\}"\s*$'
)
ARCH_BINARY = (
r'^\s*binary\s+"codeaf-darwin-#\{Hardware::CPU\.arm\? \? '
r'"arm64" : "amd64"\}",\s*target:\s*"codeaf"\s*$'
)


class ValidationError(ReleaseValidationError):
"""An actionable Homebrew Cask validation failure."""


def validate(
cask_path: pathlib.Path,
release: dict,
checksums_data: bytes,
) -> str:
try:
source = cask_path.read_text(encoding="utf-8")
except OSError as exc:
raise ValidationError(
f"Cask file does not exist or cannot be read: {cask_path}"
) from exc

try:
release_version, assets, checksums = validate_release_manifest(
release, checksums_data
)
except ReleaseValidationError as exc:
raise ValidationError(str(exc)) from exc

if not re.search(r'^\s*cask\s+"codeaf"\s+do\s*$', source, re.MULTILINE):
raise ValidationError('Cask must declare cask "codeaf"')
if not re.search(r'^\s*name\s+"codeaf"\s*$', source, re.MULTILINE):
raise ValidationError('Cask name must be "codeaf"')
if not re.search(r'^\s*desc\s+"[^"]+"\s*$', source, re.MULTILINE):
raise ValidationError("Cask must declare a non-empty description")
if not re.search(
r'^\s*homepage\s+"https://github\.com/Agent-Field/codeaf"\s*$',
source,
re.MULTILINE,
):
raise ValidationError("Cask homepage must be the official codeaf repository")

versions = re.findall(r'^\s*version\s+"([^"]+)"\s*$', source, re.MULTILINE)
if len(versions) != 1 or not VERSION_PATTERN.fullmatch(versions[0]):
raise ValidationError("Cask must declare exactly one stable x.y.z version")
version = versions[0]
if version != release_version:
raise ValidationError(
f"Cask version {version} is stale or mismatched; "
f"latest stable release is v{release_version}"
)

sha256_blocks = list(SHA256_BLOCK_PATTERN.finditer(source))
if len(sha256_blocks) != 1:
raise ValidationError(
"Cask must declare one arm and one intel SHA-256 in its sha256 block"
)
cask_hashes = dict(zip(("arm", "intel"), sha256_blocks[0].groups()))
for architecture, asset_name in CASK_ASSETS.items():
actual_checksum = cask_hashes[architecture].lower()
if actual_checksum != checksums[asset_name]:
raise ValidationError(
f"Cask {architecture} SHA-256 for {asset_name} does not match "
"the official release checksums"
)
if asset_name not in assets:
raise ValidationError(f"GitHub release is missing required asset {asset_name}")

if len(re.findall(ARCH_URL, source, re.MULTILINE)) != 1:
raise ValidationError(
"Cask URL must select the official macOS arm64 or amd64 asset"
)
if len(re.findall(ARCH_BINARY, source, re.MULTILINE)) != 1:
raise ValidationError(
'Cask must link the architecture-specific executable as "codeaf"'
)
if not re.search(
r"^\s*postflight\s+do\s*$[\s\S]*?"
r"^\s*set_permissions\s+0755,\s*staged_path\s*/\s*"
r'"codeaf-darwin-\*"\s*$[\s\S]*?^\s*end\s*$',
source,
re.MULTILINE,
):
raise ValidationError(
"Cask must make the downloaded raw executable runnable in postflight"
)
if not re.search(
r"^\s*livecheck\s+do\s*$[\s\S]*?"
r"^\s*strategy\s+:github_latest\s*$[\s\S]*?^\s*end\s*$",
source,
re.MULTILINE,
):
raise ValidationError("Cask must track the latest stable GitHub release")
if not re.search(r"^\s*test\s+do\s*$", source, re.MULTILINE):
raise ValidationError("Cask must define a test block")
if not re.search(
r'shell_output\("\#\{bin\}/codeaf --version"\)', source
) or not re.search(r'assert_match\s+"codeaf "', source):
raise ValidationError("Cask test must verify codeaf --version output")

return version


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--cask", type=pathlib.Path, default=DEFAULT_CASK)
parser.add_argument(
"--release-json",
type=pathlib.Path,
help="use supplied GitHub release JSON instead of the live latest-release API",
)
parser.add_argument(
"--checksums-file",
type=pathlib.Path,
help="use a supplied checksums.txt instead of downloading the release manifest",
)
args = parser.parse_args()

try:
release, checksums_data, is_live = load_release_inputs(
args.release_json, args.checksums_file
)
version = validate(args.cask, release, checksums_data)
except ValidationError as exc:
print(f"Homebrew Cask check failed: {exc}", file=sys.stderr)
return 1

release_source = "latest stable GitHub release" if is_live else "release fixture"
print(
f"Homebrew Cask codeaf {version} matches the {release_source}, "
"its official checksums, and both macOS architecture assets."
)
return 0


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