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
160 changes: 160 additions & 0 deletions docs/how-to/set-up-homebrew-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# Set up a Homebrew tap

Use this guide to create a cask-only Homebrew tap and connect a producer repository to the shared publisher. The [`release-cli` contract](../reference/release-cli-contract.md#homebrew-tap-initialization) defines the generated files and failure behavior.

## Prerequisites

Confirm that:

- the producer already builds a Homebrew cask with GoReleaser;
- a released, attestation-verified `release-cli` is installed;
- GitHub CLI is authenticated;
- you can create repositories and rulesets in the target organization; and
- an organization owner can change the Meigma Release App installation and Actions credentials.

Use a public repository named `homebrew-<name>`. Homebrew users will install casks from `owner/<name>`.

## 1. Generate the tap

Choose the repository and an absent or empty local directory:

```bash
export TAP_REPOSITORY=acme/homebrew-tools
export TAP_DIRECTORY="$PWD/homebrew-tools"

release-cli init homebrew-tap \
--tap "$TAP_REPOSITORY" \
--output "$TAP_DIRECTORY"
```

The command writes `.github/workflows/casks.yml`, `.github/dependabot.yml`, `Casks/.gitkeep`, and `README.md`. It does not create a `Formula/` directory. The reusable validation workflow is pinned to the full source commit of the installed CLI.

Inspect the output before creating the repository:

```bash
find "$TAP_DIRECTORY" -type f -print | sort
```

Do not run the command over an existing checkout. A nonempty output directory is rejected rather than merged or overwritten.

## 2. Create the GitHub repository

Initialize and publish the generated directory:

```bash
cd "$TAP_DIRECTORY"
git init
git add .
git commit -m "chore: initialize Homebrew tap"
git branch -M main
gh repo create "$TAP_REPOSITORY" --public --source=. --remote=origin --push
```

In the tap repository's **Settings** > **Actions** > **General**, confirm that Actions is enabled and that the Actions policy permits `meigma/release` and the actions used by its reusable workflow.

## 3. Grant the Release App access

An organization owner must add both the producer and tap repositories to the Meigma Release App installation:

1. Open the organization settings in GitHub.
2. Open **Third-party access** > **GitHub Apps** > **Installed GitHub Apps**.
3. Configure the Meigma Release App.
4. Under **Repository access**, keep **Only select repositories** selected.
5. Add the producer repository and the new tap repository, then save.

The App must have `contents: write` and `pull requests: write` for the tap. The publisher mints a short-lived token scoped to that repository. It does not use a personal access token.

## 4. Grant the producer its Actions credentials

In the organization settings, open **Secrets and variables** > **Actions**:

1. Make the variable `MEIGMA_RELEASE_APP_CLIENT_ID` available to the producer repository.
2. Make the secret `MEIGMA_RELEASE_APP_PRIVATE_KEY` available to the producer repository.
3. Keep both resources limited to selected repositories.

The tap does not need either credential. Its validation workflow is secret-free.

## 5. Configure cask generation

Add a `homebrew_casks` entry to the producer's `.goreleaser.yaml`. Keep `skip_upload: true`; `release-cli publish homebrew`, not GoReleaser, owns the tap pull request.

```yaml
homebrew_casks:
- name: example
ids:
- example
binaries:
- example
repository:
owner: acme
name: homebrew-tools
homepage: https://github.com/acme/example
description: Example command
license: MIT
url:
template: "https://github.com/acme/example/releases/download/{{ .Tag }}/{{ .ArtifactName }}"
skip_upload: true
```

Replace every example value. The cask name must use lowercase letters, digits, and interior hyphens.

## 6. Add the publisher job

Add a job after the public GitHub Release job in the producer's tag workflow. Pin `publish-homebrew.yml` and `checksum-signing-workflow-ref` to the same full `meigma/release` commit used by the other shared release workflows.

```yaml
homebrew-publish:
name: Open Homebrew tap pull request
needs:
- release-assets
- github-release
permissions:
actions: read
attestations: read
contents: read
uses: meigma/release/.github/workflows/publish-homebrew.yml@<FULL_RELEASE_COMMIT>
with:
artifact-id: ${{ needs.release-assets.outputs.artifact-id }}
artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }}
checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@<FULL_RELEASE_COMMIT>
tap: acme/homebrew-tools
cask: example
release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }}
publish-homebrew: true
secrets:
release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }}
```

Keep `publish-homebrew: false` until the tap settings are ready. The publisher runs only after `github-release` succeeds, so generated cask URLs point to public release assets.

## 7. Protect the tap branch

The publisher opens a pull request and never merges it. Protect `main` before merging the first generated cask:

1. In the tap repository, open **Settings** > **Rules** > **Rulesets**.
2. Create a branch ruleset targeting the default branch.
3. Require changes through a pull request.
4. Require the `casks / Homebrew cask validation` status check.
5. Block force pushes and branch deletion.
6. Activate the ruleset.

GitHub may not offer the status check until it has run once. If necessary, enable the publisher for one real release, wait for its tap pull request and checks, configure the ruleset, and only then merge the pull request.

## 8. Verify the first publication

For the first real release:

1. Set `publish-homebrew: true` before the release tag is created.
2. Confirm that the GitHub Release becomes public.
3. Confirm that the producer workflow opens one tap pull request.
4. Wait for `casks / Homebrew cask validation` to pass.
5. Review and merge the tap pull request.
6. Install and remove the cask on both Apple silicon and Intel macOS where those archives are published.

```bash
brew install --cask acme/tools/example
example version
brew uninstall --cask example
```

A failed publisher run does not merge or auto-merge a tap pull request. Correct the producer or tap configuration, then rerun the failed job.
32 changes: 29 additions & 3 deletions docs/reference/release-cli-contract.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# `release-cli` contract reference

`release-cli` builds and validates Go release data, reports machine-readable results, builds and verifies OCI layouts from staged binaries, opens protected Homebrew tap pull requests, publishes verified GitHub Releases, and performs two-phase digest-addressed OCI publication. The [GitHub Release contract](github-release-contract.md) defines the workflow inputs, artifacts, and publication behavior that surround the CLI.
`release-cli` builds and validates Go release data, reports machine-readable results, builds and verifies OCI layouts from staged binaries, initializes cask-only Homebrew taps, opens protected tap pull requests, publishes verified GitHub Releases, and performs two-phase digest-addressed OCI publication. The [GitHub Release contract](github-release-contract.md) defines the workflow inputs, artifacts, and publication behavior that surround the CLI.

## Commands

Expand All @@ -13,12 +13,13 @@
| `release-cli publish oci prepare --layout PATH [--image IMAGE] [--version VERSION] --digest DIGEST [--dry-run] [--plain-http] [--json]` | Validate and prepare a digest-addressed OCI image publication and recursive signature. |
| `release-cli publish oci finalize --result - [--plain-http] [--json]` | Re-read registry state and apply verified OCI image tags after attestation. |
| `release-cli publish github --dist PATH [--no-undraft] [--json]` | Reconcile a verified bundle with its matching GitHub Release and optionally publish the draft. |
| `release-cli init homebrew-tap --tap OWNER/HOMEBREW-NAME --output DIR [--json]` | Write a cask-only tap scaffold into a new or empty local directory. |
| `release-cli publish homebrew --dist PATH --tap OWNER/REPOSITORY --cask TOKEN [--json]` | Reconcile a generated cask through a protected Homebrew tap pull request. |
| `release-cli verify bundle --dist PATH --identity URL [--issuer URL] [--json]` | Verify a closed release bundle and its detached Sigstore signature. |
| `release-cli verify handoff --artifact-id <n> --digest <sha256:...> [--json]` | Verify an Actions artifact's GitHub API metadata before download. |
| `release-cli version [--json]` | Report the CLI version, source commit, and protocol integer. |

`stage`, `verify bundle`, `publish github`, and `publish homebrew` require a distribution path. The only accepted profile is `go`. `verify bundle` also requires an exact certificate identity. `verify handoff` requires artifact ID and digest values. Supply handoff values with `--artifact-id` and `--digest`, or with `RELEASE_ARTIFACT_ID` and `RELEASE_DIGEST`. An explicitly set flag takes precedence over its environment variable.
`stage`, `verify bundle`, `publish github`, and `publish homebrew` require a distribution path. `init homebrew-tap` requires `--tap` and `--output`; the repository name must use `homebrew-<name>`. The initializer also requires a released CLI whose build metadata contains a full source commit. The only accepted profile is `go`. `verify bundle` also requires an exact certificate identity. `verify handoff` requires artifact ID and digest values. Supply handoff values with `--artifact-id` and `--digest`, or with `RELEASE_ARTIFACT_ID` and `RELEASE_DIGEST`. An explicitly set flag takes precedence over its environment variable.

Boolean `RELEASE_*` environment variables must contain a value accepted by Go's `strconv.ParseBool`: `1`, `t`, `T`, `TRUE`, `true`, `True`, `0`, `f`, `F`, `FALSE`, `false`, or `False`. Any other value is invalid configuration and exits with code `2`.

Expand All @@ -35,7 +36,7 @@ When option and argument parsing succeeds and `--json` is requested, stdout cont
| Field | Value |
| --- | --- |
| `schema` | Always `release.dev/result/v1`. |
| `command` | The command path, such as `image build`, `image verify`, `plan tags`, `publish github`, `publish homebrew`, `publish oci prepare`, `publish oci finalize`, `stage`, `verify bundle`, `verify handoff`, or `version`. |
| `command` | The command path, such as `image build`, `image verify`, `init homebrew-tap`, `plan tags`, `publish github`, `publish homebrew`, `publish oci prepare`, `publish oci finalize`, `stage`, `verify bundle`, `verify handoff`, or `version`. |
| `ok` | `true` when the command succeeds; otherwise `false`. |
| `result` | The command-specific result object. |

Expand All @@ -48,6 +49,14 @@ The `stage --json` result contains these fields:
| `binaries.<arch>.path` | string | Original `<dist-basename>/`-prefixed path from `artifacts.json`. |
| `binaries.<arch>.mode` | string | Observed permission bits in octal notation. |

For `init homebrew-tap --json`, `command` is exactly `init homebrew-tap`. The `result` object contains these fields:

| Field | JSON type | Value |
| --- | --- | --- |
| `tap` | string | Validated target repository in `owner/homebrew-name` form. |
| `output` | string | Clean local output path. |
| `files` | array of strings | Generated slash-separated paths in lexical order. |

For `image build --json`, `command` is exactly `image build`. The `result` object contains these fields:

| Field | JSON type | Value |
Expand Down Expand Up @@ -714,6 +723,23 @@ A retryable operation uses at most four attempts, waiting 1 second, 2 seconds, a

A missing distribution path (`--dist` or `RELEASE_DIST`), a missing `RELEASE_APP_TOKEN`, missing or malformed `GITHUB_REPOSITORY`, `GITHUB_REF_NAME`, or `GITHUB_SHA`, and malformed GitHub endpoint configuration are configuration errors. They exit with code `2` before any publication request. An unresolvable `RELEASE_GIT_PATH` or `RELEASE_GH_PATH` is reported when the selected binary is first invoked and exits with code `1`. The Git path is first used for tag resolution; the GitHub CLI path is first used for upload, after tag resolution, draft discovery, and the pre-upload asset read. Every other post-configuration tag-resolution, GitHub API, upload, convergence, or release-contract failure also exits with code `1`. Success exits with code `0`. No other exit code is defined.

## Homebrew tap initialization

`release-cli init homebrew-tap` writes a minimal cask-only tap into a local output directory. It performs no Git or GitHub operation. The output path must not exist or must be an empty directory. A file, symlink, or nonempty directory is rejected without changing its contents.

The command writes exactly these files:

| Path | Purpose |
| --- | --- |
| `.github/workflows/casks.yml` | Calls the reusable `homebrew-tap-ci.yml` workflow for pull requests that change `Casks/**`. |
| `.github/dependabot.yml` | Checks weekly for GitHub Actions updates. |
| `Casks/.gitkeep` | Keeps the empty cask directory in Git. |
| `README.md` | Records the tap and install syntax. |

The reusable workflow reference is pinned to the full source commit stamped into the running `release-cli` binary. Development builds stamped with `none`, malformed commits, and abbreviated commits exit with code `2` before creating the output directory. The generated workflow grants `contents: read` to its only caller job and sets top-level permissions to an empty map.

The command does not generate `Formula/`, a publisher workflow, repository settings, branch protection, secrets, or a GitHub App. Follow [Set up a Homebrew tap](../how-to/set-up-homebrew-tap.md) to create the repository and connect a producer.

## Homebrew cask publication

`release-cli publish homebrew` reads the cask generated by GoReleaser and reconciles it through a tap pull request. The command never writes the tap's default branch, force-updates a branch, deletes a path, enables auto-merge, or merges the pull request.
Expand Down
Loading