From fe89ddf9c40fad8c16f8d2d9ef10f4acc811f477 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Fri, 21 Aug 2026 09:20:22 -0700 Subject: [PATCH] feat(cli): initialize Homebrew taps --- docs/how-to/set-up-homebrew-tap.md | 160 +++++++++++++ docs/reference/release-cli-contract.md | 32 ++- internal/cli/homebrew_init.go | 308 +++++++++++++++++++++++++ internal/cli/homebrew_init_test.go | 194 ++++++++++++++++ internal/cli/result.go | 10 + internal/cli/root.go | 1 + 6 files changed, 702 insertions(+), 3 deletions(-) create mode 100644 docs/how-to/set-up-homebrew-tap.md create mode 100644 internal/cli/homebrew_init.go create mode 100644 internal/cli/homebrew_init_test.go diff --git a/docs/how-to/set-up-homebrew-tap.md b/docs/how-to/set-up-homebrew-tap.md new file mode 100644 index 0000000..5364cc9 --- /dev/null +++ b/docs/how-to/set-up-homebrew-tap.md @@ -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-`. Homebrew users will install casks from `owner/`. + +## 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@ + 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@ + 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. diff --git a/docs/reference/release-cli-contract.md b/docs/reference/release-cli-contract.md index afc87a2..6e74c0e 100644 --- a/docs/reference/release-cli-contract.md +++ b/docs/reference/release-cli-contract.md @@ -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 @@ -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 --digest [--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-`. 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`. @@ -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. | @@ -48,6 +49,14 @@ The `stage --json` result contains these fields: | `binaries..path` | string | Original `/`-prefixed path from `artifacts.json`. | | `binaries..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 | @@ -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. diff --git a/internal/cli/homebrew_init.go b/internal/cli/homebrew_init.go new file mode 100644 index 0000000..9e3531a --- /dev/null +++ b/internal/cli/homebrew_init.go @@ -0,0 +1,308 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/meigma/release/internal/stage/pubbrew" + "github.com/meigma/release/internal/stage/pubgh" +) + +const ( + // commandInitHomebrewTap is the envelope command path for init homebrew-tap. + commandInitHomebrewTap = "init homebrew-tap" + // homebrewRepositoryPrefix is GitHub's required repository prefix for named taps. + homebrewRepositoryPrefix = "homebrew-" + // scaffoldDirectoryMode makes generated public-repository directories traversable. + scaffoldDirectoryMode os.FileMode = 0o755 + // scaffoldFileMode makes generated public-repository source readable. + scaffoldFileMode os.FileMode = 0o644 +) + +// homebrewTapInitConfig is the validated initializer configuration. +type homebrewTapInitConfig struct { + // Tap is the target Homebrew tap repository. + Tap pubbrew.Repository + // Name is the Homebrew tap name derived from the repository. + Name string + // Output is the clean output directory path. + Output string + // Commit is the release-cli source commit used to pin the managed workflow. + Commit pubgh.CommitSHA +} + +// scaffoldFile is one deterministic tap file. +type scaffoldFile struct { + // Path is the slash-separated path beneath the scaffold root. + Path string + // Content is the complete file content. + Content string +} + +// newInitCommand constructs the init parent verb. +func newInitCommand(options Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "init", + Short: "Initialize release infrastructure", + Args: requireSubcommand, + RunE: func(_ *cobra.Command, _ []string) error { + return UsageError(errors.New("an init subcommand is required")) + }, + } + cmd.AddCommand(newHomebrewTapInitCommand(options)) + + return cmd +} + +// newHomebrewTapInitCommand constructs the init homebrew-tap verb. +func newHomebrewTapInitCommand(options Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "homebrew-tap", + Short: "Write a cask-only Homebrew tap scaffold", + Args: usageNoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runHomebrewTapInit(cmd, options) + }, + } + cmd.Flags().String(flagTap, "", "tap repository as owner/homebrew-name") + cmd.Flags().String(flagOutput, "", "new or empty output directory") + + return cmd +} + +// runHomebrewTapInit validates the local request and writes the scaffold. +func runHomebrewTapInit(cmd *cobra.Command, options Options) error { + config, err := resolveHomebrewTapInit(cmd, options) + if err != nil { + return writeCommandResult(options, commandInitHomebrewTap, nil, UsageError(err)) + } + files := renderHomebrewTapScaffold(config) + if err := writeHomebrewTapScaffold(config.Output, files); err != nil { + return writeCommandResult(options, commandInitHomebrewTap, nil, err) + } + if options.settings == nil || !options.settings.JSON { + return nil + } + + paths := make([]string, len(files)) + for index, file := range files { + paths[index] = file.Path + } + result := HomebrewTapInitResult{ + Tap: config.Tap.String(), + Output: config.Output, + Files: paths, + } + + return writeCommandResult(options, commandInitHomebrewTap, result, nil) +} + +// resolveHomebrewTapInit validates flags and the release-cli build commit without I/O. +func resolveHomebrewTapInit(cmd *cobra.Command, options Options) (homebrewTapInitConfig, error) { + tapRaw, err := requiredCommandFlag(cmd, flagTap) + if err != nil { + return homebrewTapInitConfig{}, err + } + tap, err := pubbrew.ParseRepository(tapRaw) + if err != nil { + return homebrewTapInitConfig{}, fmt.Errorf("--%s: %w", flagTap, err) + } + name, err := homebrewTapName(tap) + if err != nil { + return homebrewTapInitConfig{}, fmt.Errorf("--%s: %w", flagTap, err) + } + output, err := requiredCommandFlag(cmd, flagOutput) + if err != nil { + return homebrewTapInitConfig{}, err + } + commit, err := pubgh.ParseCommitSHA(options.Build.Commit) + if err != nil { + return homebrewTapInitConfig{}, fmt.Errorf("release-cli build commit: %w", err) + } + + return homebrewTapInitConfig{ + Tap: tap, + Name: name, + Output: filepath.Clean(output), + Commit: commit, + }, nil +} + +// homebrewTapName derives the install-facing tap name from homebrew-. +func homebrewTapName(repository pubbrew.Repository) (string, error) { + name, ok := strings.CutPrefix(repository.Name, homebrewRepositoryPrefix) + if !ok || name == "" { + return "", fmt.Errorf("repository name %q must use homebrew-", repository.Name) + } + + return name, nil +} + +// renderHomebrewTapScaffold returns the complete deterministic cask-only scaffold. +func renderHomebrewTapScaffold(config homebrewTapInitConfig) []scaffoldFile { + workflow := fmt.Sprintf(`name: Cask CI + +on: + pull_request: + paths: + - 'Casks/**' + +permissions: {} + +jobs: + casks: + permissions: + contents: read + uses: meigma/release/.github/workflows/homebrew-tap-ci.yml@%s +`, config.Commit.String()) + readme := fmt.Sprintf(`# %s + +This repository publishes Homebrew casks through protected pull requests. + +## Install a cask + +Use the tap name %[2]s/%[3]s: + +`+"```console"+` +brew install --cask %[2]s/%[3]s/ +`+"```"+` + +Cask pull requests are validated on macOS and Linux before merge. +`, config.Tap.String(), config.Tap.Owner, config.Name) + + return []scaffoldFile{ + { + Path: ".github/dependabot.yml", + Content: `version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly +`, + }, + {Path: ".github/workflows/casks.yml", Content: workflow}, + {Path: "Casks/.gitkeep", Content: ""}, + {Path: "README.md", Content: readme}, + } +} + +// writeHomebrewTapScaffold atomically publishes files into a new or empty output directory. +func writeHomebrewTapScaffold(output string, files []scaffoldFile) error { + absoluteOutput, err := filepath.Abs(output) + if err != nil { + return fmt.Errorf("resolve output %s: %w", output, err) + } + existed, err := requireEmptyOutput(absoluteOutput) + if err != nil { + return err + } + parent := filepath.Dir(absoluteOutput) + info, err := os.Stat(parent) + if err != nil { + return fmt.Errorf("stat output parent %s: %w", parent, err) + } + if !info.IsDir() { + return fmt.Errorf("output parent %s is not a directory", parent) + } + + temporary, err := os.MkdirTemp(parent, ".release-cli-homebrew-tap-*") + if err != nil { + return fmt.Errorf("create temporary scaffold in %s: %w", parent, err) + } + defer os.RemoveAll(temporary) + if chmodErr := os.Chmod(temporary, scaffoldDirectoryMode); chmodErr != nil { + return fmt.Errorf("set temporary scaffold permissions: %w", chmodErr) + } + root, err := os.OpenRoot(temporary) + if err != nil { + return fmt.Errorf("open temporary scaffold: %w", err) + } + for _, file := range files { + if writeErr := writeScaffoldFile(root, file); writeErr != nil { + return errors.Join(writeErr, root.Close()) + } + } + if err := root.Close(); err != nil { + return fmt.Errorf("close temporary scaffold: %w", err) + } + if err := installScaffoldDirectory(temporary, absoluteOutput, existed); err != nil { + return err + } + + return nil +} + +// requireEmptyOutput rejects a non-directory, symlink, or nonempty output path. +func requireEmptyOutput(output string) (bool, error) { + info, err := os.Lstat(output) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("inspect output %s: %w", output, err) + } + if !info.IsDir() { + return false, fmt.Errorf("output %s is not a directory", output) + } + entries, err := os.ReadDir(output) + if err != nil { + return false, fmt.Errorf("read output %s: %w", output, err) + } + if len(entries) != 0 { + return false, fmt.Errorf("output %s is not empty", output) + } + + return true, nil +} + +// writeScaffoldFile writes one root-confined scaffold file and its parent directories. +func writeScaffoldFile(root *os.Root, file scaffoldFile) error { + directory := path.Dir(file.Path) + if directory != "." { + if err := root.MkdirAll(directory, scaffoldDirectoryMode); err != nil { + return fmt.Errorf("create scaffold directory %s: %w", directory, err) + } + } + if err := root.WriteFile(file.Path, []byte(file.Content), scaffoldFileMode); err != nil { + return fmt.Errorf("write scaffold file %s: %w", file.Path, err) + } + + return nil +} + +// installScaffoldDirectory renames the complete temporary scaffold into place. +func installScaffoldDirectory(temporary, output string, outputExists bool) error { + if !outputExists { + if err := os.Rename(temporary, output); err != nil { + return fmt.Errorf("install scaffold at %s: %w", output, err) + } + return nil + } + if err := os.Rename(temporary, output); err == nil { + return nil + } + if _, err := requireEmptyOutput(output); err != nil { + return err + } + if err := os.Remove(output); err != nil { + return fmt.Errorf("replace empty output %s: %w", output, err) + } + if err := os.Rename(temporary, output); err != nil { + if recreateErr := os.Mkdir(output, scaffoldDirectoryMode); recreateErr != nil { + return errors.Join( + fmt.Errorf("install scaffold at %s: %w", output, err), + fmt.Errorf("restore empty output %s: %w", output, recreateErr), + ) + } + return fmt.Errorf("install scaffold at %s: %w", output, err) + } + + return nil +} diff --git a/internal/cli/homebrew_init_test.go b/internal/cli/homebrew_init_test.go new file mode 100644 index 0000000..5373185 --- /dev/null +++ b/internal/cli/homebrew_init_test.go @@ -0,0 +1,194 @@ +package cli_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/release/internal/cli" +) + +const initializerCommit = "0123456789abcdef0123456789abcdef01234567" + +// TestInitHomebrewTapWritesDeterministicScaffold verifies the complete machine-facing scaffold contract. +func TestInitHomebrewTapWritesDeterministicScaffold(t *testing.T) { + output := filepath.Join(t.TempDir(), "homebrew-tools") + stdout, stderr, err := execute( + t, + nil, + []string{"--json", "init", "homebrew-tap", "--tap", "acme/homebrew-tools", "--output", output}, + cli.BuildInfo{Commit: initializerCommit}, + ) + require.NoError(t, err) + assert.Empty(t, stderr) + assert.Equal(t, 1, countJSONDocuments(stdout)) + + var envelope cli.Envelope + require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(stdout)), &envelope)) + assert.Equal(t, cli.Schema, envelope.Schema) + assert.Equal(t, "init homebrew-tap", envelope.Command) + assert.True(t, envelope.OK) + rawResult, err := json.Marshal(envelope.Result) + require.NoError(t, err) + var result cli.HomebrewTapInitResult + require.NoError(t, json.Unmarshal(rawResult, &result)) + assert.Equal(t, "acme/homebrew-tools", result.Tap) + assert.Equal(t, output, result.Output) + assert.Equal(t, []string{ + ".github/dependabot.yml", + ".github/workflows/casks.yml", + "Casks/.gitkeep", + "README.md", + }, result.Files) + + workflow := readTextFile(t, filepath.Join(output, ".github", "workflows", "casks.yml")) + assert.Equal(t, `name: Cask CI + +on: + pull_request: + paths: + - 'Casks/**' + +permissions: {} + +jobs: + casks: + permissions: + contents: read + uses: meigma/release/.github/workflows/homebrew-tap-ci.yml@0123456789abcdef0123456789abcdef01234567 +`, workflow) + assert.Equal(t, `version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly +`, readTextFile(t, filepath.Join(output, ".github", "dependabot.yml"))) + assert.Empty(t, readTextFile(t, filepath.Join(output, "Casks", ".gitkeep"))) + readme := readTextFile(t, filepath.Join(output, "README.md")) + assert.Contains(t, readme, "# acme/homebrew-tools") + assert.Contains(t, readme, "brew install --cask acme/tools/") + _, err = os.Stat(filepath.Join(output, "Formula")) + require.ErrorIs(t, err, os.ErrNotExist) + + secondStdout, secondStderr, secondErr := execute( + t, + nil, + []string{"init", "homebrew-tap", "--tap", "acme/homebrew-tools", "--output", output}, + cli.BuildInfo{Commit: initializerCommit}, + ) + require.Error(t, secondErr) + assert.Empty(t, secondStdout) + assert.Empty(t, secondStderr) + assert.Equal(t, workflow, readTextFile(t, filepath.Join(output, ".github", "workflows", "casks.yml"))) +} + +// TestInitHomebrewTapAcceptsEmptyOutput verifies callers may pre-create the destination directory. +func TestInitHomebrewTapAcceptsEmptyOutput(t *testing.T) { + output := filepath.Join(t.TempDir(), "tap") + require.NoError(t, os.Mkdir(output, 0o755)) + + stdout, stderr, err := execute( + t, + nil, + []string{"init", "homebrew-tap", "--tap", "acme/homebrew-tools", "--output", output}, + cli.BuildInfo{Commit: initializerCommit}, + ) + require.NoError(t, err) + assert.Empty(t, stdout) + assert.Empty(t, stderr) + assert.FileExists(t, filepath.Join(output, ".github", "workflows", "casks.yml")) +} + +// TestInitHomebrewTapRejectsNonemptyOutput verifies initialization never overlays user files. +func TestInitHomebrewTapRejectsNonemptyOutput(t *testing.T) { + output := filepath.Join(t.TempDir(), "tap") + writeFile(t, filepath.Join(output, "keep.txt"), "keep\n") + + stdout, stderr, err := execute( + t, + nil, + []string{"--json", "init", "homebrew-tap", "--tap", "acme/homebrew-tools", "--output", output}, + cli.BuildInfo{Commit: initializerCommit}, + ) + require.Error(t, err) + require.NotErrorIs(t, err, cli.ErrUsage) + assert.Empty(t, stderr) + assertHomebrewInitFailure(t, stdout, "is not empty") + assert.Equal(t, "keep\n", readTextFile(t, filepath.Join(output, "keep.txt"))) + entries, readErr := os.ReadDir(output) + require.NoError(t, readErr) + require.Len(t, entries, 1) + assert.Equal(t, "keep.txt", entries[0].Name()) +} + +// TestInitHomebrewTapRejectsInvalidConfiguration verifies usage failures happen before filesystem mutation. +func TestInitHomebrewTapRejectsInvalidConfiguration(t *testing.T) { + tests := []struct { + name string + tap string + commit string + wantError string + }{ + { + name: "repository is not a named tap", + tap: "acme/tools", + commit: initializerCommit, + wantError: "must use homebrew-", + }, + { + name: "build commit is not a release commit", + tap: "acme/homebrew-tools", + commit: "none", + wantError: "release-cli build commit", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + output := filepath.Join(t.TempDir(), "tap") + stdout, stderr, err := execute( + t, + nil, + []string{"--json", "init", "homebrew-tap", "--tap", test.tap, "--output", output}, + cli.BuildInfo{Commit: test.commit}, + ) + require.Error(t, err) + require.ErrorIs(t, err, cli.ErrUsage) + assert.Empty(t, stderr) + assertHomebrewInitFailure(t, stdout, test.wantError) + _, statErr := os.Stat(output) + assert.ErrorIs(t, statErr, os.ErrNotExist) + }) + } +} + +// assertHomebrewInitFailure checks one init homebrew-tap failure envelope. +func assertHomebrewInitFailure(t *testing.T, stdout, wantError string) { + t.Helper() + assert.Equal(t, 1, countJSONDocuments(stdout)) + + var envelope cli.Envelope + require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(stdout)), &envelope)) + assert.Equal(t, cli.Schema, envelope.Schema) + assert.Equal(t, "init homebrew-tap", envelope.Command) + assert.False(t, envelope.OK) + rawResult, err := json.Marshal(envelope.Result) + require.NoError(t, err) + var result cli.ErrorResult + require.NoError(t, json.Unmarshal(rawResult, &result)) + assert.Contains(t, result.Error, wantError) +} + +// readTextFile reads one generated text file. +func readTextFile(t *testing.T, name string) string { + t.Helper() + content, err := os.ReadFile(name) + require.NoError(t, err) + return string(content) +} diff --git a/internal/cli/result.go b/internal/cli/result.go index 2420d41..b5a7d21 100644 --- a/internal/cli/result.go +++ b/internal/cli/result.go @@ -56,6 +56,16 @@ type VersionResult struct { Protocol int `json:"protocol"` } +// HomebrewTapInitResult is the --json payload for init homebrew-tap. +type HomebrewTapInitResult struct { + // Tap is the initialized owner/homebrew-name repository. + Tap string `json:"tap"` + // Output is the local scaffold directory. + Output string `json:"output"` + // Files lists every generated slash-separated path in lexical order. + Files []string `json:"files"` +} + // BinaryResult describes one verified canonical binary. type BinaryResult struct { // Path is the original GoReleaser path, including the --dist basename prefix. diff --git a/internal/cli/root.go b/internal/cli/root.go index 8332bc5..d613df7 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -259,6 +259,7 @@ func NewRootCommand(options Options) *cobra.Command { root.AddCommand(newStageCommand(options)) root.AddCommand(newPlanCommand(options)) root.AddCommand(newVerifyCommand(options)) + root.AddCommand(newInitCommand(options)) publish := newPublishCommand(options) publish.AddCommand(newGitHubCommand(options)) root.AddCommand(publish)