From 983df29c6a993b24a4bc6b4960edd3f507e371f7 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 20 Aug 2026 17:48:34 -0700 Subject: [PATCH] feat(cli): publish Homebrew casks through tap PRs --- .mockery.yml | 13 +- cmd/release-cli/main.go | 8 + docs/reference/release-cli-contract.md | 77 ++- internal/adapter/ghtap/client.go | 55 ++ internal/adapter/ghtap/doc.go | 6 + internal/adapter/ghtap/errors.go | 66 ++ internal/adapter/ghtap/mocks/doc.go | 3 + .../adapter/ghtap/mocks/repository_reader.go | 268 ++++++++ .../adapter/ghtap/mocks/repository_writer.go | 268 ++++++++ internal/adapter/ghtap/reader.go | 247 +++++++ internal/adapter/ghtap/reader_test.go | 222 ++++++ internal/adapter/ghtap/writer.go | 115 ++++ internal/adapter/ghtap/writer_test.go | 152 +++++ internal/cli/homebrew.go | 273 ++++++++ internal/cli/homebrew_test.go | 225 +++++++ internal/cli/oci.go | 1 + internal/cli/root.go | 9 + internal/stage/pubbrew/doc.go | 7 + internal/stage/pubbrew/errors.go | 13 + internal/stage/pubbrew/publish.go | 630 ++++++++++++++++++ internal/stage/pubbrew/publish_test.go | 296 ++++++++ internal/stage/pubbrew/retry.go | 53 ++ internal/stage/pubbrew/values.go | 202 ++++++ 23 files changed, 3203 insertions(+), 6 deletions(-) create mode 100644 internal/adapter/ghtap/client.go create mode 100644 internal/adapter/ghtap/doc.go create mode 100644 internal/adapter/ghtap/errors.go create mode 100644 internal/adapter/ghtap/mocks/doc.go create mode 100644 internal/adapter/ghtap/mocks/repository_reader.go create mode 100644 internal/adapter/ghtap/mocks/repository_writer.go create mode 100644 internal/adapter/ghtap/reader.go create mode 100644 internal/adapter/ghtap/reader_test.go create mode 100644 internal/adapter/ghtap/writer.go create mode 100644 internal/adapter/ghtap/writer_test.go create mode 100644 internal/cli/homebrew.go create mode 100644 internal/cli/homebrew_test.go create mode 100644 internal/stage/pubbrew/doc.go create mode 100644 internal/stage/pubbrew/errors.go create mode 100644 internal/stage/pubbrew/publish.go create mode 100644 internal/stage/pubbrew/publish_test.go create mode 100644 internal/stage/pubbrew/retry.go create mode 100644 internal/stage/pubbrew/values.go diff --git a/.mockery.yml b/.mockery.yml index 86bd595..62676e2 100644 --- a/.mockery.yml +++ b/.mockery.yml @@ -1,7 +1,6 @@ # Interfaces live in the consumer package (I2). Generated mocks live under # the implementing adapter's mocks/ package (T3), not next to the interface. -# Add an interface here only when its slice lands. The port budget is closed -# at 13; do not invent a fourteenth. +# Add an interface here only when its implementation slice lands. all: false dir: '{{.InterfaceDir}}' filename: '{{ .InterfaceName | snakecase }}.go' @@ -16,6 +15,16 @@ pkgname: mocks recursive: false template: testify packages: + github.com/meigma/release/internal/stage/pubbrew: + config: + dir: internal/adapter/ghtap/mocks + interfaces: + RepositoryReader: + config: + filename: repository_reader.go + RepositoryWriter: + config: + filename: repository_writer.go github.com/meigma/release/internal/stage/pubgh: config: dir: internal/adapter/ghact/mocks diff --git a/cmd/release-cli/main.go b/cmd/release-cli/main.go index 1d87abe..158b4a0 100644 --- a/cmd/release-cli/main.go +++ b/cmd/release-cli/main.go @@ -11,6 +11,7 @@ import ( "github.com/meigma/release/internal/adapter/cosign" "github.com/meigma/release/internal/adapter/ghact" "github.com/meigma/release/internal/adapter/ghrel" + "github.com/meigma/release/internal/adapter/ghtap" "github.com/meigma/release/internal/adapter/ghup" "github.com/meigma/release/internal/adapter/gitx" "github.com/meigma/release/internal/adapter/melange" @@ -18,6 +19,7 @@ import ( "github.com/meigma/release/internal/cli" "github.com/meigma/release/internal/rel" "github.com/meigma/release/internal/stage/image" + "github.com/meigma/release/internal/stage/pubbrew" "github.com/meigma/release/internal/stage/pubgh" "github.com/meigma/release/internal/stage/puboci" ) @@ -88,6 +90,12 @@ func run() int { Stderr: os.Stderr, }), nil }, + NewTapReader: func(token rel.Secret, endpoint cli.GitHubEndpoint) (pubbrew.RepositoryReader, error) { + return ghtap.NewAuthenticated(token, endpoint.APIURL, endpoint.ServerURL) + }, + NewTapWriter: func(token rel.Secret, endpoint cli.GitHubEndpoint) (pubbrew.RepositoryWriter, error) { + return ghtap.NewAuthenticated(token, endpoint.APIURL, endpoint.ServerURL) + }, NewAPKBuilder: func(path string) (image.APKBuilder, error) { return melange.New(melange.Options{ Path: path, diff --git a/docs/reference/release-cli-contract.md b/docs/reference/release-cli-contract.md index 163b4fb..00cfa26 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, 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, 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. ## Commands @@ -13,11 +13,12 @@ | `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 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`, and `publish github` 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. 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`. @@ -34,7 +35,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 oci prepare`, `publish oci finalize`, `stage`, `verify bundle`, `verify handoff`, or `version`. | +| `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`. | | `ok` | `true` when the command succeeds; otherwise `false`. | | `result` | The command-specific result object. | @@ -216,6 +217,33 @@ For example, a successful publication writes this envelope: } ``` +For `publish homebrew --json`, `command` is exactly `publish homebrew`. The `result` object contains these fields: + +| Field | JSON type | Value | +| --- | --- | --- | +| `tap` | string | Target tap in `owner/repository` form. | +| `cask` | string | Published cask token. | +| `branch` | string | Deterministic publication branch in `release//v` form. | +| `pull_request_url` | string | Matching pull request URL. This can be empty when matching cask content reached the default branch without a discoverable pull request. | +| `state` | string | `created` when the command opened the pull request, `open` when it accepted an existing pull request, or `published` when matching content is on the default branch. | + +For example, a new tap publication writes this envelope: + +```json +{ + "schema": "release.dev/result/v1", + "command": "publish homebrew", + "ok": true, + "result": { + "tap": "owner/homebrew-tap", + "cask": "example", + "branch": "release/example/v1.2.3", + "pull_request_url": "https://github.com/owner/homebrew-tap/pull/42", + "state": "created" + } +} +``` + For `plan tags --json`, `command` is exactly `plan tags`. The `result` object contains these fields: | Field | JSON type | Value | @@ -380,7 +408,7 @@ parse or dispatch failures skip the envelope. These include an unknown command or flag, an invalid flag value, or the wrong number of arguments. The usage error goes to stderr and the process exits with code `2`. -Without `--json`, a successful `image build`, `image verify`, `plan tags`, `publish github`, `publish oci prepare`, `publish oci finalize`, `stage`, `verify bundle`, or `verify handoff` command writes nothing to stdout. A successful `version` command writes `release-cli (, protocol )` to stdout because the version data is the requested output and can be piped. This human format is a convenience, not a stable interface. Human diagnostics and warnings go to stderr. With `--json`, the envelope is the stable machine-readable stdout contract for all commands. +Without `--json`, a successful `image build`, `image verify`, `plan tags`, `publish github`, `publish homebrew`, `publish oci prepare`, `publish oci finalize`, `stage`, `verify bundle`, or `verify handoff` command writes nothing to stdout. A successful `version` command writes `release-cli (, protocol )` to stdout because the version data is the requested output and can be piped. This human format is a convenience, not a stable interface. Human diagnostics and warnings go to stderr. With `--json`, the envelope is the stable machine-readable stdout contract for all commands. ## Exit codes @@ -686,6 +714,47 @@ 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 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. + +| Value | Flag | Environment variable | Default | +| --- | --- | --- | --- | +| Distribution directory | `--dist` | `RELEASE_DIST` | None. A path is required. | +| Target tap | `--tap` | None. | None. Use `owner/repository` form. | +| Cask token | `--cask` | None. | None. Use lowercase letters, digits, and interior hyphens. | +| Release App installation token | None. | `RELEASE_APP_TOKEN` | None. A token is required. | +| JSON output | `--json` | `RELEASE_JSON` | Disabled. | + +The command requires this GitHub Actions context: + +| Variable | Value | +| --- | --- | +| `GITHUB_REPOSITORY` | Source repository in `owner/name` form. | +| `GITHUB_REF_NAME` | Stable release tag. | +| `GITHUB_SHA` | Expected 40-character lowercase commit SHA for the workflow run. | +| `GITHUB_API_URL` | Optional absolute GitHub API base URL. The public GitHub API is the default. | +| `GITHUB_SERVER_URL` | Optional absolute GitHub server and upload base URL used with a custom API URL. | + +The command opens `homebrew/Casks/.rb` beneath the distribution root. The path must resolve to a nonempty regular file no larger than 1 MiB. Root-confined file access rejects a symbolic link that escapes the distribution directory. The cask must contain one literal `version ""` declaration, and that version must equal `GITHUB_REF_NAME` after removal of its leading `v`. + +Publication enforces these guarantees in order: + +1. Read the tap's default branch, head commit, and current cask. +2. Find the unique pull request whose base is the default branch and whose head is `release//v`. Multiple matching pull requests are a conflict. +3. Return `published` without mutation when the default branch already contains the exact generated bytes. +4. Refuse a different cask at the same or a newer version. A malformed current version also fails before mutation. +5. Create the deterministic publication branch from the observed default-branch commit when the branch is absent. +6. Accept an existing publication commit only when it has the observed default-branch commit as its sole parent, changes only `Casks/.rb`, classifies that path as added or modified, and contains the exact generated bytes. The command refuses every other branch state. +7. Commit the generated cask to an unchanged new branch. The update uses the observed blob SHA when the cask already exists. +8. Return `open` when a matching pull request already exists. Otherwise, open a non-draft pull request with maintainer edits and auto-merge disabled, then return `created`. + +After a pull request is merged, a later invocation returns `published` only when the default branch contains the exact generated cask. A merged pull request without those bytes, or a closed unmerged pull request, is a conflict. + +Repository reads and retryable writes use at most four attempts, waiting 1 second, 2 seconds, and 4 seconds between attempts. After a failed branch, file, or pull-request write, the command reads fresh state before retrying. This accepts a write that GitHub applied before losing the response without creating a duplicate commit or pull request. + +A missing or malformed flag, Actions variable, token, endpoint, or source commit is a configuration error and exits with code `2` before a tap request. A missing, malformed, empty, non-regular, or oversized generated cask exits with code `1` before a tap request. Repository failures, conflicts, and failed postconditions also exit with code `1`. Success exits with code `0`. + ## Signed release bundle verification `release-cli verify bundle` verifies the local release bundle before the GitHub Release workflow attests or uploads it. diff --git a/internal/adapter/ghtap/client.go b/internal/adapter/ghtap/client.go new file mode 100644 index 0000000..b1204c0 --- /dev/null +++ b/internal/adapter/ghtap/client.go @@ -0,0 +1,55 @@ +package ghtap + +import ( + "context" + "errors" + "fmt" + + "github.com/google/go-github/v82/github" + + "github.com/meigma/release/internal/rel" +) + +// Client reads and mutates one GitHub tap through go-github. +type Client struct { + // github is the already-authenticated API client. + github *github.Client +} + +// New constructs a [Client] around an already-authenticated go-github client. +func New(client *github.Client) *Client { + return &Client{github: client} +} + +// NewAuthenticated constructs a [Client] for token at the given GitHub API. +// +// An empty apiURL selects public GitHub. Token text is applied only to the +// Authorization header and is never retained separately or returned in errors. +func NewAuthenticated(token rel.Secret, apiURL, serverURL string) (*Client, error) { + client := github.NewClient(nil).WithAuthToken(token.Reveal()) + if apiURL == "" { + return New(client), nil + } + uploadURL := serverURL + if uploadURL == "" { + uploadURL = apiURL + } + enterprise, err := client.WithEnterpriseURLs(apiURL, uploadURL) + if err != nil { + return nil, fmt.Errorf("github enterprise urls: %w", err) + } + + return New(enterprise), nil +} + +// requireReady rejects a nil context or uninitialized client. +func (c *Client) requireReady(ctx context.Context) error { + if ctx == nil { + return errors.New("context is nil") + } + if c == nil || c.github == nil { + return errors.New("github client is nil") + } + + return nil +} diff --git a/internal/adapter/ghtap/doc.go b/internal/adapter/ghtap/doc.go new file mode 100644 index 0000000..aa44c4c --- /dev/null +++ b/internal/adapter/ghtap/doc.go @@ -0,0 +1,6 @@ +// Package ghtap implements Homebrew tap repository reads and writes with the +// GitHub REST API. +// +// The adapter maps remote metadata into pubbrew snapshots. Publication policy, +// reconciliation, and retry decisions remain in the pubbrew stage package. +package ghtap diff --git a/internal/adapter/ghtap/errors.go b/internal/adapter/ghtap/errors.go new file mode 100644 index 0000000..e45fb2f --- /dev/null +++ b/internal/adapter/ghtap/errors.go @@ -0,0 +1,66 @@ +package ghtap + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/google/go-github/v82/github" + + "github.com/meigma/release/internal/stage/pubbrew" +) + +// errNotFound marks a repository resource that GitHub does not expose. +var errNotFound = errors.New("github repository resource not found") + +// classify maps a go-github failure onto a safe domain sentinel or diagnostic. +// +// It never includes request headers, response bodies, URLs, or token text. +func classify(err error, resource string) error { + if errors.Is(err, context.Canceled) { + return fmt.Errorf("%w: request canceled", context.Canceled) + } + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("%w: request deadline exceeded", context.DeadlineExceeded) + } + + var rateLimit *github.RateLimitError + if errors.As(err, &rateLimit) { + return fmt.Errorf("%w: rate limited", pubbrew.ErrRetryable) + } + var abuse *github.AbuseRateLimitError + if errors.As(err, &abuse) { + return fmt.Errorf("%w: secondary rate limited", pubbrew.ErrRetryable) + } + + var apiErr *github.ErrorResponse + if !errors.As(err, &apiErr) || apiErr.Response == nil { + return fmt.Errorf("%s request failed", resource) + } + + switch code := apiErr.Response.StatusCode; { + case code == http.StatusNotFound: + return fmt.Errorf("%w: %s", errNotFound, resource) + case code == http.StatusUnauthorized || code == http.StatusForbidden: + return fmt.Errorf("github authentication failed: status %d", code) + case code == http.StatusConflict || code == http.StatusUnprocessableEntity: + return fmt.Errorf("%w: %s status %d", pubbrew.ErrConflict, resource, code) + case code == http.StatusTooManyRequests || code >= http.StatusInternalServerError: + return fmt.Errorf("%w: %s status %d", pubbrew.ErrRetryable, resource, code) + default: + return fmt.Errorf("%s request failed: status %d", resource, code) + } +} + +// isNotFound reports whether err is a raw or classified GitHub 404 response. +func isNotFound(err error) bool { + if errors.Is(err, errNotFound) { + return true + } + var apiErr *github.ErrorResponse + + return errors.As(err, &apiErr) && + apiErr.Response != nil && + apiErr.Response.StatusCode == http.StatusNotFound +} diff --git a/internal/adapter/ghtap/mocks/doc.go b/internal/adapter/ghtap/mocks/doc.go new file mode 100644 index 0000000..44437a5 --- /dev/null +++ b/internal/adapter/ghtap/mocks/doc.go @@ -0,0 +1,3 @@ +// Package mocks contains generated test doubles for Homebrew tap repository +// ports. +package mocks diff --git a/internal/adapter/ghtap/mocks/repository_reader.go b/internal/adapter/ghtap/mocks/repository_reader.go new file mode 100644 index 0000000..04d49e5 --- /dev/null +++ b/internal/adapter/ghtap/mocks/repository_reader.go @@ -0,0 +1,268 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + mock "github.com/stretchr/testify/mock" + + "github.com/meigma/release/internal/stage/pubbrew" +) + +// NewMockRepositoryReader creates a new instance of MockRepositoryReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockRepositoryReader(t interface { + mock.TestingT + Cleanup(func()) +}) *MockRepositoryReader { + mock := &MockRepositoryReader{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockRepositoryReader is an autogenerated mock type for the RepositoryReader type +type MockRepositoryReader struct { + mock.Mock +} + +type MockRepositoryReader_Expecter struct { + mock *mock.Mock +} + +func (_m *MockRepositoryReader) EXPECT() *MockRepositoryReader_Expecter { + return &MockRepositoryReader_Expecter{mock: &_m.Mock} +} + +// ReadBase provides a mock function for the type MockRepositoryReader +func (_mock *MockRepositoryReader) ReadBase(ctx context.Context, repository pubbrew.Repository, path pubbrew.FilePath) (pubbrew.BaseSnapshot, error) { + ret := _mock.Called(ctx, repository, path) + + if len(ret) == 0 { + panic("no return value specified for ReadBase") + } + + var r0 pubbrew.BaseSnapshot + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, pubbrew.Repository, pubbrew.FilePath) (pubbrew.BaseSnapshot, error)); ok { + return returnFunc(ctx, repository, path) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, pubbrew.Repository, pubbrew.FilePath) pubbrew.BaseSnapshot); ok { + r0 = returnFunc(ctx, repository, path) + } else { + r0 = ret.Get(0).(pubbrew.BaseSnapshot) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, pubbrew.Repository, pubbrew.FilePath) error); ok { + r1 = returnFunc(ctx, repository, path) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockRepositoryReader_ReadBase_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadBase' +type MockRepositoryReader_ReadBase_Call struct { + *mock.Call +} + +// ReadBase is a helper method to define mock.On call +// - ctx context.Context +// - repository pubbrew.Repository +// - path pubbrew.FilePath +func (_e *MockRepositoryReader_Expecter) ReadBase(ctx any, repository any, path any) *MockRepositoryReader_ReadBase_Call { + return &MockRepositoryReader_ReadBase_Call{Call: _e.mock.On("ReadBase", ctx, repository, path)} +} + +func (_c *MockRepositoryReader_ReadBase_Call) Run(run func(ctx context.Context, repository pubbrew.Repository, path pubbrew.FilePath)) *MockRepositoryReader_ReadBase_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 pubbrew.Repository + if args[1] != nil { + arg1 = args[1].(pubbrew.Repository) + } + var arg2 pubbrew.FilePath + if args[2] != nil { + arg2 = args[2].(pubbrew.FilePath) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockRepositoryReader_ReadBase_Call) Return(baseSnapshot pubbrew.BaseSnapshot, err error) *MockRepositoryReader_ReadBase_Call { + _c.Call.Return(baseSnapshot, err) + return _c +} + +func (_c *MockRepositoryReader_ReadBase_Call) RunAndReturn(run func(ctx context.Context, repository pubbrew.Repository, path pubbrew.FilePath) (pubbrew.BaseSnapshot, error)) *MockRepositoryReader_ReadBase_Call { + _c.Call.Return(run) + return _c +} + +// ReadBranch provides a mock function for the type MockRepositoryReader +func (_mock *MockRepositoryReader) ReadBranch(ctx context.Context, repository pubbrew.Repository, branch pubbrew.BranchName, path pubbrew.FilePath) (pubbrew.BranchSnapshot, error) { + ret := _mock.Called(ctx, repository, branch, path) + + if len(ret) == 0 { + panic("no return value specified for ReadBranch") + } + + var r0 pubbrew.BranchSnapshot + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, pubbrew.Repository, pubbrew.BranchName, pubbrew.FilePath) (pubbrew.BranchSnapshot, error)); ok { + return returnFunc(ctx, repository, branch, path) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, pubbrew.Repository, pubbrew.BranchName, pubbrew.FilePath) pubbrew.BranchSnapshot); ok { + r0 = returnFunc(ctx, repository, branch, path) + } else { + r0 = ret.Get(0).(pubbrew.BranchSnapshot) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, pubbrew.Repository, pubbrew.BranchName, pubbrew.FilePath) error); ok { + r1 = returnFunc(ctx, repository, branch, path) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockRepositoryReader_ReadBranch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadBranch' +type MockRepositoryReader_ReadBranch_Call struct { + *mock.Call +} + +// ReadBranch is a helper method to define mock.On call +// - ctx context.Context +// - repository pubbrew.Repository +// - branch pubbrew.BranchName +// - path pubbrew.FilePath +func (_e *MockRepositoryReader_Expecter) ReadBranch(ctx any, repository any, branch any, path any) *MockRepositoryReader_ReadBranch_Call { + return &MockRepositoryReader_ReadBranch_Call{Call: _e.mock.On("ReadBranch", ctx, repository, branch, path)} +} + +func (_c *MockRepositoryReader_ReadBranch_Call) Run(run func(ctx context.Context, repository pubbrew.Repository, branch pubbrew.BranchName, path pubbrew.FilePath)) *MockRepositoryReader_ReadBranch_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 pubbrew.Repository + if args[1] != nil { + arg1 = args[1].(pubbrew.Repository) + } + var arg2 pubbrew.BranchName + if args[2] != nil { + arg2 = args[2].(pubbrew.BranchName) + } + var arg3 pubbrew.FilePath + if args[3] != nil { + arg3 = args[3].(pubbrew.FilePath) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockRepositoryReader_ReadBranch_Call) Return(branchSnapshot pubbrew.BranchSnapshot, err error) *MockRepositoryReader_ReadBranch_Call { + _c.Call.Return(branchSnapshot, err) + return _c +} + +func (_c *MockRepositoryReader_ReadBranch_Call) RunAndReturn(run func(ctx context.Context, repository pubbrew.Repository, branch pubbrew.BranchName, path pubbrew.FilePath) (pubbrew.BranchSnapshot, error)) *MockRepositoryReader_ReadBranch_Call { + _c.Call.Return(run) + return _c +} + +// ReadPullRequest provides a mock function for the type MockRepositoryReader +func (_mock *MockRepositoryReader) ReadPullRequest(ctx context.Context, repository pubbrew.Repository, base pubbrew.BranchName, head pubbrew.BranchName) (pubbrew.PullRequest, error) { + ret := _mock.Called(ctx, repository, base, head) + + if len(ret) == 0 { + panic("no return value specified for ReadPullRequest") + } + + var r0 pubbrew.PullRequest + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, pubbrew.Repository, pubbrew.BranchName, pubbrew.BranchName) (pubbrew.PullRequest, error)); ok { + return returnFunc(ctx, repository, base, head) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, pubbrew.Repository, pubbrew.BranchName, pubbrew.BranchName) pubbrew.PullRequest); ok { + r0 = returnFunc(ctx, repository, base, head) + } else { + r0 = ret.Get(0).(pubbrew.PullRequest) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, pubbrew.Repository, pubbrew.BranchName, pubbrew.BranchName) error); ok { + r1 = returnFunc(ctx, repository, base, head) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockRepositoryReader_ReadPullRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadPullRequest' +type MockRepositoryReader_ReadPullRequest_Call struct { + *mock.Call +} + +// ReadPullRequest is a helper method to define mock.On call +// - ctx context.Context +// - repository pubbrew.Repository +// - base pubbrew.BranchName +// - head pubbrew.BranchName +func (_e *MockRepositoryReader_Expecter) ReadPullRequest(ctx any, repository any, base any, head any) *MockRepositoryReader_ReadPullRequest_Call { + return &MockRepositoryReader_ReadPullRequest_Call{Call: _e.mock.On("ReadPullRequest", ctx, repository, base, head)} +} + +func (_c *MockRepositoryReader_ReadPullRequest_Call) Run(run func(ctx context.Context, repository pubbrew.Repository, base pubbrew.BranchName, head pubbrew.BranchName)) *MockRepositoryReader_ReadPullRequest_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 pubbrew.Repository + if args[1] != nil { + arg1 = args[1].(pubbrew.Repository) + } + var arg2 pubbrew.BranchName + if args[2] != nil { + arg2 = args[2].(pubbrew.BranchName) + } + var arg3 pubbrew.BranchName + if args[3] != nil { + arg3 = args[3].(pubbrew.BranchName) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockRepositoryReader_ReadPullRequest_Call) Return(pullRequest pubbrew.PullRequest, err error) *MockRepositoryReader_ReadPullRequest_Call { + _c.Call.Return(pullRequest, err) + return _c +} + +func (_c *MockRepositoryReader_ReadPullRequest_Call) RunAndReturn(run func(ctx context.Context, repository pubbrew.Repository, base pubbrew.BranchName, head pubbrew.BranchName) (pubbrew.PullRequest, error)) *MockRepositoryReader_ReadPullRequest_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/adapter/ghtap/mocks/repository_writer.go b/internal/adapter/ghtap/mocks/repository_writer.go new file mode 100644 index 0000000..b6bbd75 --- /dev/null +++ b/internal/adapter/ghtap/mocks/repository_writer.go @@ -0,0 +1,268 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package mocks + +import ( + "context" + + mock "github.com/stretchr/testify/mock" + + "github.com/meigma/release/internal/stage/pubbrew" +) + +// NewMockRepositoryWriter creates a new instance of MockRepositoryWriter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockRepositoryWriter(t interface { + mock.TestingT + Cleanup(func()) +}) *MockRepositoryWriter { + mock := &MockRepositoryWriter{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockRepositoryWriter is an autogenerated mock type for the RepositoryWriter type +type MockRepositoryWriter struct { + mock.Mock +} + +type MockRepositoryWriter_Expecter struct { + mock *mock.Mock +} + +func (_m *MockRepositoryWriter) EXPECT() *MockRepositoryWriter_Expecter { + return &MockRepositoryWriter_Expecter{mock: &_m.Mock} +} + +// CreateBranch provides a mock function for the type MockRepositoryWriter +func (_mock *MockRepositoryWriter) CreateBranch(ctx context.Context, repository pubbrew.Repository, branch pubbrew.BranchName, from pubbrew.CommitSHA) error { + ret := _mock.Called(ctx, repository, branch, from) + + if len(ret) == 0 { + panic("no return value specified for CreateBranch") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, pubbrew.Repository, pubbrew.BranchName, pubbrew.CommitSHA) error); ok { + r0 = returnFunc(ctx, repository, branch, from) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockRepositoryWriter_CreateBranch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateBranch' +type MockRepositoryWriter_CreateBranch_Call struct { + *mock.Call +} + +// CreateBranch is a helper method to define mock.On call +// - ctx context.Context +// - repository pubbrew.Repository +// - branch pubbrew.BranchName +// - from pubbrew.CommitSHA +func (_e *MockRepositoryWriter_Expecter) CreateBranch(ctx any, repository any, branch any, from any) *MockRepositoryWriter_CreateBranch_Call { + return &MockRepositoryWriter_CreateBranch_Call{Call: _e.mock.On("CreateBranch", ctx, repository, branch, from)} +} + +func (_c *MockRepositoryWriter_CreateBranch_Call) Run(run func(ctx context.Context, repository pubbrew.Repository, branch pubbrew.BranchName, from pubbrew.CommitSHA)) *MockRepositoryWriter_CreateBranch_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 pubbrew.Repository + if args[1] != nil { + arg1 = args[1].(pubbrew.Repository) + } + var arg2 pubbrew.BranchName + if args[2] != nil { + arg2 = args[2].(pubbrew.BranchName) + } + var arg3 pubbrew.CommitSHA + if args[3] != nil { + arg3 = args[3].(pubbrew.CommitSHA) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockRepositoryWriter_CreateBranch_Call) Return(err error) *MockRepositoryWriter_CreateBranch_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockRepositoryWriter_CreateBranch_Call) RunAndReturn(run func(ctx context.Context, repository pubbrew.Repository, branch pubbrew.BranchName, from pubbrew.CommitSHA) error) *MockRepositoryWriter_CreateBranch_Call { + _c.Call.Return(run) + return _c +} + +// CreatePullRequest provides a mock function for the type MockRepositoryWriter +func (_mock *MockRepositoryWriter) CreatePullRequest(ctx context.Context, repository pubbrew.Repository, input pubbrew.PullRequestInput) (string, error) { + ret := _mock.Called(ctx, repository, input) + + if len(ret) == 0 { + panic("no return value specified for CreatePullRequest") + } + + var r0 string + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, pubbrew.Repository, pubbrew.PullRequestInput) (string, error)); ok { + return returnFunc(ctx, repository, input) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, pubbrew.Repository, pubbrew.PullRequestInput) string); ok { + r0 = returnFunc(ctx, repository, input) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, pubbrew.Repository, pubbrew.PullRequestInput) error); ok { + r1 = returnFunc(ctx, repository, input) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockRepositoryWriter_CreatePullRequest_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreatePullRequest' +type MockRepositoryWriter_CreatePullRequest_Call struct { + *mock.Call +} + +// CreatePullRequest is a helper method to define mock.On call +// - ctx context.Context +// - repository pubbrew.Repository +// - input pubbrew.PullRequestInput +func (_e *MockRepositoryWriter_Expecter) CreatePullRequest(ctx any, repository any, input any) *MockRepositoryWriter_CreatePullRequest_Call { + return &MockRepositoryWriter_CreatePullRequest_Call{Call: _e.mock.On("CreatePullRequest", ctx, repository, input)} +} + +func (_c *MockRepositoryWriter_CreatePullRequest_Call) Run(run func(ctx context.Context, repository pubbrew.Repository, input pubbrew.PullRequestInput)) *MockRepositoryWriter_CreatePullRequest_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 pubbrew.Repository + if args[1] != nil { + arg1 = args[1].(pubbrew.Repository) + } + var arg2 pubbrew.PullRequestInput + if args[2] != nil { + arg2 = args[2].(pubbrew.PullRequestInput) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockRepositoryWriter_CreatePullRequest_Call) Return(s string, err error) *MockRepositoryWriter_CreatePullRequest_Call { + _c.Call.Return(s, err) + return _c +} + +func (_c *MockRepositoryWriter_CreatePullRequest_Call) RunAndReturn(run func(ctx context.Context, repository pubbrew.Repository, input pubbrew.PullRequestInput) (string, error)) *MockRepositoryWriter_CreatePullRequest_Call { + _c.Call.Return(run) + return _c +} + +// PutFile provides a mock function for the type MockRepositoryWriter +func (_mock *MockRepositoryWriter) PutFile(ctx context.Context, repository pubbrew.Repository, branch pubbrew.BranchName, path pubbrew.FilePath, previous pubbrew.BlobSHA, content []byte, message string) error { + ret := _mock.Called(ctx, repository, branch, path, previous, content, message) + + if len(ret) == 0 { + panic("no return value specified for PutFile") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, pubbrew.Repository, pubbrew.BranchName, pubbrew.FilePath, pubbrew.BlobSHA, []byte, string) error); ok { + r0 = returnFunc(ctx, repository, branch, path, previous, content, message) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockRepositoryWriter_PutFile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'PutFile' +type MockRepositoryWriter_PutFile_Call struct { + *mock.Call +} + +// PutFile is a helper method to define mock.On call +// - ctx context.Context +// - repository pubbrew.Repository +// - branch pubbrew.BranchName +// - path pubbrew.FilePath +// - previous pubbrew.BlobSHA +// - content []byte +// - message string +func (_e *MockRepositoryWriter_Expecter) PutFile(ctx any, repository any, branch any, path any, previous any, content any, message any) *MockRepositoryWriter_PutFile_Call { + return &MockRepositoryWriter_PutFile_Call{Call: _e.mock.On("PutFile", ctx, repository, branch, path, previous, content, message)} +} + +func (_c *MockRepositoryWriter_PutFile_Call) Run(run func(ctx context.Context, repository pubbrew.Repository, branch pubbrew.BranchName, path pubbrew.FilePath, previous pubbrew.BlobSHA, content []byte, message string)) *MockRepositoryWriter_PutFile_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 pubbrew.Repository + if args[1] != nil { + arg1 = args[1].(pubbrew.Repository) + } + var arg2 pubbrew.BranchName + if args[2] != nil { + arg2 = args[2].(pubbrew.BranchName) + } + var arg3 pubbrew.FilePath + if args[3] != nil { + arg3 = args[3].(pubbrew.FilePath) + } + var arg4 pubbrew.BlobSHA + if args[4] != nil { + arg4 = args[4].(pubbrew.BlobSHA) + } + var arg5 []byte + if args[5] != nil { + arg5 = args[5].([]byte) + } + var arg6 string + if args[6] != nil { + arg6 = args[6].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + arg5, + arg6, + ) + }) + return _c +} + +func (_c *MockRepositoryWriter_PutFile_Call) Return(err error) *MockRepositoryWriter_PutFile_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockRepositoryWriter_PutFile_Call) RunAndReturn(run func(ctx context.Context, repository pubbrew.Repository, branch pubbrew.BranchName, path pubbrew.FilePath, previous pubbrew.BlobSHA, content []byte, message string) error) *MockRepositoryWriter_PutFile_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/adapter/ghtap/reader.go b/internal/adapter/ghtap/reader.go new file mode 100644 index 0000000..9a7f531 --- /dev/null +++ b/internal/adapter/ghtap/reader.go @@ -0,0 +1,247 @@ +package ghtap + +import ( + "context" + "errors" + "fmt" + + "github.com/google/go-github/v82/github" + + "github.com/meigma/release/internal/stage/pubbrew" +) + +const ( + // pullRequestPageSize is GitHub's maximum pull-request list page size. + pullRequestPageSize = 100 +) + +// ReadBase implements [pubbrew.RepositoryReader]. +func (c *Client) ReadBase( + ctx context.Context, + repository pubbrew.Repository, + path pubbrew.FilePath, +) (pubbrew.BaseSnapshot, error) { + if err := c.requireReady(ctx); err != nil { + return pubbrew.BaseSnapshot{}, err + } + + remote, _, err := c.github.Repositories.Get(ctx, repository.Owner, repository.Name) + if err != nil { + return pubbrew.BaseSnapshot{}, classify(err, "tap repository") + } + branch := remote.GetDefaultBranch() + if branch == "" { + return pubbrew.BaseSnapshot{}, errors.New("tap default branch is empty") + } + commit, err := c.refCommit(ctx, repository, pubbrew.BranchName(branch)) + if err != nil { + return pubbrew.BaseSnapshot{}, err + } + file, err := c.readFile(ctx, repository, commit, path) + if err != nil { + return pubbrew.BaseSnapshot{}, err + } + + return pubbrew.BaseSnapshot{ + Branch: pubbrew.BranchName(branch), + Commit: commit, + File: file, + }, nil +} + +// ReadBranch implements [pubbrew.RepositoryReader]. +func (c *Client) ReadBranch( + ctx context.Context, + repository pubbrew.Repository, + branch pubbrew.BranchName, + path pubbrew.FilePath, +) (pubbrew.BranchSnapshot, error) { + if err := c.requireReady(ctx); err != nil { + return pubbrew.BranchSnapshot{}, err + } + + commit, err := c.refCommit(ctx, repository, branch) + if err != nil { + if isNotFound(err) { + return pubbrew.BranchSnapshot{}, nil + } + + return pubbrew.BranchSnapshot{}, err + } + remote, _, err := c.github.Repositories.GetCommit( + ctx, + repository.Owner, + repository.Name, + commit.String(), + nil, + ) + if err != nil { + return pubbrew.BranchSnapshot{}, classify(err, "publication branch commit") + } + file, err := c.readFile(ctx, repository, commit, path) + if err != nil { + return pubbrew.BranchSnapshot{}, err + } + + return pubbrew.BranchSnapshot{ + Present: true, + Commit: commit, + Parent: soleParent(remote), + Files: changedFiles(remote.Files), + File: file, + }, nil +} + +// ReadPullRequest implements [pubbrew.RepositoryReader]. +func (c *Client) ReadPullRequest( + ctx context.Context, + repository pubbrew.Repository, + base pubbrew.BranchName, + head pubbrew.BranchName, +) (pubbrew.PullRequest, error) { + if err := c.requireReady(ctx); err != nil { + return pubbrew.PullRequest{}, err + } + + options := &github.PullRequestListOptions{ + State: "all", + Head: repository.Owner + ":" + head.String(), + Base: base.String(), + ListOptions: github.ListOptions{ + PerPage: pullRequestPageSize, + }, + } + var matched []*github.PullRequest + for { + pulls, response, err := c.github.PullRequests.List( + ctx, + repository.Owner, + repository.Name, + options, + ) + if err != nil { + return pubbrew.PullRequest{}, classify(err, "publication pull request") + } + for _, pull := range pulls { + if pull.GetHead().GetRef() == head.String() && pull.GetBase().GetRef() == base.String() { + matched = append(matched, pull) + } + } + if response == nil || response.NextPage == 0 { + break + } + options.Page = response.NextPage + } + + switch len(matched) { + case 0: + return pubbrew.PullRequest{State: pubbrew.PullRequestAbsent}, nil + case 1: + return mapPullRequest(matched[0]) + default: + return pubbrew.PullRequest{}, fmt.Errorf( + "%w: multiple pull requests use branch %s", + pubbrew.ErrConflict, + head, + ) + } +} + +// refCommit resolves one branch without updating it. +func (c *Client) refCommit( + ctx context.Context, + repository pubbrew.Repository, + branch pubbrew.BranchName, +) (pubbrew.CommitSHA, error) { + ref, _, err := c.github.Git.GetRef( + ctx, + repository.Owner, + repository.Name, + "heads/"+branch.String(), + ) + if err != nil { + return "", classify(err, "repository branch") + } + sha := ref.GetObject().GetSHA() + if sha == "" { + return "", errors.New("repository branch commit is empty") + } + + return pubbrew.CommitSHA(sha), nil +} + +// readFile returns path at ref or a successful absent snapshot. +func (c *Client) readFile( + ctx context.Context, + repository pubbrew.Repository, + ref pubbrew.CommitSHA, + path pubbrew.FilePath, +) (pubbrew.File, error) { + file, directory, _, err := c.github.Repositories.GetContents( + ctx, + repository.Owner, + repository.Name, + path.String(), + &github.RepositoryContentGetOptions{Ref: ref.String()}, + ) + if err != nil { + if isNotFound(err) { + return pubbrew.File{}, nil + } + + return pubbrew.File{}, classify(err, "repository cask") + } + if file == nil || len(directory) != 0 || file.GetType() != "file" { + return pubbrew.File{}, errors.New("repository cask is not a regular file") + } + content, err := file.GetContent() + if err != nil { + return pubbrew.File{}, errors.New("repository cask content is malformed") + } + if file.GetSHA() == "" { + return pubbrew.File{}, errors.New("repository cask blob SHA is empty") + } + + return pubbrew.File{ + Present: true, + Content: []byte(content), + SHA: pubbrew.BlobSHA(file.GetSHA()), + }, nil +} + +// soleParent returns the parent only when the commit has exactly one. +func soleParent(commit *github.RepositoryCommit) pubbrew.CommitSHA { + if commit == nil || len(commit.Parents) != 1 { + return "" + } + + return pubbrew.CommitSHA(commit.Parents[0].GetSHA()) +} + +// changedFiles maps commit paths without interpreting publication policy. +func changedFiles(files []*github.CommitFile) []pubbrew.ChangedFile { + changed := make([]pubbrew.ChangedFile, 0, len(files)) + for _, file := range files { + changed = append(changed, pubbrew.ChangedFile{ + Path: pubbrew.FilePath(file.GetFilename()), + Status: pubbrew.ChangeStatus(file.GetStatus()), + }) + } + + return changed +} + +// mapPullRequest maps GitHub state onto the closed publication lifecycle. +func mapPullRequest(pull *github.PullRequest) (pubbrew.PullRequest, error) { + if pull == nil || pull.GetHTMLURL() == "" { + return pubbrew.PullRequest{}, errors.New("publication pull request URL is empty") + } + state := pubbrew.PullRequestClosed + if pull.GetState() == "open" { + state = pubbrew.PullRequestOpen + } else if pull.MergedAt != nil { + state = pubbrew.PullRequestMerged + } + + return pubbrew.PullRequest{State: state, URL: pull.GetHTMLURL()}, nil +} diff --git a/internal/adapter/ghtap/reader_test.go b/internal/adapter/ghtap/reader_test.go new file mode 100644 index 0000000..f6c8bd1 --- /dev/null +++ b/internal/adapter/ghtap/reader_test.go @@ -0,0 +1,222 @@ +package ghtap_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/google/go-github/v82/github" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/release/internal/adapter/ghtap" + "github.com/meigma/release/internal/stage/pubbrew" +) + +const ( + // testToken is a credential marker that must never appear in errors. + testToken = "ghs_homebrew_adapter_secret" + // testBaseSHA is the tap default-branch commit. + testBaseSHA = "1111111111111111111111111111111111111111" + // testHeadSHA is the publication branch commit. + testHeadSHA = "2222222222222222222222222222222222222222" + // testBlobSHA is the cask blob commit. + testBlobSHA = "3333333333333333333333333333333333333333" + // testPullURL is the publication review URL. + testPullURL = "https://github.com/meigma/homebrew-tap/pull/7" +) + +// TestClientSatisfiesPorts proves the focused adapter implements both tap ports. +func TestClientSatisfiesPorts(t *testing.T) { + t.Parallel() + + var ( + _ pubbrew.RepositoryReader = (*ghtap.Client)(nil) + _ pubbrew.RepositoryWriter = (*ghtap.Client)(nil) + ) +} + +// TestReadBaseReturnsDefaultBranchAndCask proves the base snapshot is bound to +// one immutable commit rather than racing against a moving branch ref. +func TestReadBaseReturnsDefaultBranchAndCask(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + assert.Equal(t, "Bearer "+testToken, request.Header.Get("Authorization")) + switch request.URL.Path { + case "/repos/meigma/homebrew-tap": + assert.NoError(t, json.NewEncoder(writer).Encode(map[string]any{"default_branch": "main"})) + case "/repos/meigma/homebrew-tap/git/ref/heads/main": + assert.NoError(t, json.NewEncoder(writer).Encode(map[string]any{ + "ref": "refs/heads/main", + "object": map[string]any{"sha": testBaseSHA, "type": "commit"}, + })) + case "/repos/meigma/homebrew-tap/contents/Casks/release-cli.rb": + assert.Equal(t, testBaseSHA, request.URL.Query().Get("ref")) + assert.NoError(t, json.NewEncoder(writer).Encode(map[string]any{ + "type": "file", + "sha": testBlobSHA, + "encoding": "base64", + "content": "dmVyc2lvbiAiMS4yLjIiCg==", + })) + default: + t.Fatalf("unexpected request %s", request.URL.String()) + } + })) + t.Cleanup(server.Close) + + got, err := newClient(t, server).ReadBase(context.Background(), mustRepository(t), testPath()) + require.NoError(t, err) + assert.Equal(t, pubbrew.BranchName("main"), got.Branch) + assert.Equal(t, pubbrew.CommitSHA(testBaseSHA), got.Commit) + assert.Equal(t, []byte("version \"1.2.2\"\n"), got.File.Content) + assert.Equal(t, pubbrew.BlobSHA(testBlobSHA), got.File.SHA) +} + +// TestReadBranchMapsOneCommit proves branch reconciliation receives the exact +// head commit parent, changed paths, and decoded cask. +func TestReadBranchMapsOneCommit(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/repos/meigma/homebrew-tap/git/ref/heads/release/release-cli/v1.2.3": + assert.NoError(t, json.NewEncoder(writer).Encode(map[string]any{ + "ref": "refs/heads/release/release-cli/v1.2.3", + "object": map[string]any{"sha": testHeadSHA, "type": "commit"}, + })) + case "/repos/meigma/homebrew-tap/commits/" + testHeadSHA: + assert.NoError(t, json.NewEncoder(writer).Encode(map[string]any{ + "sha": testHeadSHA, + "parents": []map[string]any{{"sha": testBaseSHA}}, + "files": []map[string]any{{"filename": "Casks/release-cli.rb", "status": "modified"}}, + })) + case "/repos/meigma/homebrew-tap/contents/Casks/release-cli.rb": + assert.Equal(t, testHeadSHA, request.URL.Query().Get("ref")) + assert.NoError(t, json.NewEncoder(writer).Encode(map[string]any{ + "type": "file", + "sha": testBlobSHA, + "encoding": "base64", + "content": "dmVyc2lvbiAiMS4yLjMiCg==", + })) + default: + t.Fatalf("unexpected request %s", request.URL.String()) + } + })) + t.Cleanup(server.Close) + + got, err := newClient(t, server).ReadBranch( + context.Background(), + mustRepository(t), + "release/release-cli/v1.2.3", + testPath(), + ) + require.NoError(t, err) + assert.True(t, got.Present) + assert.Equal(t, pubbrew.CommitSHA(testHeadSHA), got.Commit) + assert.Equal(t, pubbrew.CommitSHA(testBaseSHA), got.Parent) + require.Len(t, got.Files, 1) + assert.Equal(t, testPath(), got.Files[0].Path) + assert.Equal(t, pubbrew.ChangeModified, got.Files[0].Status) + assert.Equal(t, []byte("version \"1.2.3\"\n"), got.File.Content) +} + +// TestReadBranchReturnsAbsent proves a missing deterministic branch is normal +// state rather than an adapter failure. +func TestReadBranchReturnsAbsent(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusNotFound) + })) + t.Cleanup(server.Close) + + got, err := newClient(t, server).ReadBranch( + context.Background(), + mustRepository(t), + "release/release-cli/v1.2.3", + testPath(), + ) + require.NoError(t, err) + assert.False(t, got.Present) +} + +// TestReadPullRequestMapsMergedReview proves the adapter filters the exact head +// and base and preserves the merged review URL. +func TestReadPullRequestMapsMergedReview(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + assert.Equal(t, "/repos/meigma/homebrew-tap/pulls", request.URL.Path) + assert.Equal(t, "all", request.URL.Query().Get("state")) + assert.Equal(t, "meigma:release/release-cli/v1.2.3", request.URL.Query().Get("head")) + assert.Equal(t, "main", request.URL.Query().Get("base")) + assert.Equal(t, "100", request.URL.Query().Get("per_page")) + assert.NoError(t, json.NewEncoder(writer).Encode([]map[string]any{{ + "state": "closed", + "html_url": testPullURL, + "merged_at": "2026-08-20T00:00:00Z", + "head": map[string]any{"ref": "release/release-cli/v1.2.3"}, + "base": map[string]any{"ref": "main"}, + }})) + })) + t.Cleanup(server.Close) + + got, err := newClient(t, server).ReadPullRequest( + context.Background(), + mustRepository(t), + "main", + "release/release-cli/v1.2.3", + ) + require.NoError(t, err) + assert.Equal(t, pubbrew.PullRequestMerged, got.State) + assert.Equal(t, testPullURL, got.URL) +} + +// TestReadBaseClassifiesRetryableFailure proves transient API failures remain +// retryable without leaking credentials or request URLs. +func TestReadBaseClassifiesRetryableFailure(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusServiceUnavailable) + _, _ = writer.Write([]byte(testToken)) + })) + t.Cleanup(server.Close) + + _, err := newClient(t, server).ReadBase(context.Background(), mustRepository(t), testPath()) + require.Error(t, err) + require.ErrorIs(t, err, pubbrew.ErrRetryable) + assert.NotContains(t, err.Error(), testToken) + assert.NotContains(t, err.Error(), server.URL) +} + +// newClient returns an authenticated go-github client pointed at server. +func newClient(t *testing.T, server *httptest.Server) *ghtap.Client { + t.Helper() + + parsed, err := url.Parse(server.URL + "/") + require.NoError(t, err) + client := github.NewClient(server.Client()).WithAuthToken(testToken) + client.BaseURL = parsed + + return ghtap.New(client) +} + +// mustRepository parses the tap fixture or fails the test. +func mustRepository(t *testing.T) pubbrew.Repository { + t.Helper() + + repository, err := pubbrew.ParseRepository("meigma/homebrew-tap") + require.NoError(t, err) + + return repository +} + +// testPath returns the publisher's only changed path. +func testPath() pubbrew.FilePath { + return "Casks/release-cli.rb" +} diff --git a/internal/adapter/ghtap/writer.go b/internal/adapter/ghtap/writer.go new file mode 100644 index 0000000..0ff246b --- /dev/null +++ b/internal/adapter/ghtap/writer.go @@ -0,0 +1,115 @@ +package ghtap + +import ( + "context" + "errors" + + "github.com/google/go-github/v82/github" + + "github.com/meigma/release/internal/stage/pubbrew" +) + +// CreateBranch implements [pubbrew.RepositoryWriter]. +func (c *Client) CreateBranch( + ctx context.Context, + repository pubbrew.Repository, + branch pubbrew.BranchName, + from pubbrew.CommitSHA, +) error { + if err := c.requireReady(ctx); err != nil { + return err + } + + _, _, err := c.github.Git.CreateRef( + ctx, + repository.Owner, + repository.Name, + github.CreateRef{ + Ref: "refs/heads/" + branch.String(), + SHA: from.String(), + }, + ) + if err != nil { + return classify(err, "publication branch") + } + + return nil +} + +// PutFile implements [pubbrew.RepositoryWriter]. +func (c *Client) PutFile( + ctx context.Context, + repository pubbrew.Repository, + branch pubbrew.BranchName, + path pubbrew.FilePath, + previous pubbrew.BlobSHA, + content []byte, + message string, +) error { + if err := c.requireReady(ctx); err != nil { + return err + } + + options := &github.RepositoryContentFileOptions{ + Message: new(message), + Content: content, + Branch: new(branch.String()), + } + var err error + if previous == "" { + _, _, err = c.github.Repositories.CreateFile( + ctx, + repository.Owner, + repository.Name, + path.String(), + options, + ) + } else { + options.SHA = new(previous.String()) + _, _, err = c.github.Repositories.UpdateFile( + ctx, + repository.Owner, + repository.Name, + path.String(), + options, + ) + } + if err != nil { + return classify(err, "publication cask commit") + } + + return nil +} + +// CreatePullRequest implements [pubbrew.RepositoryWriter]. +func (c *Client) CreatePullRequest( + ctx context.Context, + repository pubbrew.Repository, + input pubbrew.PullRequestInput, +) (string, error) { + if err := c.requireReady(ctx); err != nil { + return "", err + } + + pull, _, err := c.github.PullRequests.Create( + ctx, + repository.Owner, + repository.Name, + &github.NewPullRequest{ + Title: new(input.Title), + Head: new(input.Head.String()), + Base: new(input.Base.String()), + Body: new(input.Body), + MaintainerCanModify: new(false), + Draft: new(false), + }, + ) + if err != nil { + return "", classify(err, "publication pull request") + } + if pull == nil || pull.GetHTMLURL() == "" { + return "", errors.New("created pull request URL is empty") + } + + return pull.GetHTMLURL(), nil +} diff --git a/internal/adapter/ghtap/writer_test.go b/internal/adapter/ghtap/writer_test.go new file mode 100644 index 0000000..80d57eb --- /dev/null +++ b/internal/adapter/ghtap/writer_test.go @@ -0,0 +1,152 @@ +package ghtap_test + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/release/internal/stage/pubbrew" +) + +// TestCreateBranchUsesNonForceRefCreation proves branch publication cannot +// overwrite an existing reference. +func TestCreateBranchUsesNonForceRefCreation(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + assert.Equal(t, http.MethodPost, request.Method) + assert.Equal(t, "/repos/meigma/homebrew-tap/git/refs", request.URL.Path) + var payload map[string]any + if !assert.NoError(t, json.NewDecoder(request.Body).Decode(&payload)) { + return + } + assert.Equal(t, "refs/heads/release/release-cli/v1.2.3", payload["ref"]) + assert.Equal(t, testBaseSHA, payload["sha"]) + _, hasForce := payload["force"] + assert.False(t, hasForce) + assert.NoError(t, json.NewEncoder(writer).Encode(map[string]any{ + "ref": payload["ref"], + "object": map[string]any{"sha": testBaseSHA}, + })) + })) + t.Cleanup(server.Close) + + err := newClient(t, server).CreateBranch( + context.Background(), + mustRepository(t), + "release/release-cli/v1.2.3", + testBaseSHA, + ) + require.NoError(t, err) +} + +// TestPutFileUpdatesOnlyExpectedCask proves an existing cask is replaced on +// the publication branch with the observed base blob SHA. +func TestPutFileUpdatesOnlyExpectedCask(t *testing.T) { + t.Parallel() + + content := []byte("cask \"release-cli\" do\n version \"1.2.3\"\nend\n") + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + assert.Equal(t, http.MethodPut, request.Method) + assert.Equal(t, "/repos/meigma/homebrew-tap/contents/Casks/release-cli.rb", request.URL.Path) + var payload struct { + // Message is the commit subject. + Message string `json:"message"` + // Content is the API-encoded cask body. + Content string `json:"content"` + // SHA is the previous blob object ID. + SHA string `json:"sha"` + // Branch is the publication branch. + Branch string `json:"branch"` + } + if !assert.NoError(t, json.NewDecoder(request.Body).Decode(&payload)) { + return + } + assert.Equal(t, "chore(cask): update release-cli to 1.2.3", payload.Message) + assert.Equal(t, base64.StdEncoding.EncodeToString(content), payload.Content) + assert.Equal(t, testBlobSHA, payload.SHA) + assert.Equal(t, "release/release-cli/v1.2.3", payload.Branch) + assert.NoError(t, json.NewEncoder(writer).Encode(map[string]any{ + "content": map[string]any{"sha": "4444444444444444444444444444444444444444"}, + "commit": map[string]any{"sha": testHeadSHA}, + })) + })) + t.Cleanup(server.Close) + + err := newClient(t, server).PutFile( + context.Background(), + mustRepository(t), + "release/release-cli/v1.2.3", + testPath(), + testBlobSHA, + content, + "chore(cask): update release-cli to 1.2.3", + ) + require.NoError(t, err) +} + +// TestCreatePullRequestLeavesMergeManual proves publication opens a normal +// review without draft or auto-merge behavior. +func TestCreatePullRequestLeavesMergeManual(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + assert.Equal(t, http.MethodPost, request.Method) + assert.Equal(t, "/repos/meigma/homebrew-tap/pulls", request.URL.Path) + var payload map[string]any + if !assert.NoError(t, json.NewDecoder(request.Body).Decode(&payload)) { + return + } + assert.Equal(t, "release/release-cli/v1.2.3", payload["head"]) + assert.Equal(t, "main", payload["base"]) + assert.Equal(t, false, payload["draft"]) + assert.Equal(t, false, payload["maintainer_can_modify"]) + _, hasAutoMerge := payload["auto_merge"] + assert.False(t, hasAutoMerge) + assert.NoError(t, json.NewEncoder(writer).Encode(map[string]any{ + "state": "open", + "html_url": testPullURL, + })) + })) + t.Cleanup(server.Close) + + url, err := newClient(t, server).CreatePullRequest( + context.Background(), + mustRepository(t), + pubbrew.PullRequestInput{ + Base: "main", + Head: "release/release-cli/v1.2.3", + Title: "chore(cask): update release-cli to 1.2.3", + Body: "Source release: https://github.com/meigma/release/releases/tag/v1.2.3", + }, + ) + require.NoError(t, err) + assert.Equal(t, testPullURL, url) +} + +// TestCreateBranchClassifiesConflict proves GitHub refuses rather than +// overwrites an existing publication branch. +func TestCreateBranchClassifiesConflict(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusUnprocessableEntity) + })) + t.Cleanup(server.Close) + + err := newClient(t, server).CreateBranch( + context.Background(), + mustRepository(t), + "release/release-cli/v1.2.3", + testBaseSHA, + ) + require.Error(t, err) + require.ErrorIs(t, err, pubbrew.ErrConflict) + assert.NotContains(t, err.Error(), testToken) +} diff --git a/internal/cli/homebrew.go b/internal/cli/homebrew.go new file mode 100644 index 0000000..7438df6 --- /dev/null +++ b/internal/cli/homebrew.go @@ -0,0 +1,273 @@ +package cli + +import ( + "errors" + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + + "github.com/meigma/release/internal/rel" + "github.com/meigma/release/internal/stage/pubbrew" + "github.com/meigma/release/internal/stage/pubgh" +) + +const ( + // commandHomebrew is the envelope command path for publish homebrew. + commandHomebrew = "publish homebrew" + // flagTap is the Homebrew tap owner/repository flag name. + flagTap = "tap" + // flagCask is the expected Homebrew cask token flag name. + flagCask = "cask" + // generatedCaskDirectory is GoReleaser's cask output directory under dist. + generatedCaskDirectory = "homebrew/" + // maxGeneratedCaskBytes bounds the generated Ruby source read into memory. + maxGeneratedCaskBytes int64 = 1 << 20 + // maxGeneratedCaskReadBytes distinguishes an exact-size file from an + // oversized file. + maxGeneratedCaskReadBytes = maxGeneratedCaskBytes + 1 +) + +// newHomebrewCommand constructs the publish homebrew verb. +func newHomebrewCommand(options Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "homebrew", + Short: "Open a protected tap pull request for a generated cask", + Args: usageNoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runHomebrew(cmd, options) + }, + } + cmd.Flags().String(flagDist, "", "path to the authoritative release artifact directory") + cmd.Flags().String(flagTap, "", "target Homebrew tap as owner/repository") + cmd.Flags().String(flagCask, "", "expected Homebrew cask token") + + return cmd +} + +// homebrewConfig is the resolved publish-homebrew configuration. +type homebrewConfig struct { + // Dist is the authoritative release artifact directory. + Dist string + // Tap is the target Homebrew tap. + Tap pubbrew.Repository + // Source is the repository that owns the release. + Source pubbrew.Repository + // Version is the stable source release version. + Version rel.Version + // Commit is the source commit that built the release. + Commit pubbrew.CommitSHA + // Cask is the expected generated cask token. + Cask pubbrew.CaskToken + // Token is the GitHub App installation token. + Token rel.Secret + // Endpoint is the GitHub API location. + Endpoint GitHubEndpoint +} + +// runHomebrew validates configuration, opens the generated cask through a +// confined distribution root, and reconciles the tap pull request. +func runHomebrew(cmd *cobra.Command, options Options) error { + expected, err := resolveHomebrew(cmd, options) + if err != nil { + return writeCommandResult(options, commandHomebrew, nil, UsageError(err)) + } + content, err := readGeneratedCask(expected.Dist, expected.Cask) + if err != nil { + return writeCommandResult(options, commandHomebrew, nil, err) + } + reader, err := tapReader(options, expected) + if err != nil { + return writeCommandResult(options, commandHomebrew, nil, err) + } + writer, err := tapWriter(options, expected) + if err != nil { + return writeCommandResult(options, commandHomebrew, nil, err) + } + + result, err := pubbrew.Publish(cmd.Context(), pubbrew.PublishInput{ + Tap: expected.Tap, + Source: expected.Source, + Version: expected.Version, + Commit: expected.Commit, + Cask: expected.Cask, + Content: content, + }, reader, writer) + if err != nil { + return writeCommandResult(options, commandHomebrew, nil, err) + } + if options.settings == nil || !options.settings.JSON { + return nil + } + + return writeCommandResult(options, commandHomebrew, result, nil) +} + +// resolveHomebrew parses flags and Actions environment without performing I/O. +func resolveHomebrew(cmd *cobra.Command, options Options) (homebrewConfig, error) { + settings := Settings{} + if options.settings != nil { + settings = *options.settings + } + if err := settings.err; err != nil { + return homebrewConfig{}, err + } + if settings.Dist == "" { + return homebrewConfig{}, fmt.Errorf("--%s is required", flagDist) + } + + tapRaw, err := requiredCommandFlag(cmd, flagTap) + if err != nil { + return homebrewConfig{}, err + } + tap, err := pubbrew.ParseRepository(tapRaw) + if err != nil { + return homebrewConfig{}, fmt.Errorf("--%s: %w", flagTap, err) + } + caskRaw, err := requiredCommandFlag(cmd, flagCask) + if err != nil { + return homebrewConfig{}, err + } + cask, err := pubbrew.ParseCaskToken(caskRaw) + if err != nil { + return homebrewConfig{}, fmt.Errorf("--%s: %w", flagCask, err) + } + + sourceRaw, err := requiredEnv(options.LookupEnv, envRepository) + if err != nil { + return homebrewConfig{}, err + } + source, err := pubbrew.ParseRepository(sourceRaw) + if err != nil { + return homebrewConfig{}, fmt.Errorf("%s: %w", envRepository, err) + } + versionRaw, err := deriveVersion(options.LookupEnv) + if err != nil { + return homebrewConfig{}, err + } + version, err := rel.ParseVersion(versionRaw) + if err != nil { + return homebrewConfig{}, fmt.Errorf("%s: %w", envRefName, err) + } + commitRaw, err := requiredEnv(options.LookupEnv, envCommitSHA) + if err != nil { + return homebrewConfig{}, err + } + commit, err := pubgh.ParseCommitSHA(commitRaw) + if err != nil { + return homebrewConfig{}, err + } + tokenRaw, err := requiredEnv(options.LookupEnv, envAppToken) + if err != nil { + return homebrewConfig{}, err + } + endpoint, err := resolveGitHubEndpoint(options.LookupEnv) + if err != nil { + return homebrewConfig{}, err + } + + return homebrewConfig{ + Dist: settings.Dist, + Tap: tap, + Source: source, + Version: version, + Commit: pubbrew.CommitSHA(commit.String()), + Cask: cask, + Token: rel.NewSecret(tokenRaw), + Endpoint: endpoint, + }, nil +} + +// requiredCommandFlag returns one nonempty local command flag. +func requiredCommandFlag(cmd *cobra.Command, name string) (string, error) { + value, err := cmd.Flags().GetString(name) + if err != nil { + return "", fmt.Errorf("read --%s: %w", name, err) + } + if value == "" { + return "", fmt.Errorf("--%s is required", name) + } + + return value, nil +} + +// readGeneratedCask reads exactly homebrew/Casks/.rb through an [os.Root]. +func readGeneratedCask(dist string, token pubbrew.CaskToken) ([]byte, error) { + root, err := os.OpenRoot(dist) + if err != nil { + return nil, fmt.Errorf("open dist %s: %w", dist, err) + } + defer root.Close() + + path := generatedCaskDirectory + token.Path().String() + file, err := root.Open(path) + if err != nil { + return nil, fmt.Errorf("open generated cask %s: %w", path, err) + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, fmt.Errorf("stat generated cask %s: %w", path, err) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("generated cask %s is not a regular file", path) + } + if info.Size() == 0 { + return nil, fmt.Errorf("generated cask %s is empty", path) + } + if info.Size() > maxGeneratedCaskBytes { + return nil, fmt.Errorf("generated cask %s exceeds %d bytes", path, maxGeneratedCaskBytes) + } + + content, err := io.ReadAll(io.LimitReader(file, maxGeneratedCaskReadBytes)) + if err != nil { + return nil, fmt.Errorf("read generated cask %s: %w", path, err) + } + if int64(len(content)) > maxGeneratedCaskBytes { + return nil, fmt.Errorf("generated cask %s exceeds %d bytes", path, maxGeneratedCaskBytes) + } + if len(content) == 0 { + return nil, fmt.Errorf("generated cask %s is empty", path) + } + + return content, nil +} + +// tapReader returns the injected read port or constructs one. +func tapReader(options Options, expected homebrewConfig) (pubbrew.RepositoryReader, error) { + if options.TapReader != nil { + return options.TapReader, nil + } + if options.NewTapReader == nil { + return nil, errors.New("tap repository reader is not configured") + } + reader, err := options.NewTapReader(expected.Token, expected.Endpoint) + if err != nil { + return nil, fmt.Errorf("construct tap repository reader: %w", err) + } + if reader == nil { + return nil, errors.New("tap repository reader is nil") + } + + return reader, nil +} + +// tapWriter returns the injected write port or constructs one. +func tapWriter(options Options, expected homebrewConfig) (pubbrew.RepositoryWriter, error) { + if options.TapWriter != nil { + return options.TapWriter, nil + } + if options.NewTapWriter == nil { + return nil, errors.New("tap repository writer is not configured") + } + writer, err := options.NewTapWriter(expected.Token, expected.Endpoint) + if err != nil { + return nil, fmt.Errorf("construct tap repository writer: %w", err) + } + if writer == nil { + return nil, errors.New("tap repository writer is nil") + } + + return writer, nil +} diff --git a/internal/cli/homebrew_test.go b/internal/cli/homebrew_test.go new file mode 100644 index 0000000..3d19cdd --- /dev/null +++ b/internal/cli/homebrew_test.go @@ -0,0 +1,225 @@ +package cli_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/meigma/release/internal/adapter/ghtap/mocks" + "github.com/meigma/release/internal/cli" + "github.com/meigma/release/internal/rel" + "github.com/meigma/release/internal/stage/pubbrew" +) + +const ( + // homebrewCommit is the source release commit fixture. + homebrewCommit = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + // homebrewToken is the secret fixture that must never enter output. + homebrewToken = "ghs_homebrew_command_secret" +) + +// TestPublishHomebrewEmitsPublishedEnvelope proves command resolution, confined +// cask loading, secret factory delivery, and the stable JSON result contract. +func TestPublishHomebrewEmitsPublishedEnvelope(t *testing.T) { + t.Parallel() + + fixture := newHomebrewCLI(t) + var readerToken rel.Secret + var writerToken rel.Secret + fixture.options.NewTapReader = func(token rel.Secret, _ cli.GitHubEndpoint) (pubbrew.RepositoryReader, error) { + readerToken = token + return fixture.reader, nil + } + fixture.options.NewTapWriter = func(token rel.Secret, _ cli.GitHubEndpoint) (pubbrew.RepositoryWriter, error) { + writerToken = token + return fixture.writer, nil + } + fixture.reader.EXPECT().ReadBase(mock.Anything, fixture.tap, fixture.path).Return(pubbrew.BaseSnapshot{ + Branch: "main", + Commit: "1111111111111111111111111111111111111111", + File: pubbrew.File{ + Present: true, + Content: fixture.content, + SHA: "2222222222222222222222222222222222222222", + }, + }, nil).Once() + fixture.reader.EXPECT().ReadPullRequest( + mock.Anything, + fixture.tap, + pubbrew.BranchName("main"), + pubbrew.BranchName("release/release-cli/v1.2.3"), + ).Return(pubbrew.PullRequest{State: pubbrew.PullRequestAbsent}, nil).Once() + + err := fixture.execute( + "--json", + "publish", + "homebrew", + "--dist", + fixture.dist, + "--tap", + fixture.tap.String(), + "--cask", + "release-cli", + ) + require.NoError(t, err) + assert.Equal(t, homebrewToken, readerToken.Reveal()) + assert.Equal(t, homebrewToken, writerToken.Reveal()) + assert.Empty(t, fixture.stderr.String()) + assert.NotContains(t, fixture.stdout.String(), homebrewToken) + + var envelope cli.Envelope + require.NoError(t, json.Unmarshal([]byte(fixture.stdout.String()), &envelope)) + assert.Equal(t, cli.Schema, envelope.Schema) + assert.Equal(t, "publish homebrew", envelope.Command) + assert.True(t, envelope.OK) + payload, ok := envelope.Result.(map[string]any) + require.True(t, ok) + assert.Equal(t, fixture.tap.String(), payload["tap"]) + assert.Equal(t, "release-cli", payload["cask"]) + assert.Equal(t, "release/release-cli/v1.2.3", payload["branch"]) + assert.Equal(t, "published", payload["state"]) +} + +// TestPublishHomebrewMissingTokenIsUsage proves configuration fails before a +// repository port is constructed. +func TestPublishHomebrewMissingTokenIsUsage(t *testing.T) { + t.Parallel() + + fixture := newHomebrewCLI(t) + fixture.environment["RELEASE_APP_TOKEN"] = "" + constructed := false + fixture.options.NewTapReader = func(rel.Secret, cli.GitHubEndpoint) (pubbrew.RepositoryReader, error) { + constructed = true + return fixture.reader, nil + } + + err := fixture.execute( + "--json", + "publish", + "homebrew", + "--dist", + fixture.dist, + "--tap", + fixture.tap.String(), + "--cask", + "release-cli", + ) + require.Error(t, err) + require.ErrorIs(t, err, cli.ErrUsage) + assert.False(t, constructed) + assert.Contains(t, err.Error(), "RELEASE_APP_TOKEN is required") + assert.NotContains(t, fixture.stdout.String(), homebrewToken) +} + +// TestPublishHomebrewRefusesEscapingSymlink proves the generated cask is opened +// through the confined distribution root. +func TestPublishHomebrewRefusesEscapingSymlink(t *testing.T) { + t.Parallel() + + fixture := newHomebrewCLI(t) + outside := filepath.Join(t.TempDir(), "outside.rb") + require.NoError(t, os.WriteFile(outside, fixture.content, 0o600)) + require.NoError(t, os.Remove(filepath.Join(fixture.dist, "homebrew", "Casks", "release-cli.rb"))) + require.NoError(t, os.Symlink(outside, filepath.Join(fixture.dist, "homebrew", "Casks", "release-cli.rb"))) + constructed := false + fixture.options.NewTapReader = func(rel.Secret, cli.GitHubEndpoint) (pubbrew.RepositoryReader, error) { + constructed = true + return fixture.reader, nil + } + + err := fixture.execute( + "publish", + "homebrew", + "--dist", + fixture.dist, + "--tap", + fixture.tap.String(), + "--cask", + "release-cli", + ) + require.Error(t, err) + assert.False(t, constructed) + assert.Contains(t, err.Error(), "open generated cask") + assert.NotContains(t, err.Error(), string(fixture.content)) +} + +// homebrewCLI holds one isolated command fixture. +type homebrewCLI struct { + // dist contains the generated GoReleaser cask. + dist string + // content is the expected cask Ruby source. + content []byte + // tap is the parsed target repository. + tap pubbrew.Repository + // path is the expected tap path. + path pubbrew.FilePath + // environment is the command's injected process environment. + environment map[string]string + // options constructs the root command. + options cli.Options + // reader is the generated tap read mock. + reader *mocks.MockRepositoryReader + // writer is the generated tap write mock. + writer *mocks.MockRepositoryWriter + // stdout receives machine-readable output. + stdout *strings.Builder + // stderr receives diagnostics. + stderr *strings.Builder +} + +// newHomebrewCLI constructs one valid command fixture. +func newHomebrewCLI(t *testing.T) *homebrewCLI { + t.Helper() + + dist := t.TempDir() + directory := filepath.Join(dist, "homebrew", "Casks") + require.NoError(t, os.MkdirAll(directory, 0o755)) + content := []byte("cask \"release-cli\" do\n version \"1.2.3\"\nend\n") + require.NoError(t, os.WriteFile(filepath.Join(directory, "release-cli.rb"), content, 0o600)) + tap, err := pubbrew.ParseRepository("meigma/homebrew-tap") + require.NoError(t, err) + stdout := &strings.Builder{} + stderr := &strings.Builder{} + environment := map[string]string{ + "RELEASE_APP_TOKEN": homebrewToken, + "GITHUB_REPOSITORY": "meigma/release", + "GITHUB_REF_NAME": "v1.2.3", + "GITHUB_SHA": homebrewCommit, + } + + fixture := &homebrewCLI{ + dist: dist, + content: content, + tap: tap, + path: "Casks/release-cli.rb", + environment: environment, + reader: mocks.NewMockRepositoryReader(t), + writer: mocks.NewMockRepositoryWriter(t), + stdout: stdout, + stderr: stderr, + } + fixture.options = cli.Options{ + Out: stdout, + Err: stderr, + LookupEnv: func(key string) (string, bool) { + value, ok := fixture.environment[key] + return value, ok && value != "" + }, + } + + return fixture +} + +// execute constructs and runs a fresh root command with arguments. +func (fixture *homebrewCLI) execute(arguments ...string) error { + command := cli.NewRootCommand(fixture.options) + command.SetArgs(arguments) + return command.ExecuteContext(context.Background()) +} diff --git a/internal/cli/oci.go b/internal/cli/oci.go index 88ec22c..b8a8e94 100644 --- a/internal/cli/oci.go +++ b/internal/cli/oci.go @@ -58,6 +58,7 @@ func newPublishCommand(options Options) *cobra.Command { }, } cmd.AddCommand(newOCICommand(options)) + cmd.AddCommand(newHomebrewCommand(options)) return cmd } diff --git a/internal/cli/root.go b/internal/cli/root.go index 83bcfe2..8332bc5 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -14,6 +14,7 @@ import ( "github.com/meigma/release/internal/profile/goprof" "github.com/meigma/release/internal/rel" "github.com/meigma/release/internal/stage/image" + "github.com/meigma/release/internal/stage/pubbrew" "github.com/meigma/release/internal/stage/pubgh" "github.com/meigma/release/internal/stage/puboci" ) @@ -199,6 +200,14 @@ type Options struct { // An empty path resolves git from PATH. An empty directory inherits // the process working directory. NewRefResolver func(path, dir string) (pubgh.RefResolver, error) + // TapReader, when set, is the Homebrew tap read port. Tests inject it. + TapReader pubbrew.RepositoryReader + // NewTapReader constructs the tap read port from a token and API endpoint. + NewTapReader func(token rel.Secret, endpoint GitHubEndpoint) (pubbrew.RepositoryReader, error) + // TapWriter, when set, is the Homebrew tap mutation port. Tests inject it. + TapWriter pubbrew.RepositoryWriter + // NewTapWriter constructs the tap mutation port from a token and API endpoint. + NewTapWriter func(token rel.Secret, endpoint GitHubEndpoint) (pubbrew.RepositoryWriter, error) // APKBuilder, when set, is the Melange APK-build port. Tests inject it. APKBuilder image.APKBuilder // NewAPKBuilder constructs the Melange APK-build port from a binary path. diff --git a/internal/stage/pubbrew/doc.go b/internal/stage/pubbrew/doc.go new file mode 100644 index 0000000..6d42ec2 --- /dev/null +++ b/internal/stage/pubbrew/doc.go @@ -0,0 +1,7 @@ +// Package pubbrew reconciles one generated Homebrew cask into a protected tap +// through a reviewable GitHub pull request. +// +// The package owns publication policy and idempotency. Repository reads, +// branch writes, and pull-request creation remain behind narrow ports so the +// state machine is deterministic and testable without network access. +package pubbrew diff --git a/internal/stage/pubbrew/errors.go b/internal/stage/pubbrew/errors.go new file mode 100644 index 0000000..cebafba --- /dev/null +++ b/internal/stage/pubbrew/errors.go @@ -0,0 +1,13 @@ +package pubbrew + +import "errors" + +// Publication sentinel errors. +var ( + // ErrConflict reports remote state that the publisher cannot safely + // overwrite or reconcile. + ErrConflict = errors.New("homebrew publication conflict") + // ErrRetryable reports a transient repository failure that may succeed on + // a bounded retry. + ErrRetryable = errors.New("retryable homebrew repository failure") +) diff --git a/internal/stage/pubbrew/publish.go b/internal/stage/pubbrew/publish.go new file mode 100644 index 0000000..0d5ce7c --- /dev/null +++ b/internal/stage/pubbrew/publish.go @@ -0,0 +1,630 @@ +package pubbrew + +import ( + "bytes" + "context" + "errors" + "fmt" + + "github.com/meigma/release/internal/rel" +) + +// PublicationState is the reconciled tap outcome. +type PublicationState string + +const ( + // StateCreated means this invocation created the pull request. + StateCreated PublicationState = "created" + // StateOpen means the exact pull request already existed. + StateOpen PublicationState = "open" + // StatePublished means the tap default branch already contains the cask. + StatePublished PublicationState = "published" +) + +// PullRequestState is the observed lifecycle state of one publication pull request. +type PullRequestState string + +const ( + // PullRequestAbsent means no pull request uses the publication branch. + PullRequestAbsent PullRequestState = "absent" + // PullRequestOpen means the pull request awaits review or merge. + PullRequestOpen PullRequestState = "open" + // PullRequestMerged means the pull request was merged. + PullRequestMerged PullRequestState = "merged" + // PullRequestClosed means the pull request was closed without merging. + PullRequestClosed PullRequestState = "closed" +) + +// BaseSnapshot is the tap default branch and cask observed together. +type BaseSnapshot struct { + // Branch is the tap's current default branch. + Branch BranchName + // Commit is the default branch head commit. + Commit CommitSHA + // File is the cask at Commit. + File File +} + +// BranchSnapshot is the publication branch head and cask. +type BranchSnapshot struct { + // Present reports whether the branch exists. + Present bool + // Commit is the branch head commit when Present is true. + Commit CommitSHA + // Parent is the sole parent of Commit. It is empty unless Commit has + // exactly one parent. + Parent CommitSHA + // Files are the paths changed by Commit. + Files []ChangedFile + // File is the cask at Commit. + File File +} + +// PullRequest is the unique pull request for a publication branch. +type PullRequest struct { + // State is the observed pull-request lifecycle. + State PullRequestState + // URL is the human-facing pull-request URL when State is not absent. + URL string +} + +// PullRequestInput is the closed request used to open a publication review. +type PullRequestInput struct { + // Base is the tap default branch. + Base BranchName + // Head is the publication branch. + Head BranchName + // Title is the pull-request title. + Title string + // Body is the pull-request description. + Body string +} + +// RepositoryReader observes tap branches, casks, and pull requests. +type RepositoryReader interface { + // ReadBase returns the default branch, its head, and path at that head. + ReadBase(ctx context.Context, repository Repository, path FilePath) (BaseSnapshot, error) + // ReadBranch returns branch head metadata and path at that head. An absent + // branch is a successful snapshot with Present false. + ReadBranch( + ctx context.Context, + repository Repository, + branch BranchName, + path FilePath, + ) (BranchSnapshot, error) + // ReadPullRequest returns the unique pull request from head into base. No + // match is a successful result with State absent. + ReadPullRequest( + ctx context.Context, + repository Repository, + base BranchName, + head BranchName, + ) (PullRequest, error) +} + +// RepositoryWriter creates publisher-owned branches, commits, and pull requests. +type RepositoryWriter interface { + // CreateBranch creates branch at from without updating an existing ref. + CreateBranch( + ctx context.Context, + repository Repository, + branch BranchName, + from CommitSHA, + ) error + // PutFile creates one commit on branch that creates or replaces path. + // Previous is empty for a new path and the current base blob for an update. + PutFile( + ctx context.Context, + repository Repository, + branch BranchName, + path FilePath, + previous BlobSHA, + content []byte, + message string, + ) error + // CreatePullRequest opens a non-draft pull request without auto-merge. + CreatePullRequest( + ctx context.Context, + repository Repository, + input PullRequestInput, + ) (string, error) +} + +// PublishInput is the closed input to [Publish]. +type PublishInput struct { + // Tap is the Homebrew tap repository to update. + Tap Repository + // Source is the producer repository that owns the release. + Source Repository + // Version is the stable released version. + Version rel.Version + // Commit is the producer commit that built the release. + Commit CommitSHA + // Cask is the expected cask token and filename stem. + Cask CaskToken + // Content is the generated cask Ruby source. + Content []byte + // Sleep waits between retryable observations. Nil selects a + // context-aware timer. + Sleep SleepFunc +} + +// PublishResult is the JSON payload produced by a successful [Publish]. +type PublishResult struct { + // Tap is the target owner/repository. + Tap string `json:"tap"` + // Cask is the published cask token. + Cask string `json:"cask"` + // Branch is the deterministic publication branch. + Branch string `json:"branch"` + // PullRequestURL is the review URL. It can be empty when matching content + // reached the default branch outside a discoverable pull request. + PullRequestURL string `json:"pull_request_url"` + // State is created, open, or published. + State PublicationState `json:"state"` +} + +// Publish reconciles one generated cask through a tap pull request. +// +// It never writes the default branch, force-updates a branch, deletes a path, +// or enables auto-merge. Remote write errors are followed by a fresh read +// before retry so an accepted request with a lost response cannot duplicate a +// commit or pull request. +func Publish( + ctx context.Context, + input PublishInput, + reader RepositoryReader, + writer RepositoryWriter, +) (PublishResult, error) { + if err := validatePublish(ctx, input, reader, writer); err != nil { + return PublishResult{}, err + } + desiredVersion, err := caskVersion(input.Content) + if err != nil { + return PublishResult{}, fmt.Errorf("generated cask: %w", err) + } + if desiredVersion != input.Version { + return PublishResult{}, fmt.Errorf( + "generated cask version %s, expected %s", + desiredVersion, + input.Version, + ) + } + + sleep := input.Sleep + if sleep == nil { + sleep = sleepContext + } + + return publish(ctx, input, reader, writer, sleep) +} + +// validatePublish rejects incomplete input and nil ports before any I/O. +func validatePublish( + ctx context.Context, + input PublishInput, + reader RepositoryReader, + writer RepositoryWriter, +) error { + if ctx == nil { + return errors.New("context is nil") + } + if reader == nil { + return errors.New("repository reader is nil") + } + if writer == nil { + return errors.New("repository writer is nil") + } + if input.Tap.Owner == "" || input.Tap.Name == "" { + return errors.New("tap repository is empty") + } + if input.Source.Owner == "" || input.Source.Name == "" { + return errors.New("source repository is empty") + } + if input.Commit == "" { + return errors.New("source commit is empty") + } + if input.Cask == "" { + return errors.New("cask token is empty") + } + if len(input.Content) == 0 { + return errors.New("cask content is empty") + } + + return nil +} + +// publish runs the ordered state machine after exported guards. +func publish( + ctx context.Context, + input PublishInput, + reader RepositoryReader, + writer RepositoryWriter, + sleep SleepFunc, +) (PublishResult, error) { + path := input.Cask.Path() + branch := publicationBranch(input.Cask, input.Version) + base, err := readBase(ctx, reader, input.Tap, path, sleep) + if err != nil { + return PublishResult{}, err + } + err = validateBase(base) + if err != nil { + return PublishResult{}, err + } + + pull, err := readPullRequest(ctx, reader, input.Tap, base.Branch, branch, sleep) + if err != nil { + return PublishResult{}, err + } + if base.File.Present && bytes.Equal(base.File.Content, input.Content) { + return result(input, branch, pull.URL, StatePublished), nil + } + err = rejectBaseConflict(base.File, input) + if err != nil { + return PublishResult{}, err + } + if pull.State == PullRequestMerged { + return PublishResult{}, fmt.Errorf( + "%w: merged pull request %s did not publish the expected cask", + ErrConflict, + pull.URL, + ) + } + if pull.State == PullRequestClosed { + return PublishResult{}, fmt.Errorf( + "%w: publication pull request %s is closed", + ErrConflict, + pull.URL, + ) + } + + observed, err := ensureBranch(ctx, input, reader, writer, sleep, base, branch, path) + if err != nil { + return PublishResult{}, err + } + if err := requireExactBranch(observed, input.Content, path, base.Commit); err != nil { + return PublishResult{}, err + } + if pull.State == PullRequestOpen { + return result(input, branch, pull.URL, StateOpen), nil + } + + return ensurePullRequest(ctx, input, reader, writer, sleep, base.Branch, branch) +} + +// readBase observes the default branch with bounded transient retries. +func readBase( + ctx context.Context, + reader RepositoryReader, + tap Repository, + path FilePath, + sleep SleepFunc, +) (BaseSnapshot, error) { + base, err := retryRead(ctx, sleep, func() (BaseSnapshot, error) { + return reader.ReadBase(ctx, tap, path) + }) + if err != nil { + return BaseSnapshot{}, fmt.Errorf("read tap base: %w", err) + } + + return base, nil +} + +// readBranch observes the publication branch with bounded transient retries. +func readBranch( + ctx context.Context, + reader RepositoryReader, + tap Repository, + branch BranchName, + path FilePath, + sleep SleepFunc, +) (BranchSnapshot, error) { + observed, err := retryRead(ctx, sleep, func() (BranchSnapshot, error) { + return reader.ReadBranch(ctx, tap, branch, path) + }) + if err != nil { + return BranchSnapshot{}, fmt.Errorf("read publication branch: %w", err) + } + + return observed, nil +} + +// readPullRequest observes the publication pull request with bounded retries. +func readPullRequest( + ctx context.Context, + reader RepositoryReader, + tap Repository, + base BranchName, + branch BranchName, + sleep SleepFunc, +) (PullRequest, error) { + pull, err := retryRead(ctx, sleep, func() (PullRequest, error) { + return reader.ReadPullRequest(ctx, tap, base, branch) + }) + if err != nil { + return PullRequest{}, fmt.Errorf("read publication pull request: %w", err) + } + + return pull, nil +} + +// validateBase rejects incomplete repository metadata. +func validateBase(base BaseSnapshot) error { + if base.Branch == "" { + return errors.New("tap default branch is empty") + } + if base.Commit == "" { + return errors.New("tap default branch commit is empty") + } + if base.File.Present && base.File.SHA == "" { + return errors.New("tap cask blob SHA is empty") + } + + return nil +} + +// rejectBaseConflict refuses equal or newer cask versions with different content. +func rejectBaseConflict(current File, input PublishInput) error { + if !current.Present { + return nil + } + version, err := caskVersion(current.Content) + if err != nil { + return fmt.Errorf("tap cask: %w", err) + } + comparison := version.Compare(input.Version) + if comparison == 0 { + return fmt.Errorf( + "%w: cask version %s exists with different content", + ErrConflict, + version, + ) + } + if comparison > 0 { + return fmt.Errorf( + "%w: tap cask version %s is newer than release %s", + ErrConflict, + version, + input.Version, + ) + } + + return nil +} + +// ensureBranch creates or converges the deterministic publication branch. +func ensureBranch( + ctx context.Context, + input PublishInput, + reader RepositoryReader, + writer RepositoryWriter, + sleep SleepFunc, + base BaseSnapshot, + branch BranchName, + path FilePath, +) (BranchSnapshot, error) { + observed, err := readBranch(ctx, reader, input.Tap, branch, path, sleep) + if err != nil { + return BranchSnapshot{}, err + } + if !observed.Present { + observed, err = createBranch(ctx, input.Tap, reader, writer, sleep, base, branch, path) + if err != nil { + return BranchSnapshot{}, err + } + } + if exactBranch(observed, input.Content, path, base.Commit) { + return observed, nil + } + if !emptyBranch(observed, base) { + return BranchSnapshot{}, unexpectedBranch(branch) + } + + return putCask(ctx, input, reader, writer, sleep, base, branch, path) +} + +// createBranch creates a missing branch and re-observes ambiguous outcomes. +func createBranch( + ctx context.Context, + tap Repository, + reader RepositoryReader, + writer RepositoryWriter, + sleep SleepFunc, + base BaseSnapshot, + branch BranchName, + path FilePath, +) (BranchSnapshot, error) { + for attempt := range retryAttempts { + err := writer.CreateBranch(ctx, tap, branch, base.Commit) + observed, readErr := readBranch(ctx, reader, tap, branch, path, sleep) + if readErr != nil { + return BranchSnapshot{}, readErr + } + if observed.Present { + return observed, nil + } + if err == nil { + return BranchSnapshot{}, errors.New("created publication branch is absent") + } + if !errors.Is(err, ErrRetryable) || attempt == retryAttempts-1 { + return BranchSnapshot{}, fmt.Errorf("create publication branch: %w", err) + } + if err := sleep(ctx, retryBaseDelay<= 'a' && character <= 'z' || character >= '0' && character <= '9' { + continue + } + if character == '-' && index > 0 && index < len(value)-1 { + continue + } + + return "", fmt.Errorf("cask token %q must use lowercase letters, digits, and interior hyphens", value) + } + + return CaskToken(value), nil +} + +// String returns the cask token text. +func (t CaskToken) String() string { + return string(t) +} + +// Path returns the only tap path the publisher may change. +func (t CaskToken) Path() FilePath { + return FilePath(caskPathPrefix + t.String() + caskPathSuffix) +} + +// BranchName is a validated publisher branch name. +type BranchName string + +// String returns the branch name text. +func (b BranchName) String() string { + return string(b) +} + +// CommitSHA identifies a Git commit. +type CommitSHA string + +// String returns the commit SHA text. +func (s CommitSHA) String() string { + return string(s) +} + +// BlobSHA identifies a Git blob. +type BlobSHA string + +// String returns the blob SHA text. +func (s BlobSHA) String() string { + return string(s) +} + +// FilePath is a repository-relative file path. +type FilePath string + +// String returns the repository-relative path. +func (p FilePath) String() string { + return string(p) +} + +// ChangeStatus describes how one commit changed a path. +type ChangeStatus string + +const ( + // ChangeAdded means the commit created a path. + ChangeAdded ChangeStatus = "added" + // ChangeModified means the commit replaced an existing path. + ChangeModified ChangeStatus = "modified" +) + +// File is one observed repository file. +type File struct { + // Present reports whether the path exists at the observed ref. + Present bool + // Content is the decoded file body when Present is true. + Content []byte + // SHA is the blob object ID when Present is true. + SHA BlobSHA +} + +// ChangedFile is one path changed by a branch-head commit. +type ChangedFile struct { + // Path is the repository-relative changed path. + Path FilePath + // Status is the GitHub change classification. + Status ChangeStatus +} + +// publicationBranch returns the deterministic branch for token and version. +func publicationBranch(token CaskToken, version rel.Version) BranchName { + return BranchName(publicationBranchPrefix + token.String() + "/v" + version.String()) +} + +// validRepositoryPart reports whether a GitHub owner or repository segment is +// safe to pass to API adapters. +func validRepositoryPart(value string) bool { + if value == "" || value == "." || value == ".." { + return false + } + for _, character := range value { + if character >= 'a' && character <= 'z' || + character >= 'A' && character <= 'Z' || + character >= '0' && character <= '9' || + character == '-' || character == '_' || character == '.' { + continue + } + + return false + } + + return true +} + +// caskVersion reads the unique literal version declaration from generated +// Homebrew Ruby source. +func caskVersion(content []byte) (rel.Version, error) { + var found string + for line := range bytes.SplitSeq(content, []byte{'\n'}) { + trimmed := strings.TrimSpace(string(line)) + value, ok := strings.CutPrefix(trimmed, "version ") + if !ok { + continue + } + value = strings.TrimSpace(value) + if len(value) < 2 || value[0] != '"' || value[len(value)-1] != '"' { + return rel.Version{}, errorsVersionDeclaration(value) + } + value = value[1 : len(value)-1] + if value == "" || strings.ContainsAny(value, "\"\\") { + return rel.Version{}, errorsVersionDeclaration(value) + } + if found != "" { + return rel.Version{}, errors.New("cask contains multiple version declarations") + } + found = value + } + if found == "" { + return rel.Version{}, errors.New("cask has no literal version declaration") + } + + version, err := rel.ParseVersion(found) + if err != nil { + return rel.Version{}, fmt.Errorf("cask version: %w", err) + } + + return version, nil +} + +// errorsVersionDeclaration returns the stable malformed-version diagnostic. +func errorsVersionDeclaration(value string) error { + return fmt.Errorf("cask version declaration %q is not a literal string", value) +}