diff --git a/.github/workflows/build-bottles.yml b/.github/workflows/build-bottles.yml new file mode 100644 index 0000000..e1cb09c --- /dev/null +++ b/.github/workflows/build-bottles.yml @@ -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}" diff --git a/.gitignore b/.gitignore index e43b0f9..7e87423 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ .DS_Store +*.bottle*.tar.gz +*.bottle.json +bottle-output/ diff --git a/README.md b/README.md index 30354c2..a0562f3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/tests/test_bottle_workflow.py b/tests/test_bottle_workflow.py new file mode 100644 index 0000000..781fb34 --- /dev/null +++ b/tests/test_bottle_workflow.py @@ -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()