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
194 changes: 194 additions & 0 deletions .github/workflows/build-bottles.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
name: Build Base Bottles

on:
workflow_dispatch:

permissions:
contents: write

jobs:
metadata:
name: Read formula metadata
runs-on: ubuntu-latest
outputs:
version: ${{ steps.formula.outputs.version }}
release_tag: ${{ steps.formula.outputs.release_tag }}
root_url: ${{ steps.formula.outputs.root_url }}
steps:
- name: Check out tap
uses: actions/checkout@v4

- name: Read Base formula version
id: formula
shell: bash
run: |
version="$(awk -F'"' '/^[[:space:]]*version "/ { print $2; exit }' Formula/base.rb)"
if [[ -z "$version" ]]; then
echo "::error::Unable to read Formula/base.rb version"
exit 1
fi

release_tag="base-v${version}"
root_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${release_tag}"

{
echo "version=${version}"
echo "release_tag=${release_tag}"
echo "root_url=${root_url}"
} >> "$GITHUB_OUTPUT"

build:
name: Build bottle on ${{ matrix.runner }}
needs: metadata
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-intel
artifact: intel
- runner: macos-15
artifact: arm64
env:
HOMEBREW_GITHUB_API_TOKEN: ${{ github.token }}
HOMEBREW_NO_INSTALL_CLEANUP: 1
steps:
- name: Check out tap
uses: actions/checkout@v4

- name: Tap local checkout
shell: bash
run: |
brew untap codeforester/base >/dev/null 2>&1 || true
brew tap codeforester/base "$GITHUB_WORKSPACE"

- name: Build bottle
shell: bash
run: brew install --build-bottle codeforester/base/base

- name: Test bottle build
shell: bash
run: brew test codeforester/base/base

- name: Generate bottle JSON
shell: bash
run: |
brew bottle --json --root-url "${{ needs.metadata.outputs.root_url }}" codeforester/base/base

mkdir -p bottle-output
generated=()
for path in *.bottle.* *.json; do
[[ -e "$path" ]] || continue
generated+=("$path")
done

if [[ ${#generated[@]} -eq 0 ]]; then
echo "::error::brew bottle did not produce bottle artifacts"
exit 1
fi

mv "${generated[@]}" bottle-output/

- name: Upload bottle workflow artifact
uses: actions/upload-artifact@v4
with:
name: base-bottle-${{ matrix.artifact }}
path: bottle-output/
if-no-files-found: error

publish:
name: Publish bottle assets and update formula
needs:
- metadata
- build
runs-on: macos-15
env:
GH_TOKEN: ${{ github.token }}
HOMEBREW_GITHUB_API_TOKEN: ${{ github.token }}
steps:
- name: Refuse direct master updates
if: ${{ github.ref_name == "master" }}
shell: bash
run: |
echo "::error::Run this workflow from a tap release branch, not master."
exit 1

- name: Check out tap branch
uses: actions/checkout@v4
with:
ref: ${{ github.ref_name }}

- name: Download bottle artifacts
uses: actions/download-artifact@v4
with:
pattern: base-bottle-*
path: bottles
merge-multiple: true

- name: Tap local checkout
shell: bash
run: |
brew untap codeforester/base >/dev/null 2>&1 || true
brew tap codeforester/base "$GITHUB_WORKSPACE"

- name: Create tap bottle release
shell: bash
run: |
if gh release view "${{ needs.metadata.outputs.release_tag }}" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
exit 0
fi

gh release create "${{ needs.metadata.outputs.release_tag }}" \
--repo "$GITHUB_REPOSITORY" \
--title "Base Homebrew bottles ${{ needs.metadata.outputs.version }}" \
--notes "Homebrew bottle artifacts for Base ${{ needs.metadata.outputs.version }}."

- name: Upload bottle assets
shell: bash
run: |
bottle_files=()
while IFS= read -r path; do
bottle_files+=("$path")
done < <(find bottles -name '*.bottle.*' -type f | sort)

if [[ ${#bottle_files[@]} -eq 0 ]]; then
echo "::error::No bottle tarballs were downloaded"
exit 1
fi

gh release upload "${{ needs.metadata.outputs.release_tag }}" \
"${bottle_files[@]}" \
--repo "$GITHUB_REPOSITORY" \
--clobber

- name: Merge bottle JSON into formula
shell: bash
run: |
json_files=()
while IFS= read -r path; do
json_files+=("$path")
done < <(find bottles -name '*.json' -type f | sort)

if [[ ${#json_files[@]} -eq 0 ]]; then
echo "::error::No bottle JSON files were downloaded"
exit 1
fi

brew bottle --merge --write --no-commit "${json_files[@]}"

tap_path="$(brew --repo codeforester/base)"
cp "$tap_path/Formula/base.rb" Formula/base.rb

- name: Commit bottle stanza
shell: bash
run: |
if git diff --quiet -- Formula/base.rb; then
echo "Formula/base.rb already contains the current bottle stanza."
exit 0
fi

git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add Formula/base.rb
git commit -m "Add Base ${{ needs.metadata.outputs.version }} Homebrew bottles"
git push origin "HEAD:${GITHUB_REF_NAME}"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
.DS_Store
*.bottle*.tar.gz
*.bottle.json
bottle-output/
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,24 @@ brew audit --new --formula Formula/base.rb
The stable formula installs Base from a versioned release archive. The formula's
`head` stanza remains available for local development against Base's `master`
branch.

## Build Bottles

Build Homebrew bottles from a tap release branch after `Formula/base.rb` points
at a published Base tag. The workflow publishes bottle tarballs to a tap GitHub
Release named `base-vX.Y.Z`, merges the generated bottle stanza into
`Formula/base.rb`, and pushes that change back to the same branch.

Use the GitHub Actions **Build Base Bottles** workflow on the tap release
branch, then review the branch diff before merging the tap PR.

After the tap PR is merged, verify the consumer bottle path:

```bash
brew update
brew install --force-bottle codeforester/base/base
brew test codeforester/base/base
```

Use `brew reinstall --force-bottle codeforester/base/base` when Base is already
installed on the validation host.
43 changes: 43 additions & 0 deletions tests/test_bottle_workflow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from __future__ import annotations

import unittest
from pathlib import Path


REPO_ROOT = Path(__file__).resolve().parents[1]


class BottleWorkflowTests(unittest.TestCase):
def test_bottle_workflow_builds_uploads_and_merges_bottles(self) -> None:
workflow = REPO_ROOT / ".github" / "workflows" / "build-bottles.yml"
content = workflow.read_text(encoding="utf-8")

self.assertIn("workflow_dispatch:", content)
self.assertIn("macos-15-intel", content)
self.assertIn("macos-15", content)
self.assertIn("brew install --build-bottle codeforester/base/base", content)
self.assertIn("brew test codeforester/base/base", content)
self.assertIn("brew bottle --json --root-url", content)
self.assertIn("gh release upload", content)
self.assertIn("brew bottle --merge --write --no-commit", content)
self.assertIn("brew --repo codeforester/base", content)
self.assertIn('"$tap_path/Formula/base.rb" Formula/base.rb', content)
self.assertIn('github.ref_name == "master"', content)

def test_readme_documents_bottle_release_flow(self) -> None:
readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")

self.assertIn("Build Bottles", readme)
self.assertIn("base-vX.Y.Z", readme)
self.assertIn("brew install --force-bottle codeforester/base/base", readme)

def test_generated_bottle_artifacts_are_ignored(self) -> None:
ignore = (REPO_ROOT / ".gitignore").read_text(encoding="utf-8")

self.assertIn("*.bottle*.tar.gz", ignore)
self.assertIn("*.bottle.json", ignore)
self.assertIn("bottle-output/", ignore)


if __name__ == "__main__":
unittest.main()