From 807f2c1943178164c6b6ad2acf5aa06b96212308 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Fri, 21 Aug 2026 13:51:22 -0700 Subject: [PATCH] feat(cli): publish Scoop manifests through pull requests --- .goreleaser.yaml | 13 + .mockery.yml | 10 + cmd/release-cli/main.go | 8 + docs/reference/release-cli-contract.md | 79 ++- internal/adapter/ghbucket/client.go | 55 ++ internal/adapter/ghbucket/doc.go | 6 + internal/adapter/ghbucket/errors.go | 66 ++ internal/adapter/ghbucket/mocks/doc.go | 3 + .../ghbucket/mocks/repository_reader.go | 268 ++++++++ .../ghbucket/mocks/repository_writer.go | 268 ++++++++ internal/adapter/ghbucket/reader.go | 247 +++++++ internal/adapter/ghbucket/reader_test.go | 314 +++++++++ internal/adapter/ghbucket/writer.go | 115 ++++ internal/adapter/ghbucket/writer_test.go | 152 +++++ internal/cli/doc.go | 4 +- internal/cli/homebrew.go | 4 + internal/cli/oci.go | 1 + internal/cli/root.go | 9 + internal/cli/scoop.go | 264 ++++++++ internal/cli/scoop_test.go | 570 ++++++++++++++++ internal/stage/pubscoop/doc.go | 7 + internal/stage/pubscoop/errors.go | 13 + internal/stage/pubscoop/publish.go | 633 ++++++++++++++++++ internal/stage/pubscoop/publish_test.go | 568 ++++++++++++++++ internal/stage/pubscoop/retry.go | 53 ++ internal/stage/pubscoop/values.go | 197 ++++++ internal/stage/pubscoop/values_test.go | 85 +++ 27 files changed, 4006 insertions(+), 6 deletions(-) create mode 100644 internal/adapter/ghbucket/client.go create mode 100644 internal/adapter/ghbucket/doc.go create mode 100644 internal/adapter/ghbucket/errors.go create mode 100644 internal/adapter/ghbucket/mocks/doc.go create mode 100644 internal/adapter/ghbucket/mocks/repository_reader.go create mode 100644 internal/adapter/ghbucket/mocks/repository_writer.go create mode 100644 internal/adapter/ghbucket/reader.go create mode 100644 internal/adapter/ghbucket/reader_test.go create mode 100644 internal/adapter/ghbucket/writer.go create mode 100644 internal/adapter/ghbucket/writer_test.go create mode 100644 internal/cli/scoop.go create mode 100644 internal/cli/scoop_test.go create mode 100644 internal/stage/pubscoop/doc.go create mode 100644 internal/stage/pubscoop/errors.go create mode 100644 internal/stage/pubscoop/publish.go create mode 100644 internal/stage/pubscoop/publish_test.go create mode 100644 internal/stage/pubscoop/retry.go create mode 100644 internal/stage/pubscoop/values.go create mode 100644 internal/stage/pubscoop/values_test.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 9063752..c1ff4d2 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -90,6 +90,19 @@ homebrew_casks: template: "https://github.com/meigma/release/releases/download/{{ .Tag }}/{{ .ArtifactName }}" skip_upload: true +scoops: + - name: meigma-release-cli + ids: + - release-cli + repository: + owner: meigma + name: scoop-bucket + homepage: https://github.com/meigma/release + description: Release automation for Meigma projects + license: Proprietary + url_template: "https://github.com/meigma/release/releases/download/{{ .Tag }}/{{ .ArtifactName }}" + skip_upload: true + checksum: name_template: checksums.txt diff --git a/.mockery.yml b/.mockery.yml index 62676e2..64f27cf 100644 --- a/.mockery.yml +++ b/.mockery.yml @@ -25,6 +25,16 @@ packages: RepositoryWriter: config: filename: repository_writer.go + github.com/meigma/release/internal/stage/pubscoop: + config: + dir: internal/adapter/ghbucket/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 158b4a0..2dc5ae1 100644 --- a/cmd/release-cli/main.go +++ b/cmd/release-cli/main.go @@ -10,6 +10,7 @@ import ( "github.com/meigma/release/internal/adapter/apko" "github.com/meigma/release/internal/adapter/cosign" "github.com/meigma/release/internal/adapter/ghact" + "github.com/meigma/release/internal/adapter/ghbucket" "github.com/meigma/release/internal/adapter/ghrel" "github.com/meigma/release/internal/adapter/ghtap" "github.com/meigma/release/internal/adapter/ghup" @@ -22,6 +23,7 @@ import ( "github.com/meigma/release/internal/stage/pubbrew" "github.com/meigma/release/internal/stage/pubgh" "github.com/meigma/release/internal/stage/puboci" + "github.com/meigma/release/internal/stage/pubscoop" ) //nolint:gochecknoglobals // Linker-injected build metadata. @@ -96,6 +98,12 @@ func run() int { NewTapWriter: func(token rel.Secret, endpoint cli.GitHubEndpoint) (pubbrew.RepositoryWriter, error) { return ghtap.NewAuthenticated(token, endpoint.APIURL, endpoint.ServerURL) }, + NewBucketReader: func(token rel.Secret, endpoint cli.GitHubEndpoint) (pubscoop.RepositoryReader, error) { + return ghbucket.NewAuthenticated(token, endpoint.APIURL, endpoint.ServerURL) + }, + NewBucketWriter: func(token rel.Secret, endpoint cli.GitHubEndpoint) (pubscoop.RepositoryWriter, error) { + return ghbucket.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 6e74c0e..1538f21 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, initializes cask-only Homebrew taps, opens protected tap pull requests, publishes verified GitHub Releases, and performs two-phase digest-addressed OCI publication. The [GitHub Release contract](github-release-contract.md) defines the workflow inputs, artifacts, and publication behavior that surround the CLI. +`release-cli` builds and validates Go release data, reports machine-readable results, builds and verifies OCI layouts from staged binaries, initializes cask-only Homebrew taps, opens protected tap and Scoop bucket 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 @@ -15,11 +15,12 @@ | `release-cli publish github --dist PATH [--no-undraft] [--json]` | Reconcile a verified bundle with its matching GitHub Release and optionally publish the draft. | | `release-cli init homebrew-tap --tap OWNER/HOMEBREW-NAME --output DIR [--json]` | Write a cask-only tap scaffold into a new or empty local directory. | | `release-cli publish homebrew --dist PATH --tap OWNER/REPOSITORY --cask TOKEN [--json]` | Reconcile a generated cask through a protected Homebrew tap pull request. | +| `release-cli publish scoop --dist PATH --bucket OWNER/REPOSITORY --manifest NAME [--json]` | Reconcile a generated Scoop manifest through a protected bucket pull request. | | `release-cli verify bundle --dist PATH --identity URL [--issuer URL] [--json]` | Verify a closed release bundle and its detached Sigstore signature. | | `release-cli verify handoff --artifact-id --digest [--json]` | Verify an Actions artifact's GitHub API metadata before download. | | `release-cli version [--json]` | Report the CLI version, source commit, and protocol integer. | -`stage`, `verify bundle`, `publish github`, and `publish homebrew` require a distribution path. `init homebrew-tap` requires `--tap` and `--output`; the repository name must use `homebrew-`. The initializer also requires a released CLI whose build metadata contains a full source commit. The only accepted profile is `go`. `verify bundle` also requires an exact certificate identity. `verify handoff` requires artifact ID and digest values. Supply handoff values with `--artifact-id` and `--digest`, or with `RELEASE_ARTIFACT_ID` and `RELEASE_DIGEST`. An explicitly set flag takes precedence over its environment variable. +`stage`, `verify bundle`, `publish github`, `publish homebrew`, and `publish scoop` require a distribution path. `init homebrew-tap` requires `--tap` and `--output`; the repository name must use `homebrew-`. The initializer also requires a released CLI whose build metadata contains a full source commit. The only accepted profile is `go`. `verify bundle` also requires an exact certificate identity. `verify handoff` requires artifact ID and digest values. Supply handoff values with `--artifact-id` and `--digest`, or with `RELEASE_ARTIFACT_ID` and `RELEASE_DIGEST`. An explicitly set flag takes precedence over its environment variable. Boolean `RELEASE_*` environment variables must contain a value accepted by Go's `strconv.ParseBool`: `1`, `t`, `T`, `TRUE`, `true`, `True`, `0`, `f`, `F`, `FALSE`, `false`, or `False`. Any other value is invalid configuration and exits with code `2`. @@ -36,7 +37,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`, `init homebrew-tap`, `plan tags`, `publish github`, `publish homebrew`, `publish oci prepare`, `publish oci finalize`, `stage`, `verify bundle`, `verify handoff`, or `version`. | +| `command` | The command path, such as `image build`, `image verify`, `init homebrew-tap`, `plan tags`, `publish github`, `publish homebrew`, `publish scoop`, `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. | @@ -253,6 +254,33 @@ For example, a new tap publication writes this envelope: } ``` +For `publish scoop --json`, `command` is exactly `publish scoop`. The `result` object contains these fields: + +| Field | JSON type | Value | +| --- | --- | --- | +| `bucket` | string | Target bucket in `owner/repository` form. | +| `manifest` | string | Published manifest name. | +| `branch` | string | Deterministic publication branch in `release//v` form. | +| `pull_request_url` | string | Matching pull request URL. This can be empty when matching manifest 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 bucket publication writes this envelope: + +```json +{ + "schema": "release.dev/result/v1", + "command": "publish scoop", + "ok": true, + "result": { + "bucket": "owner/scoop-bucket", + "manifest": "example", + "branch": "release/example/v1.2.3", + "pull_request_url": "https://github.com/owner/scoop-bucket/pull/42", + "state": "created" + } +} +``` + For `plan tags --json`, `command` is exactly `plan tags`. The `result` object contains these fields: | Field | JSON type | Value | @@ -417,7 +445,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 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. +Without `--json`, a successful `image build`, `image verify`, `plan tags`, `publish github`, `publish homebrew`, `publish scoop`, `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 @@ -805,6 +833,49 @@ The workflow fails before staging when any enabled credential is absent. GoRelea When signing is disabled, the workflow does not require Apple credentials. Existing external callers therefore preserve their credential-free release path. Producers that enable signing must add a guarded `notarize.macos` block to `.goreleaser.yaml`; a workflow input alone cannot add signing policy to a producer's GoReleaser configuration. +## Scoop manifest publication + +`release-cli publish scoop` reads the manifest generated by GoReleaser and reconciles it through a bucket pull request. The command never writes the bucket'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 bucket | `--bucket` | None. | None. Use `owner/repository` form. | +| Manifest name | `--manifest` | 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 `scoop/.json` 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 generated JSON must parse and contain exactly a string `version` value equal to `GITHUB_REF_NAME` after removal of its leading `v`. Other JSON fields remain allowed; the publisher does not rewrite content. The repository write path remains `.json` at the bucket root. + +The producer's `.goreleaser.yaml` declares the `meigma-release-cli` Scoop manifest for `meigma/scoop-bucket`, selects the `release-cli` archive ID, uses the GitHub release asset URL template, and sets `skip_upload: true`. GoReleaser therefore writes `dist/scoop/meigma-release-cli.json` for the reviewed publisher without pushing directly to the bucket. + +Publication enforces these guarantees in order: + +1. Read the bucket's default branch, head commit, and current manifest. +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 manifest 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 `.json`, classifies that path as added or modified, and contains the exact generated bytes. The command refuses every other branch state. +7. Commit the generated manifest to an unchanged new branch. The update uses the observed blob SHA when the manifest 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 manifest. 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 bucket request. A missing, malformed, empty, non-regular, or oversized generated manifest exits with code `1` before a bucket 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/ghbucket/client.go b/internal/adapter/ghbucket/client.go new file mode 100644 index 0000000..0928cf5 --- /dev/null +++ b/internal/adapter/ghbucket/client.go @@ -0,0 +1,55 @@ +package ghbucket + +import ( + "context" + "errors" + "fmt" + + "github.com/google/go-github/v82/github" + + "github.com/meigma/release/internal/rel" +) + +// Client reads and mutates one GitHub bucket 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/ghbucket/doc.go b/internal/adapter/ghbucket/doc.go new file mode 100644 index 0000000..974477b --- /dev/null +++ b/internal/adapter/ghbucket/doc.go @@ -0,0 +1,6 @@ +// Package ghbucket implements Scoop bucket repository reads and writes with the +// GitHub REST API. +// +// The adapter maps remote metadata into pubscoop snapshots. Publication policy, +// reconciliation, and retry decisions remain in the pubscoop stage package. +package ghbucket diff --git a/internal/adapter/ghbucket/errors.go b/internal/adapter/ghbucket/errors.go new file mode 100644 index 0000000..f9d39b1 --- /dev/null +++ b/internal/adapter/ghbucket/errors.go @@ -0,0 +1,66 @@ +package ghbucket + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/google/go-github/v82/github" + + "github.com/meigma/release/internal/stage/pubscoop" +) + +// 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", pubscoop.ErrRetryable) + } + var abuse *github.AbuseRateLimitError + if errors.As(err, &abuse) { + return fmt.Errorf("%w: secondary rate limited", pubscoop.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", pubscoop.ErrConflict, resource, code) + case code == http.StatusTooManyRequests || code >= http.StatusInternalServerError: + return fmt.Errorf("%w: %s status %d", pubscoop.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/ghbucket/mocks/doc.go b/internal/adapter/ghbucket/mocks/doc.go new file mode 100644 index 0000000..9072b74 --- /dev/null +++ b/internal/adapter/ghbucket/mocks/doc.go @@ -0,0 +1,3 @@ +// Package mocks contains generated test doubles for Scoop bucket repository +// ports. +package mocks diff --git a/internal/adapter/ghbucket/mocks/repository_reader.go b/internal/adapter/ghbucket/mocks/repository_reader.go new file mode 100644 index 0000000..0d29765 --- /dev/null +++ b/internal/adapter/ghbucket/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/pubscoop" +) + +// 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 pubscoop.Repository, path pubscoop.FilePath) (pubscoop.BaseSnapshot, error) { + ret := _mock.Called(ctx, repository, path) + + if len(ret) == 0 { + panic("no return value specified for ReadBase") + } + + var r0 pubscoop.BaseSnapshot + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, pubscoop.Repository, pubscoop.FilePath) (pubscoop.BaseSnapshot, error)); ok { + return returnFunc(ctx, repository, path) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, pubscoop.Repository, pubscoop.FilePath) pubscoop.BaseSnapshot); ok { + r0 = returnFunc(ctx, repository, path) + } else { + r0 = ret.Get(0).(pubscoop.BaseSnapshot) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, pubscoop.Repository, pubscoop.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 pubscoop.Repository +// - path pubscoop.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 pubscoop.Repository, path pubscoop.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 pubscoop.Repository + if args[1] != nil { + arg1 = args[1].(pubscoop.Repository) + } + var arg2 pubscoop.FilePath + if args[2] != nil { + arg2 = args[2].(pubscoop.FilePath) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *MockRepositoryReader_ReadBase_Call) Return(baseSnapshot pubscoop.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 pubscoop.Repository, path pubscoop.FilePath) (pubscoop.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 pubscoop.Repository, branch pubscoop.BranchName, path pubscoop.FilePath) (pubscoop.BranchSnapshot, error) { + ret := _mock.Called(ctx, repository, branch, path) + + if len(ret) == 0 { + panic("no return value specified for ReadBranch") + } + + var r0 pubscoop.BranchSnapshot + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, pubscoop.Repository, pubscoop.BranchName, pubscoop.FilePath) (pubscoop.BranchSnapshot, error)); ok { + return returnFunc(ctx, repository, branch, path) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, pubscoop.Repository, pubscoop.BranchName, pubscoop.FilePath) pubscoop.BranchSnapshot); ok { + r0 = returnFunc(ctx, repository, branch, path) + } else { + r0 = ret.Get(0).(pubscoop.BranchSnapshot) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, pubscoop.Repository, pubscoop.BranchName, pubscoop.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 pubscoop.Repository +// - branch pubscoop.BranchName +// - path pubscoop.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 pubscoop.Repository, branch pubscoop.BranchName, path pubscoop.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 pubscoop.Repository + if args[1] != nil { + arg1 = args[1].(pubscoop.Repository) + } + var arg2 pubscoop.BranchName + if args[2] != nil { + arg2 = args[2].(pubscoop.BranchName) + } + var arg3 pubscoop.FilePath + if args[3] != nil { + arg3 = args[3].(pubscoop.FilePath) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockRepositoryReader_ReadBranch_Call) Return(branchSnapshot pubscoop.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 pubscoop.Repository, branch pubscoop.BranchName, path pubscoop.FilePath) (pubscoop.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 pubscoop.Repository, base pubscoop.BranchName, head pubscoop.BranchName) (pubscoop.PullRequest, error) { + ret := _mock.Called(ctx, repository, base, head) + + if len(ret) == 0 { + panic("no return value specified for ReadPullRequest") + } + + var r0 pubscoop.PullRequest + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, pubscoop.Repository, pubscoop.BranchName, pubscoop.BranchName) (pubscoop.PullRequest, error)); ok { + return returnFunc(ctx, repository, base, head) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, pubscoop.Repository, pubscoop.BranchName, pubscoop.BranchName) pubscoop.PullRequest); ok { + r0 = returnFunc(ctx, repository, base, head) + } else { + r0 = ret.Get(0).(pubscoop.PullRequest) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, pubscoop.Repository, pubscoop.BranchName, pubscoop.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 pubscoop.Repository +// - base pubscoop.BranchName +// - head pubscoop.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 pubscoop.Repository, base pubscoop.BranchName, head pubscoop.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 pubscoop.Repository + if args[1] != nil { + arg1 = args[1].(pubscoop.Repository) + } + var arg2 pubscoop.BranchName + if args[2] != nil { + arg2 = args[2].(pubscoop.BranchName) + } + var arg3 pubscoop.BranchName + if args[3] != nil { + arg3 = args[3].(pubscoop.BranchName) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *MockRepositoryReader_ReadPullRequest_Call) Return(pullRequest pubscoop.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 pubscoop.Repository, base pubscoop.BranchName, head pubscoop.BranchName) (pubscoop.PullRequest, error)) *MockRepositoryReader_ReadPullRequest_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/adapter/ghbucket/mocks/repository_writer.go b/internal/adapter/ghbucket/mocks/repository_writer.go new file mode 100644 index 0000000..0f26016 --- /dev/null +++ b/internal/adapter/ghbucket/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/pubscoop" +) + +// 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 pubscoop.Repository, branch pubscoop.BranchName, from pubscoop.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, pubscoop.Repository, pubscoop.BranchName, pubscoop.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 pubscoop.Repository +// - branch pubscoop.BranchName +// - from pubscoop.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 pubscoop.Repository, branch pubscoop.BranchName, from pubscoop.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 pubscoop.Repository + if args[1] != nil { + arg1 = args[1].(pubscoop.Repository) + } + var arg2 pubscoop.BranchName + if args[2] != nil { + arg2 = args[2].(pubscoop.BranchName) + } + var arg3 pubscoop.CommitSHA + if args[3] != nil { + arg3 = args[3].(pubscoop.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 pubscoop.Repository, branch pubscoop.BranchName, from pubscoop.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 pubscoop.Repository, input pubscoop.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, pubscoop.Repository, pubscoop.PullRequestInput) (string, error)); ok { + return returnFunc(ctx, repository, input) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, pubscoop.Repository, pubscoop.PullRequestInput) string); ok { + r0 = returnFunc(ctx, repository, input) + } else { + r0 = ret.Get(0).(string) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, pubscoop.Repository, pubscoop.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 pubscoop.Repository +// - input pubscoop.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 pubscoop.Repository, input pubscoop.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 pubscoop.Repository + if args[1] != nil { + arg1 = args[1].(pubscoop.Repository) + } + var arg2 pubscoop.PullRequestInput + if args[2] != nil { + arg2 = args[2].(pubscoop.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 pubscoop.Repository, input pubscoop.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 pubscoop.Repository, branch pubscoop.BranchName, path pubscoop.FilePath, previous pubscoop.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, pubscoop.Repository, pubscoop.BranchName, pubscoop.FilePath, pubscoop.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 pubscoop.Repository +// - branch pubscoop.BranchName +// - path pubscoop.FilePath +// - previous pubscoop.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 pubscoop.Repository, branch pubscoop.BranchName, path pubscoop.FilePath, previous pubscoop.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 pubscoop.Repository + if args[1] != nil { + arg1 = args[1].(pubscoop.Repository) + } + var arg2 pubscoop.BranchName + if args[2] != nil { + arg2 = args[2].(pubscoop.BranchName) + } + var arg3 pubscoop.FilePath + if args[3] != nil { + arg3 = args[3].(pubscoop.FilePath) + } + var arg4 pubscoop.BlobSHA + if args[4] != nil { + arg4 = args[4].(pubscoop.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 pubscoop.Repository, branch pubscoop.BranchName, path pubscoop.FilePath, previous pubscoop.BlobSHA, content []byte, message string) error) *MockRepositoryWriter_PutFile_Call { + _c.Call.Return(run) + return _c +} diff --git a/internal/adapter/ghbucket/reader.go b/internal/adapter/ghbucket/reader.go new file mode 100644 index 0000000..590f9b2 --- /dev/null +++ b/internal/adapter/ghbucket/reader.go @@ -0,0 +1,247 @@ +package ghbucket + +import ( + "context" + "errors" + "fmt" + + "github.com/google/go-github/v82/github" + + "github.com/meigma/release/internal/stage/pubscoop" +) + +const ( + // pullRequestPageSize is GitHub's maximum pull-request list page size. + pullRequestPageSize = 100 +) + +// ReadBase implements [pubscoop.RepositoryReader]. +func (c *Client) ReadBase( + ctx context.Context, + repository pubscoop.Repository, + path pubscoop.FilePath, +) (pubscoop.BaseSnapshot, error) { + if err := c.requireReady(ctx); err != nil { + return pubscoop.BaseSnapshot{}, err + } + + remote, _, err := c.github.Repositories.Get(ctx, repository.Owner, repository.Name) + if err != nil { + return pubscoop.BaseSnapshot{}, classify(err, "bucket repository") + } + branch := remote.GetDefaultBranch() + if branch == "" { + return pubscoop.BaseSnapshot{}, errors.New("bucket default branch is empty") + } + commit, err := c.refCommit(ctx, repository, pubscoop.BranchName(branch)) + if err != nil { + return pubscoop.BaseSnapshot{}, err + } + file, err := c.readFile(ctx, repository, commit, path) + if err != nil { + return pubscoop.BaseSnapshot{}, err + } + + return pubscoop.BaseSnapshot{ + Branch: pubscoop.BranchName(branch), + Commit: commit, + File: file, + }, nil +} + +// ReadBranch implements [pubscoop.RepositoryReader]. +func (c *Client) ReadBranch( + ctx context.Context, + repository pubscoop.Repository, + branch pubscoop.BranchName, + path pubscoop.FilePath, +) (pubscoop.BranchSnapshot, error) { + if err := c.requireReady(ctx); err != nil { + return pubscoop.BranchSnapshot{}, err + } + + commit, err := c.refCommit(ctx, repository, branch) + if err != nil { + if isNotFound(err) { + return pubscoop.BranchSnapshot{}, nil + } + + return pubscoop.BranchSnapshot{}, err + } + remote, _, err := c.github.Repositories.GetCommit( + ctx, + repository.Owner, + repository.Name, + commit.String(), + nil, + ) + if err != nil { + return pubscoop.BranchSnapshot{}, classify(err, "publication branch commit") + } + file, err := c.readFile(ctx, repository, commit, path) + if err != nil { + return pubscoop.BranchSnapshot{}, err + } + + return pubscoop.BranchSnapshot{ + Present: true, + Commit: commit, + Parent: soleParent(remote), + Files: changedFiles(remote.Files), + File: file, + }, nil +} + +// ReadPullRequest implements [pubscoop.RepositoryReader]. +func (c *Client) ReadPullRequest( + ctx context.Context, + repository pubscoop.Repository, + base pubscoop.BranchName, + head pubscoop.BranchName, +) (pubscoop.PullRequest, error) { + if err := c.requireReady(ctx); err != nil { + return pubscoop.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 pubscoop.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 pubscoop.PullRequest{State: pubscoop.PullRequestAbsent}, nil + case 1: + return mapPullRequest(matched[0]) + default: + return pubscoop.PullRequest{}, fmt.Errorf( + "%w: multiple pull requests use branch %s", + pubscoop.ErrConflict, + head, + ) + } +} + +// refCommit resolves one branch without updating it. +func (c *Client) refCommit( + ctx context.Context, + repository pubscoop.Repository, + branch pubscoop.BranchName, +) (pubscoop.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 pubscoop.CommitSHA(sha), nil +} + +// readFile returns path at ref or a successful absent snapshot. +func (c *Client) readFile( + ctx context.Context, + repository pubscoop.Repository, + ref pubscoop.CommitSHA, + path pubscoop.FilePath, +) (pubscoop.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 pubscoop.File{}, nil + } + + return pubscoop.File{}, classify(err, "repository manifest") + } + if file == nil || len(directory) != 0 || file.GetType() != "file" { + return pubscoop.File{}, errors.New("repository manifest is not a regular file") + } + content, err := file.GetContent() + if err != nil { + return pubscoop.File{}, errors.New("repository manifest content is malformed") + } + if file.GetSHA() == "" { + return pubscoop.File{}, errors.New("repository manifest blob SHA is empty") + } + + return pubscoop.File{ + Present: true, + Content: []byte(content), + SHA: pubscoop.BlobSHA(file.GetSHA()), + }, nil +} + +// soleParent returns the parent only when the commit has exactly one. +func soleParent(commit *github.RepositoryCommit) pubscoop.CommitSHA { + if commit == nil || len(commit.Parents) != 1 { + return "" + } + + return pubscoop.CommitSHA(commit.Parents[0].GetSHA()) +} + +// changedFiles maps commit paths without interpreting publication policy. +func changedFiles(files []*github.CommitFile) []pubscoop.ChangedFile { + changed := make([]pubscoop.ChangedFile, 0, len(files)) + for _, file := range files { + changed = append(changed, pubscoop.ChangedFile{ + Path: pubscoop.FilePath(file.GetFilename()), + Status: pubscoop.ChangeStatus(file.GetStatus()), + }) + } + + return changed +} + +// mapPullRequest maps GitHub state onto the closed publication lifecycle. +func mapPullRequest(pull *github.PullRequest) (pubscoop.PullRequest, error) { + if pull == nil || pull.GetHTMLURL() == "" { + return pubscoop.PullRequest{}, errors.New("publication pull request URL is empty") + } + state := pubscoop.PullRequestClosed + if pull.GetState() == "open" { + state = pubscoop.PullRequestOpen + } else if pull.MergedAt != nil { + state = pubscoop.PullRequestMerged + } + + return pubscoop.PullRequest{State: state, URL: pull.GetHTMLURL()}, nil +} diff --git a/internal/adapter/ghbucket/reader_test.go b/internal/adapter/ghbucket/reader_test.go new file mode 100644 index 0000000..4c5e7d4 --- /dev/null +++ b/internal/adapter/ghbucket/reader_test.go @@ -0,0 +1,314 @@ +package ghbucket_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/ghbucket" + "github.com/meigma/release/internal/rel" + "github.com/meigma/release/internal/stage/pubscoop" +) + +const ( + // testToken is a credential marker that must never appear in errors. + testToken = "ghs_scoop_adapter_secret" + // testBaseSHA is the bucket default-branch commit. + testBaseSHA = "1111111111111111111111111111111111111111" + // testHeadSHA is the publication branch commit. + testHeadSHA = "2222222222222222222222222222222222222222" + // testBlobSHA is the manifest blob commit. + testBlobSHA = "3333333333333333333333333333333333333333" + // testPullURL is the publication review URL. + testPullURL = "https://github.com/meigma/scoop-bucket/pull/7" +) + +// TestClientSatisfiesPorts proves the focused adapter implements both bucket ports. +func TestClientSatisfiesPorts(t *testing.T) { + t.Parallel() + + var ( + _ pubscoop.RepositoryReader = (*ghbucket.Client)(nil) + _ pubscoop.RepositoryWriter = (*ghbucket.Client)(nil) + ) +} + +// TestReadBaseReturnsDefaultBranchAndManifest proves the base snapshot is bound +// to one immutable commit rather than racing against a moving branch ref. +func TestReadBaseReturnsDefaultBranchAndManifest(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/scoop-bucket": + assert.NoError(t, json.NewEncoder(writer).Encode(map[string]any{"default_branch": "main"})) + case "/repos/meigma/scoop-bucket/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/scoop-bucket/contents/release-cli.json": + 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": "eyJ2ZXJzaW9uIjoiMS4yLjIifQ==", + })) + 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, pubscoop.BranchName("main"), got.Branch) + assert.Equal(t, pubscoop.CommitSHA(testBaseSHA), got.Commit) + assert.JSONEq(t, `{"version":"1.2.2"}`, string(got.File.Content)) + assert.Equal(t, pubscoop.BlobSHA(testBlobSHA), got.File.SHA) +} + +// TestReadBranchMapsOneCommit proves branch reconciliation receives the exact +// head commit parent, changed paths, and decoded manifest. +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/scoop-bucket/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/scoop-bucket/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": "release-cli.json", "status": "modified"}}, + })) + case "/repos/meigma/scoop-bucket/contents/release-cli.json": + 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": "eyJ2ZXJzaW9uIjoiMS4yLjMifQ==", + })) + 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, pubscoop.CommitSHA(testHeadSHA), got.Commit) + assert.Equal(t, pubscoop.CommitSHA(testBaseSHA), got.Parent) + require.Len(t, got.Files, 1) + assert.Equal(t, testPath(), got.Files[0].Path) + assert.Equal(t, pubscoop.ChangeModified, got.Files[0].Status) + assert.JSONEq(t, `{"version":"1.2.3"}`, string(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/scoop-bucket/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, pubscoop.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, pubscoop.ErrRetryable) + assert.NotContains(t, err.Error(), testToken) + assert.NotContains(t, err.Error(), server.URL) +} + +// TestReadBaseClassifiesAuthenticationFailure proves unauthorized responses +// stay fail-closed without leaking credentials. +func TestReadBaseClassifiesAuthenticationFailure(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusUnauthorized) + _, _ = writer.Write([]byte(`{"message":"Bad credentials"}`)) + })) + t.Cleanup(server.Close) + + _, err := newClient(t, server).ReadBase(context.Background(), mustRepository(t), testPath()) + require.Error(t, err) + assert.Contains(t, err.Error(), "github authentication failed") + assert.NotContains(t, err.Error(), testToken) + assert.NotContains(t, err.Error(), "Bearer") + assert.NotContains(t, err.Error(), server.URL) +} + +// TestReadBaseCanceledErrorOmitsURL proves cancellation stays typed and never +// includes the request URL or token. +func TestReadBaseCanceledErrorOmitsURL(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + writer.WriteHeader(http.StatusOK) + })) + t.Cleanup(server.Close) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err := newClient(t, server).ReadBase(ctx, mustRepository(t), testPath()) + require.Error(t, err) + require.ErrorIs(t, err, context.Canceled) + assert.Contains(t, err.Error(), "request canceled") + assert.NotContains(t, err.Error(), server.URL) + assert.NotContains(t, err.Error(), testToken) +} + +// TestReadBaseRejectsNilClient proves an uninitialized adapter fails closed. +func TestReadBaseRejectsNilClient(t *testing.T) { + t.Parallel() + + client := ghbucket.New(nil) + _, err := client.ReadBase(context.Background(), mustRepository(t), testPath()) + require.Error(t, err) + assert.Contains(t, err.Error(), "github client is nil") +} + +// TestReadBaseRejectsNilContext proves a missing context fails before I/O. +func TestReadBaseRejectsNilContext(t *testing.T) { + t.Parallel() + + var ctx context.Context + client := ghbucket.New(github.NewClient(nil)) + _, err := client.ReadBase(ctx, mustRepository(t), testPath()) + require.Error(t, err) + assert.Contains(t, err.Error(), "context is nil") +} + +// TestNewAuthenticatedUsesCustomAPIBase proves enterprise and stub endpoints +// receive the token on the go-github v3 path. +func TestNewAuthenticatedUsesCustomAPIBase(t *testing.T) { + t.Parallel() + + hit := false + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + hit = true + assert.Contains(t, request.URL.Path, "/api/v3/repos/meigma/scoop-bucket") + assert.Equal(t, "Bearer "+testToken, request.Header.Get("Authorization")) + assert.NoError(t, json.NewEncoder(writer).Encode(map[string]any{"default_branch": "main"})) + })) + t.Cleanup(server.Close) + + client, err := ghbucket.NewAuthenticated(rel.NewSecret(testToken), server.URL, server.URL) + require.NoError(t, err) + _, err = client.ReadBase(context.Background(), mustRepository(t), testPath()) + require.Error(t, err) + assert.True(t, hit) +} + +// TestNewAuthenticatedEmptyAPIURLUsesPublicHost proves an empty endpoint keeps +// the public GitHub client. +func TestNewAuthenticatedEmptyAPIURLUsesPublicHost(t *testing.T) { + t.Parallel() + + client, err := ghbucket.NewAuthenticated(rel.NewSecret(testToken), "", "") + require.NoError(t, err) + require.NotNil(t, client) +} + +// newClient returns an authenticated go-github client pointed at server. +func newClient(t *testing.T, server *httptest.Server) *ghbucket.Client { + t.Helper() + + parsed, err := url.Parse(server.URL + "/") + require.NoError(t, err) + client := github.NewClient(server.Client()).WithAuthToken(testToken) + client.BaseURL = parsed + + return ghbucket.New(client) +} + +// mustRepository parses the bucket fixture or fails the test. +func mustRepository(t *testing.T) pubscoop.Repository { + t.Helper() + + repository, err := pubscoop.ParseRepository("meigma/scoop-bucket") + require.NoError(t, err) + + return repository +} + +// testPath returns the publisher's only changed path. +func testPath() pubscoop.FilePath { + return "release-cli.json" +} diff --git a/internal/adapter/ghbucket/writer.go b/internal/adapter/ghbucket/writer.go new file mode 100644 index 0000000..5b6acd9 --- /dev/null +++ b/internal/adapter/ghbucket/writer.go @@ -0,0 +1,115 @@ +package ghbucket + +import ( + "context" + "errors" + + "github.com/google/go-github/v82/github" + + "github.com/meigma/release/internal/stage/pubscoop" +) + +// CreateBranch implements [pubscoop.RepositoryWriter]. +func (c *Client) CreateBranch( + ctx context.Context, + repository pubscoop.Repository, + branch pubscoop.BranchName, + from pubscoop.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 [pubscoop.RepositoryWriter]. +func (c *Client) PutFile( + ctx context.Context, + repository pubscoop.Repository, + branch pubscoop.BranchName, + path pubscoop.FilePath, + previous pubscoop.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 manifest commit") + } + + return nil +} + +// CreatePullRequest implements [pubscoop.RepositoryWriter]. +func (c *Client) CreatePullRequest( + ctx context.Context, + repository pubscoop.Repository, + input pubscoop.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/ghbucket/writer_test.go b/internal/adapter/ghbucket/writer_test.go new file mode 100644 index 0000000..38a4c86 --- /dev/null +++ b/internal/adapter/ghbucket/writer_test.go @@ -0,0 +1,152 @@ +package ghbucket_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/pubscoop" +) + +// 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/scoop-bucket/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) +} + +// TestPutFileUpdatesOnlyExpectedManifest proves an existing manifest is +// replaced on the publication branch with the observed base blob SHA. +func TestPutFileUpdatesOnlyExpectedManifest(t *testing.T) { + t.Parallel() + + content := []byte("{\n \"version\": \"1.2.3\"\n}\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/scoop-bucket/contents/release-cli.json", request.URL.Path) + var payload struct { + // Message is the commit subject. + Message string `json:"message"` + // Content is the API-encoded manifest 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(manifest): 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(manifest): 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/scoop-bucket/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), + pubscoop.PullRequestInput{ + Base: "main", + Head: "release/release-cli/v1.2.3", + Title: "chore(manifest): 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, pubscoop.ErrConflict) + assert.NotContains(t, err.Error(), testToken) +} diff --git a/internal/cli/doc.go b/internal/cli/doc.go index 771551a..6fea229 100644 --- a/internal/cli/doc.go +++ b/internal/cli/doc.go @@ -2,8 +2,8 @@ // // NewRootCommand builds a fresh command with injected streams and an optional // [LookupEnv] seam. The tree exposes stage, plan tags, publish oci prepare, -// publish oci finalize, publish github, verify bundle, verify handoff, and -// version. Flags override RELEASE_* environment variables via +// publish oci finalize, publish github, publish homebrew, publish scoop, +// verify bundle, verify handoff, and version. Flags override RELEASE_* // [cobra.Flag.Changed]; there is no config file. ExitCode maps errors onto // the process contract: 0 success, 1 a release-contract, verification, or // command failure, 2 usage or configuration error. diff --git a/internal/cli/homebrew.go b/internal/cli/homebrew.go index 7438df6..48edd68 100644 --- a/internal/cli/homebrew.go +++ b/internal/cli/homebrew.go @@ -68,6 +68,8 @@ type homebrewConfig struct { // runHomebrew validates configuration, opens the generated cask through a // confined distribution root, and reconciles the tap pull request. +// +//nolint:dupl // Homebrew policy intentionally remains isolated from Scoop policy. func runHomebrew(cmd *cobra.Command, options Options) error { expected, err := resolveHomebrew(cmd, options) if err != nil { @@ -193,6 +195,8 @@ func requiredCommandFlag(cmd *cobra.Command, name string) (string, error) { } // readGeneratedCask reads exactly homebrew/Casks/.rb through an [os.Root]. +// +//nolint:dupl // Channel-specific paths and diagnostics stay explicit. func readGeneratedCask(dist string, token pubbrew.CaskToken) ([]byte, error) { root, err := os.OpenRoot(dist) if err != nil { diff --git a/internal/cli/oci.go b/internal/cli/oci.go index b8a8e94..8f85ae6 100644 --- a/internal/cli/oci.go +++ b/internal/cli/oci.go @@ -59,6 +59,7 @@ func newPublishCommand(options Options) *cobra.Command { } cmd.AddCommand(newOCICommand(options)) cmd.AddCommand(newHomebrewCommand(options)) + cmd.AddCommand(newScoopCommand(options)) return cmd } diff --git a/internal/cli/root.go b/internal/cli/root.go index d613df7..2792140 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -17,6 +17,7 @@ import ( "github.com/meigma/release/internal/stage/pubbrew" "github.com/meigma/release/internal/stage/pubgh" "github.com/meigma/release/internal/stage/puboci" + "github.com/meigma/release/internal/stage/pubscoop" ) const ( @@ -208,6 +209,14 @@ type Options struct { 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) + // BucketReader, when set, is the Scoop bucket read port. Tests inject it. + BucketReader pubscoop.RepositoryReader + // NewBucketReader constructs the bucket read port from a token and API endpoint. + NewBucketReader func(token rel.Secret, endpoint GitHubEndpoint) (pubscoop.RepositoryReader, error) + // BucketWriter, when set, is the Scoop bucket mutation port. Tests inject it. + BucketWriter pubscoop.RepositoryWriter + // NewBucketWriter constructs the bucket mutation port from a token and API endpoint. + NewBucketWriter func(token rel.Secret, endpoint GitHubEndpoint) (pubscoop.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/cli/scoop.go b/internal/cli/scoop.go new file mode 100644 index 0000000..aa62e61 --- /dev/null +++ b/internal/cli/scoop.go @@ -0,0 +1,264 @@ +package cli + +import ( + "errors" + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + + "github.com/meigma/release/internal/rel" + "github.com/meigma/release/internal/stage/pubgh" + "github.com/meigma/release/internal/stage/pubscoop" +) + +const ( + // commandScoop is the envelope command path for publish scoop. + commandScoop = "publish scoop" + // flagBucket is the Scoop bucket owner/repository flag name. + flagBucket = "bucket" + // flagManifest is the expected Scoop manifest name flag name. + flagManifest = "manifest" + // generatedManifestDirectory is GoReleaser's Scoop output directory under dist. + generatedManifestDirectory = "scoop/" + // maxGeneratedManifestBytes bounds the generated JSON read into memory. + maxGeneratedManifestBytes int64 = 1 << 20 + // maxGeneratedManifestReadBytes distinguishes an exact-size file from an + // oversized file. + maxGeneratedManifestReadBytes = maxGeneratedManifestBytes + 1 +) + +// newScoopCommand constructs the publish scoop verb. +func newScoopCommand(options Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "scoop", + Short: "Open a protected bucket pull request for a generated manifest", + Args: usageNoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runScoop(cmd, options) + }, + } + cmd.Flags().String(flagDist, "", "path to the authoritative release artifact directory") + cmd.Flags().String(flagBucket, "", "target Scoop bucket as owner/repository") + cmd.Flags().String(flagManifest, "", "expected Scoop manifest name") + + return cmd +} + +// scoopConfig is the resolved publish-scoop configuration. +type scoopConfig struct { + // Dist is the authoritative release artifact directory. + Dist string + // Bucket is the target Scoop bucket. + Bucket pubscoop.Repository + // Source is the repository that owns the release. + Source pubscoop.Repository + // Version is the stable source release version. + Version rel.Version + // Commit is the source commit that built the release. + Commit pubscoop.CommitSHA + // Manifest is the expected generated manifest name. + Manifest pubscoop.ManifestName + // Token is the GitHub App installation token. + Token rel.Secret + // Endpoint is the GitHub API location. + Endpoint GitHubEndpoint +} + +// runScoop validates configuration, opens the generated manifest through a +// confined distribution root, and reconciles the bucket pull request. +// +//nolint:dupl // Scoop policy intentionally remains isolated from Homebrew policy. +func runScoop(cmd *cobra.Command, options Options) error { + expected, err := resolveScoop(cmd, options) + if err != nil { + return writeCommandResult(options, commandScoop, nil, UsageError(err)) + } + content, err := readGeneratedManifest(expected.Dist, expected.Manifest) + if err != nil { + return writeCommandResult(options, commandScoop, nil, err) + } + reader, err := bucketReader(options, expected) + if err != nil { + return writeCommandResult(options, commandScoop, nil, err) + } + writer, err := bucketWriter(options, expected) + if err != nil { + return writeCommandResult(options, commandScoop, nil, err) + } + + result, err := pubscoop.Publish(cmd.Context(), pubscoop.PublishInput{ + Bucket: expected.Bucket, + Source: expected.Source, + Version: expected.Version, + Commit: expected.Commit, + Manifest: expected.Manifest, + Content: content, + }, reader, writer) + if err != nil { + return writeCommandResult(options, commandScoop, nil, err) + } + if options.settings == nil || !options.settings.JSON { + return nil + } + + return writeCommandResult(options, commandScoop, result, nil) +} + +// resolveScoop parses flags and Actions environment without performing I/O. +func resolveScoop(cmd *cobra.Command, options Options) (scoopConfig, error) { + settings := Settings{} + if options.settings != nil { + settings = *options.settings + } + if err := settings.err; err != nil { + return scoopConfig{}, err + } + if settings.Dist == "" { + return scoopConfig{}, fmt.Errorf("--%s is required", flagDist) + } + + bucketRaw, err := requiredCommandFlag(cmd, flagBucket) + if err != nil { + return scoopConfig{}, err + } + bucket, err := pubscoop.ParseRepository(bucketRaw) + if err != nil { + return scoopConfig{}, fmt.Errorf("--%s: %w", flagBucket, err) + } + manifestRaw, err := requiredCommandFlag(cmd, flagManifest) + if err != nil { + return scoopConfig{}, err + } + manifest, err := pubscoop.ParseManifestName(manifestRaw) + if err != nil { + return scoopConfig{}, fmt.Errorf("--%s: %w", flagManifest, err) + } + + sourceRaw, err := requiredEnv(options.LookupEnv, envRepository) + if err != nil { + return scoopConfig{}, err + } + source, err := pubscoop.ParseRepository(sourceRaw) + if err != nil { + return scoopConfig{}, fmt.Errorf("%s: %w", envRepository, err) + } + versionRaw, err := deriveVersion(options.LookupEnv) + if err != nil { + return scoopConfig{}, err + } + version, err := rel.ParseVersion(versionRaw) + if err != nil { + return scoopConfig{}, fmt.Errorf("%s: %w", envRefName, err) + } + commitRaw, err := requiredEnv(options.LookupEnv, envCommitSHA) + if err != nil { + return scoopConfig{}, err + } + commit, err := pubgh.ParseCommitSHA(commitRaw) + if err != nil { + return scoopConfig{}, err + } + tokenRaw, err := requiredEnv(options.LookupEnv, envAppToken) + if err != nil { + return scoopConfig{}, err + } + endpoint, err := resolveGitHubEndpoint(options.LookupEnv) + if err != nil { + return scoopConfig{}, err + } + + return scoopConfig{ + Dist: settings.Dist, + Bucket: bucket, + Source: source, + Version: version, + Commit: pubscoop.CommitSHA(commit.String()), + Manifest: manifest, + Token: rel.NewSecret(tokenRaw), + Endpoint: endpoint, + }, nil +} + +// readGeneratedManifest reads exactly scoop/.json through an [os.Root]. +// +//nolint:dupl // Channel-specific paths and diagnostics stay explicit. +func readGeneratedManifest(dist string, name pubscoop.ManifestName) ([]byte, error) { + root, err := os.OpenRoot(dist) + if err != nil { + return nil, fmt.Errorf("open dist %s: %w", dist, err) + } + defer root.Close() + + path := generatedManifestDirectory + name.Path().String() + file, err := root.Open(path) + if err != nil { + return nil, fmt.Errorf("open generated manifest %s: %w", path, err) + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, fmt.Errorf("stat generated manifest %s: %w", path, err) + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("generated manifest %s is not a regular file", path) + } + if info.Size() == 0 { + return nil, fmt.Errorf("generated manifest %s is empty", path) + } + if info.Size() > maxGeneratedManifestBytes { + return nil, fmt.Errorf("generated manifest %s exceeds %d bytes", path, maxGeneratedManifestBytes) + } + + content, err := io.ReadAll(io.LimitReader(file, maxGeneratedManifestReadBytes)) + if err != nil { + return nil, fmt.Errorf("read generated manifest %s: %w", path, err) + } + if int64(len(content)) > maxGeneratedManifestBytes { + return nil, fmt.Errorf("generated manifest %s exceeds %d bytes", path, maxGeneratedManifestBytes) + } + if len(content) == 0 { + return nil, fmt.Errorf("generated manifest %s is empty", path) + } + + return content, nil +} + +// bucketReader returns the injected read port or constructs one. +func bucketReader(options Options, expected scoopConfig) (pubscoop.RepositoryReader, error) { + if options.BucketReader != nil { + return options.BucketReader, nil + } + if options.NewBucketReader == nil { + return nil, errors.New("bucket repository reader is not configured") + } + reader, err := options.NewBucketReader(expected.Token, expected.Endpoint) + if err != nil { + return nil, fmt.Errorf("construct bucket repository reader: %w", err) + } + if reader == nil { + return nil, errors.New("bucket repository reader is nil") + } + + return reader, nil +} + +// bucketWriter returns the injected write port or constructs one. +func bucketWriter(options Options, expected scoopConfig) (pubscoop.RepositoryWriter, error) { + if options.BucketWriter != nil { + return options.BucketWriter, nil + } + if options.NewBucketWriter == nil { + return nil, errors.New("bucket repository writer is not configured") + } + writer, err := options.NewBucketWriter(expected.Token, expected.Endpoint) + if err != nil { + return nil, fmt.Errorf("construct bucket repository writer: %w", err) + } + if writer == nil { + return nil, errors.New("bucket repository writer is nil") + } + + return writer, nil +} diff --git a/internal/cli/scoop_test.go b/internal/cli/scoop_test.go new file mode 100644 index 0000000..001e5df --- /dev/null +++ b/internal/cli/scoop_test.go @@ -0,0 +1,570 @@ +package cli_test + +import ( + "context" + "encoding/json" + "errors" + "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/ghbucket/mocks" + "github.com/meigma/release/internal/cli" + "github.com/meigma/release/internal/rel" + "github.com/meigma/release/internal/stage/pubscoop" +) + +const ( + // scoopCommit is the source release commit fixture. + scoopCommit = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + // scoopToken is the secret fixture that must never enter output. + scoopToken = "ghs_scoop_command_secret" + // scoopCommand is the envelope command path for publish scoop. + scoopCommand = "publish scoop" +) + +// TestPublishScoopHelpDocumentsRequiredFlags proves the public command contract. +func TestPublishScoopHelpDocumentsRequiredFlags(t *testing.T) { + t.Parallel() + + fixture := newScoopCLI(t) + err := fixture.execute("publish", "scoop", "--help") + require.NoError(t, err) + help := fixture.stdout.String() + fixture.stderr.String() + assert.Contains(t, help, "--dist") + assert.Contains(t, help, "--bucket") + assert.Contains(t, help, "--manifest") + assert.NotContains(t, help, "--token") + assert.NotContains(t, help, scoopToken) +} + +// TestPublishScoopConfigErrorsAreUsage proves missing flags and environment +// fail before a repository port is constructed. +func TestPublishScoopConfigErrorsAreUsage(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + env func(*scoopCLI) + args []string + want string + }{ + { + name: "missing dist", + args: []string{ + "--json", + "publish", + "scoop", + "--bucket", + "meigma/scoop-bucket", + "--manifest", + "release-cli", + }, + want: "--dist is required", + }, + { + name: "missing bucket", + args: []string{"--json", "publish", "scoop", "--dist", "", "--manifest", "release-cli"}, + want: "--bucket is required", + }, + { + name: "missing manifest", + args: []string{"--json", "publish", "scoop", "--dist", "", "--bucket", "meigma/scoop-bucket"}, + want: "--manifest is required", + }, + { + name: "missing token", + env: func(fixture *scoopCLI) { + fixture.environment["RELEASE_APP_TOKEN"] = "" + }, + args: []string{ + "--json", + "publish", + "scoop", + "--dist", + "", + "--bucket", + "meigma/scoop-bucket", + "--manifest", + "release-cli", + }, + want: "RELEASE_APP_TOKEN is required", + }, + { + name: "missing repository", + env: func(fixture *scoopCLI) { + delete(fixture.environment, "GITHUB_REPOSITORY") + }, + args: []string{ + "--json", + "publish", + "scoop", + "--dist", + "", + "--bucket", + "meigma/scoop-bucket", + "--manifest", + "release-cli", + }, + want: "GITHUB_REPOSITORY is required", + }, + { + name: "missing ref name", + env: func(fixture *scoopCLI) { + delete(fixture.environment, "GITHUB_REF_NAME") + }, + args: []string{ + "--json", + "publish", + "scoop", + "--dist", + "", + "--bucket", + "meigma/scoop-bucket", + "--manifest", + "release-cli", + }, + want: "--version is required when GITHUB_REF_NAME is unset", + }, + { + name: "missing sha", + env: func(fixture *scoopCLI) { + delete(fixture.environment, "GITHUB_SHA") + }, + args: []string{ + "--json", + "publish", + "scoop", + "--dist", + "", + "--bucket", + "meigma/scoop-bucket", + "--manifest", + "release-cli", + }, + want: "GITHUB_SHA is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fixture := newScoopCLI(t) + if tt.env != nil { + tt.env(fixture) + } + args := append([]string{}, tt.args...) + for index, argument := range args { + if argument == "--dist" && index+1 < len(args) && args[index+1] == "" { + args[index+1] = fixture.dist + } + } + constructed := false + fixture.options.NewBucketReader = func(rel.Secret, cli.GitHubEndpoint) (pubscoop.RepositoryReader, error) { + constructed = true + return fixture.reader, nil + } + + err := fixture.execute(args...) + require.Error(t, err) + assert.Equal(t, 2, cli.ExitCode(err)) + assert.False(t, constructed) + assert.Contains(t, err.Error(), tt.want) + assertScoopFailureEnvelope(t, fixture.stdout.String(), tt.want) + assert.NotContains(t, fixture.stdout.String(), scoopToken) + assert.NotContains(t, fixture.stderr.String(), scoopToken) + assert.NotContains(t, err.Error(), scoopToken) + }) + } +} + +// TestPublishScoopTokenReachesFactoriesAsSecret proves the App token never +// appears in output and is delivered only through the authenticated factories. +func TestPublishScoopTokenReachesFactoriesAsSecret(t *testing.T) { + t.Parallel() + + fixture := newScoopCLI(t) + var readerToken rel.Secret + var writerToken rel.Secret + var readerEndpoint cli.GitHubEndpoint + var writerEndpoint cli.GitHubEndpoint + fixture.options.NewBucketReader = func(token rel.Secret, endpoint cli.GitHubEndpoint) (pubscoop.RepositoryReader, error) { + readerToken = token + readerEndpoint = endpoint + return fixture.reader, nil + } + fixture.options.NewBucketWriter = func(token rel.Secret, endpoint cli.GitHubEndpoint) (pubscoop.RepositoryWriter, error) { + writerToken = token + writerEndpoint = endpoint + return fixture.writer, nil + } + expectPublishedScoop(fixture) + + err := fixture.execute( + "--json", + "publish", + "scoop", + "--dist", + fixture.dist, + "--bucket", + fixture.bucket.String(), + "--manifest", + "release-cli", + ) + require.NoError(t, err) + assert.Equal(t, scoopToken, readerToken.Reveal()) + assert.Equal(t, scoopToken, writerToken.Reveal()) + assert.Equal(t, "[REDACTED]", readerToken.String()) + assert.Equal(t, "[REDACTED]", writerToken.String()) + assert.Empty(t, readerEndpoint.APIURL) + assert.Empty(t, writerEndpoint.APIURL) + assert.NotContains(t, fixture.stdout.String(), scoopToken) + assert.NotContains(t, fixture.stderr.String(), scoopToken) +} + +// TestPublishScoopRefusesEscapingSymlink proves the generated manifest is +// opened through the confined distribution root. +func TestPublishScoopRefusesEscapingSymlink(t *testing.T) { + t.Parallel() + + fixture := newScoopCLI(t) + outside := filepath.Join(t.TempDir(), "outside.json") + require.NoError(t, os.WriteFile(outside, fixture.content, 0o600)) + require.NoError(t, os.Remove(filepath.Join(fixture.dist, "scoop", "release-cli.json"))) + require.NoError(t, os.Symlink(outside, filepath.Join(fixture.dist, "scoop", "release-cli.json"))) + constructed := false + fixture.options.NewBucketReader = func(rel.Secret, cli.GitHubEndpoint) (pubscoop.RepositoryReader, error) { + constructed = true + return fixture.reader, nil + } + + err := fixture.execute( + "publish", + "scoop", + "--dist", + fixture.dist, + "--bucket", + fixture.bucket.String(), + "--manifest", + "release-cli", + ) + require.Error(t, err) + assert.Equal(t, 1, cli.ExitCode(err)) + assert.False(t, constructed) + assert.Contains(t, err.Error(), "open generated manifest") + assert.NotContains(t, err.Error(), string(fixture.content)) + assert.NotContains(t, err.Error(), scoopToken) +} + +// TestPublishScoopRejectsNonRegularEmptyAndOversizedManifests proves local +// file bounds fail closed before a bucket request. +func TestPublishScoopRejectsNonRegularEmptyAndOversizedManifests(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + prepare func(t *testing.T, fixture *scoopCLI) + want string + }{ + { + name: "directory", + prepare: func(t *testing.T, fixture *scoopCLI) { + t.Helper() + path := filepath.Join(fixture.dist, "scoop", "release-cli.json") + require.NoError(t, os.Remove(path)) + require.NoError(t, os.Mkdir(path, 0o755)) + }, + want: "generated manifest scoop/release-cli.json is not a regular file", + }, + { + name: "empty", + prepare: func(t *testing.T, fixture *scoopCLI) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(fixture.dist, "scoop", "release-cli.json"), nil, 0o600)) + }, + want: "generated manifest scoop/release-cli.json is empty", + }, + { + name: "oversized", + prepare: func(t *testing.T, fixture *scoopCLI) { + t.Helper() + oversized := make([]byte, (1<<20)+1) + require.NoError( + t, + os.WriteFile(filepath.Join(fixture.dist, "scoop", "release-cli.json"), oversized, 0o600), + ) + }, + want: "generated manifest scoop/release-cli.json exceeds 1048576 bytes", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fixture := newScoopCLI(t) + tt.prepare(t, fixture) + constructed := false + fixture.options.NewBucketReader = func(rel.Secret, cli.GitHubEndpoint) (pubscoop.RepositoryReader, error) { + constructed = true + return fixture.reader, nil + } + + err := fixture.execute( + "--json", + "publish", + "scoop", + "--dist", + fixture.dist, + "--bucket", + fixture.bucket.String(), + "--manifest", + "release-cli", + ) + require.Error(t, err) + assert.Equal(t, 1, cli.ExitCode(err)) + assert.False(t, constructed) + assert.Contains(t, err.Error(), tt.want) + assertScoopFailureEnvelope(t, fixture.stdout.String(), tt.want) + assert.NotContains(t, fixture.stdout.String(), scoopToken) + }) + } +} + +// TestPublishScoopEmitsPublishedEnvelope proves command resolution, confined +// manifest loading, secret factory delivery, and the stable JSON result contract. +func TestPublishScoopEmitsPublishedEnvelope(t *testing.T) { + t.Parallel() + + fixture := newScoopCLI(t) + var readerToken rel.Secret + var writerToken rel.Secret + fixture.options.NewBucketReader = func(token rel.Secret, _ cli.GitHubEndpoint) (pubscoop.RepositoryReader, error) { + readerToken = token + return fixture.reader, nil + } + fixture.options.NewBucketWriter = func(token rel.Secret, _ cli.GitHubEndpoint) (pubscoop.RepositoryWriter, error) { + writerToken = token + return fixture.writer, nil + } + expectPublishedScoop(fixture) + + err := fixture.execute( + "--json", + "publish", + "scoop", + "--dist", + fixture.dist, + "--bucket", + fixture.bucket.String(), + "--manifest", + "release-cli", + ) + require.NoError(t, err) + assert.Equal(t, scoopToken, readerToken.Reveal()) + assert.Equal(t, scoopToken, writerToken.Reveal()) + assert.Empty(t, fixture.stderr.String()) + assert.NotContains(t, fixture.stdout.String(), scoopToken) + assert.Equal(t, 1, countJSONDocuments(fixture.stdout.String())) + + result := decodeScoopResult(t, fixture.stdout.String()) + assert.Equal(t, fixture.bucket.String(), result.Bucket) + assert.Equal(t, "release-cli", result.Manifest) + assert.Equal(t, "release/release-cli/v1.2.3", result.Branch) + assert.Equal(t, pubscoop.StatePublished, result.State) +} + +// TestPublishScoopSilentSuccessWithoutJSON proves success writes no envelope +// unless --json is requested. +func TestPublishScoopSilentSuccessWithoutJSON(t *testing.T) { + t.Parallel() + + fixture := newScoopCLI(t) + fixture.options.BucketReader = fixture.reader + fixture.options.BucketWriter = fixture.writer + expectPublishedScoop(fixture) + + err := fixture.execute( + "publish", + "scoop", + "--dist", + fixture.dist, + "--bucket", + fixture.bucket.String(), + "--manifest", + "release-cli", + ) + require.NoError(t, err) + assert.Empty(t, fixture.stdout.String()) + assert.Empty(t, fixture.stderr.String()) +} + +// TestPublishScoopPropagatesDomainError proves a domain failure is a command +// failure and still emits one error envelope. +func TestPublishScoopPropagatesDomainError(t *testing.T) { + t.Parallel() + + fixture := newScoopCLI(t) + fixture.options.BucketReader = fixture.reader + fixture.options.BucketWriter = fixture.writer + fixture.reader.EXPECT().ReadBase(mock.Anything, fixture.bucket, fixture.path). + Return(pubscoop.BaseSnapshot{}, errors.New("bucket default branch is empty")). + Once() + + err := fixture.execute( + "--json", + "publish", + "scoop", + "--dist", + fixture.dist, + "--bucket", + fixture.bucket.String(), + "--manifest", + "release-cli", + ) + require.Error(t, err) + assert.Equal(t, 1, cli.ExitCode(err)) + assert.Contains(t, err.Error(), "bucket default branch is empty") + assertScoopFailureEnvelope(t, fixture.stdout.String(), "bucket default branch is empty") + assert.NotContains(t, fixture.stdout.String(), scoopToken) + assert.NotContains(t, fixture.stderr.String(), scoopToken) +} + +// scoopCLI holds one isolated command fixture. +type scoopCLI struct { + // dist contains the generated Scoop manifest. + dist string + // content is the expected manifest JSON. + content []byte + // bucket is the parsed target repository. + bucket pubscoop.Repository + // path is the expected bucket write path. + path pubscoop.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 bucket read mock. + reader *mocks.MockRepositoryReader + // writer is the generated bucket write mock. + writer *mocks.MockRepositoryWriter + // stdout receives machine-readable output. + stdout *strings.Builder + // stderr receives diagnostics. + stderr *strings.Builder +} + +// newScoopCLI constructs one valid command fixture. +func newScoopCLI(t *testing.T) *scoopCLI { + t.Helper() + + dist := t.TempDir() + directory := filepath.Join(dist, "scoop") + require.NoError(t, os.MkdirAll(directory, 0o755)) + content := []byte("{\n \"version\": \"1.2.3\",\n \"url\": \"https://example.invalid/release-cli.zip\"\n}\n") + require.NoError(t, os.WriteFile(filepath.Join(directory, "release-cli.json"), content, 0o600)) + bucket, err := pubscoop.ParseRepository("meigma/scoop-bucket") + require.NoError(t, err) + stdout := &strings.Builder{} + stderr := &strings.Builder{} + environment := map[string]string{ + "RELEASE_APP_TOKEN": scoopToken, + "GITHUB_REPOSITORY": "meigma/release", + "GITHUB_REF_NAME": "v1.2.3", + "GITHUB_SHA": scoopCommit, + } + + fixture := &scoopCLI{ + dist: dist, + content: content, + bucket: bucket, + path: "release-cli.json", + 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 *scoopCLI) execute(arguments ...string) error { + command := cli.NewRootCommand(fixture.options) + command.SetArgs(arguments) + return command.ExecuteContext(context.Background()) +} + +// expectPublishedScoop stubs a matching default-branch manifest. +func expectPublishedScoop(fixture *scoopCLI) { + fixture.reader.EXPECT().ReadBase(mock.Anything, fixture.bucket, fixture.path).Return(pubscoop.BaseSnapshot{ + Branch: "main", + Commit: "1111111111111111111111111111111111111111", + File: pubscoop.File{ + Present: true, + Content: fixture.content, + SHA: "2222222222222222222222222222222222222222", + }, + }, nil).Once() + fixture.reader.EXPECT().ReadPullRequest( + mock.Anything, + fixture.bucket, + pubscoop.BranchName("main"), + pubscoop.BranchName("release/release-cli/v1.2.3"), + ).Return(pubscoop.PullRequest{State: pubscoop.PullRequestAbsent}, nil).Once() +} + +// decodeScoopResult unmarshals the envelope result as [pubscoop.PublishResult]. +func decodeScoopResult(t *testing.T, stdout string) pubscoop.PublishResult { + t.Helper() + + var envelope cli.Envelope + require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(stdout)), &envelope)) + assert.Equal(t, cli.Schema, envelope.Schema) + assert.Equal(t, scoopCommand, envelope.Command) + assert.True(t, envelope.OK) + raw, err := json.Marshal(envelope.Result) + require.NoError(t, err) + var result pubscoop.PublishResult + require.NoError(t, json.Unmarshal(raw, &result)) + + return result +} + +// assertScoopFailureEnvelope checks stdout is one ok:false publish-scoop envelope. +func assertScoopFailureEnvelope(t *testing.T, stdout, wantError string) { + t.Helper() + assert.Equal(t, 1, countJSONDocuments(stdout)) + + var envelope cli.Envelope + require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(stdout)), &envelope)) + assert.Equal(t, cli.Schema, envelope.Schema) + assert.Equal(t, scoopCommand, envelope.Command) + assert.False(t, envelope.OK) + assert.NotContains(t, stdout, scoopToken) + + if wantError == "" { + return + } + + raw, err := json.Marshal(envelope.Result) + require.NoError(t, err) + var result cli.ErrorResult + require.NoError(t, json.Unmarshal(raw, &result)) + assert.Contains(t, result.Error, wantError) +} diff --git a/internal/stage/pubscoop/doc.go b/internal/stage/pubscoop/doc.go new file mode 100644 index 0000000..6b985a2 --- /dev/null +++ b/internal/stage/pubscoop/doc.go @@ -0,0 +1,7 @@ +// Package pubscoop reconciles one generated Scoop manifest into a protected +// bucket 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 pubscoop diff --git a/internal/stage/pubscoop/errors.go b/internal/stage/pubscoop/errors.go new file mode 100644 index 0000000..3bee4f9 --- /dev/null +++ b/internal/stage/pubscoop/errors.go @@ -0,0 +1,13 @@ +package pubscoop + +import "errors" + +// Publication sentinel errors. +var ( + // ErrConflict reports remote state that the publisher cannot safely + // overwrite or reconcile. + ErrConflict = errors.New("scoop publication conflict") + // ErrRetryable reports a transient repository failure that may succeed on + // a bounded retry. + ErrRetryable = errors.New("retryable scoop repository failure") +) diff --git a/internal/stage/pubscoop/publish.go b/internal/stage/pubscoop/publish.go new file mode 100644 index 0000000..d64ae10 --- /dev/null +++ b/internal/stage/pubscoop/publish.go @@ -0,0 +1,633 @@ +package pubscoop + +import ( + "bytes" + "context" + "errors" + "fmt" + + "github.com/meigma/release/internal/rel" +) + +// PublicationState is the reconciled bucket 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 bucket default branch already contains the + // manifest. + 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 bucket default branch and manifest observed together. +type BaseSnapshot struct { + // Branch is the bucket's current default branch. + Branch BranchName + // Commit is the default branch head commit. + Commit CommitSHA + // File is the manifest at Commit. + File File +} + +// BranchSnapshot is the publication branch head and manifest. +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 manifest 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 bucket 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 bucket branches, manifests, 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 { + // Bucket is the Scoop bucket repository to update. + Bucket 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 + // Manifest is the expected manifest name and filename stem. + Manifest ManifestName + // Content is the generated Scoop manifest JSON. + 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 { + // Bucket is the target owner/repository. + Bucket string `json:"bucket"` + // Manifest is the published manifest name. + Manifest string `json:"manifest"` + // 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 manifest through a bucket 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 := manifestVersion(input.Content) + if err != nil { + return PublishResult{}, fmt.Errorf("generated manifest: %w", err) + } + if desiredVersion != input.Version { + return PublishResult{}, fmt.Errorf( + "generated manifest 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.Bucket.Owner == "" || input.Bucket.Name == "" { + return errors.New("bucket 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.Manifest == "" { + return errors.New("manifest name is empty") + } + if len(input.Content) == 0 { + return errors.New("manifest 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.Manifest.Path() + branch := publicationBranch(input.Manifest, input.Version) + base, err := readBase(ctx, reader, input.Bucket, path, sleep) + if err != nil { + return PublishResult{}, err + } + err = validateBase(base) + if err != nil { + return PublishResult{}, err + } + + pull, err := readPullRequest(ctx, reader, input.Bucket, 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 manifest", + 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, + bucket Repository, + path FilePath, + sleep SleepFunc, +) (BaseSnapshot, error) { + base, err := retryRead(ctx, sleep, func() (BaseSnapshot, error) { + return reader.ReadBase(ctx, bucket, path) + }) + if err != nil { + return BaseSnapshot{}, fmt.Errorf("read bucket base: %w", err) + } + + return base, nil +} + +// readBranch observes the publication branch with bounded transient retries. +func readBranch( + ctx context.Context, + reader RepositoryReader, + bucket Repository, + branch BranchName, + path FilePath, + sleep SleepFunc, +) (BranchSnapshot, error) { + observed, err := retryRead(ctx, sleep, func() (BranchSnapshot, error) { + return reader.ReadBranch(ctx, bucket, 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, + bucket Repository, + base BranchName, + branch BranchName, + sleep SleepFunc, +) (PullRequest, error) { + pull, err := retryRead(ctx, sleep, func() (PullRequest, error) { + return reader.ReadPullRequest(ctx, bucket, 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("bucket default branch is empty") + } + if base.Commit == "" { + return errors.New("bucket default branch commit is empty") + } + if base.File.Present && base.File.SHA == "" { + return errors.New("bucket manifest blob SHA is empty") + } + + return nil +} + +// rejectBaseConflict refuses equal or newer manifest versions with different +// content. +func rejectBaseConflict(current File, input PublishInput) error { + if !current.Present { + return nil + } + version, err := manifestVersion(current.Content) + if err != nil { + return fmt.Errorf("bucket manifest: %w", err) + } + comparison := version.Compare(input.Version) + if comparison == 0 { + return fmt.Errorf( + "%w: manifest version %s exists with different content", + ErrConflict, + version, + ) + } + if comparison > 0 { + return fmt.Errorf( + "%w: bucket manifest 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.Bucket, branch, path, sleep) + if err != nil { + return BranchSnapshot{}, err + } + if !observed.Present { + observed, err = createBranch(ctx, input.Bucket, 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 putManifest(ctx, input, reader, writer, sleep, base, branch, path) +} + +// createBranch creates a missing branch and re-observes ambiguous outcomes. +func createBranch( + ctx context.Context, + bucket Repository, + reader RepositoryReader, + writer RepositoryWriter, + sleep SleepFunc, + base BaseSnapshot, + branch BranchName, + path FilePath, +) (BranchSnapshot, error) { + for attempt := range retryAttempts { + err := writer.CreateBranch(ctx, bucket, branch, base.Commit) + observed, readErr := readBranch(ctx, reader, bucket, 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("manifest name %q must use lowercase letters, digits, and interior hyphens", value) + } + + return ManifestName(value), nil +} + +// String returns the manifest name text. +func (n ManifestName) String() string { + return string(n) +} + +// Path returns the only bucket path the publisher may change. +func (n ManifestName) Path() FilePath { + return FilePath(n.String() + manifestPathSuffix) +} + +// 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 name and version. +func publicationBranch(name ManifestName, version rel.Version) BranchName { + return BranchName(publicationBranchPrefix + name.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 +} + +// manifestVersion reads the unique string version field from generated Scoop +// JSON. +func manifestVersion(content []byte) (rel.Version, error) { + var payload map[string]json.RawMessage + if err := json.Unmarshal(content, &payload); err != nil { + return rel.Version{}, fmt.Errorf("manifest JSON is malformed: %w", err) + } + raw, ok := payload["version"] + if !ok { + return rel.Version{}, errorsVersionMissing() + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return rel.Version{}, errorsVersionDeclaration(string(raw)) + } + if value == "" { + return rel.Version{}, errorsVersionDeclaration(value) + } + + version, err := rel.ParseVersion(value) + if err != nil { + return rel.Version{}, fmt.Errorf("manifest version: %w", err) + } + + return version, nil +} + +// errorsVersionMissing returns the stable absent-version diagnostic. +func errorsVersionMissing() error { + return errors.New("manifest has no version") +} + +// errorsVersionDeclaration returns the stable malformed-version diagnostic. +func errorsVersionDeclaration(value string) error { + return fmt.Errorf("manifest version %q is not a string", value) +} diff --git a/internal/stage/pubscoop/values_test.go b/internal/stage/pubscoop/values_test.go new file mode 100644 index 0000000..336b418 --- /dev/null +++ b/internal/stage/pubscoop/values_test.go @@ -0,0 +1,85 @@ +package pubscoop_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/release/internal/stage/pubscoop" +) + +// TestParseManifestNameAcceptsSafeNames proves the filename stem stays +// confined to lowercase letters, digits, and interior hyphens. +func TestParseManifestNameAcceptsSafeNames(t *testing.T) { + t.Parallel() + + tests := []struct { + // name identifies the case. + name string + // value is the candidate manifest name. + value string + }{ + {name: "single letter", value: "a"}, + {name: "single digit", value: "1"}, + {name: "hyphenated token", value: "release-cli"}, + {name: "interior hyphens", value: "rel-ease-cli"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := pubscoop.ParseManifestName(tt.value) + require.NoError(t, err) + assert.Equal(t, tt.value, got.String()) + assert.Equal(t, tt.value+".json", got.Path().String()) + }) + } +} + +// TestParseManifestNameRejectsUnsafeNames proves empty, decorated, and +// uppercase names never become a writable path. +func TestParseManifestNameRejectsUnsafeNames(t *testing.T) { + t.Parallel() + + tests := []struct { + // name identifies the case. + name string + // value is the rejected manifest name. + value string + }{ + {name: "empty", value: ""}, + {name: "uppercase", value: "Release-CLI"}, + {name: "leading hyphen", value: "-release"}, + {name: "trailing hyphen", value: "release-"}, + {name: "underscore", value: "release_cli"}, + {name: "dot", value: "release.cli"}, + {name: "slash", value: "release/cli"}, + {name: "space", value: "release cli"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := pubscoop.ParseManifestName(tt.value) + require.Error(t, err) + if tt.value == "" { + assert.Contains(t, err.Error(), "is empty") + return + } + assert.Contains(t, err.Error(), "lowercase letters, digits, and interior hyphens") + }) + } +} + +// TestParseRepositoryRejectsInvalidOwnerName proves the bucket coordinate +// stays in owner/name form. +func TestParseRepositoryRejectsInvalidOwnerName(t *testing.T) { + t.Parallel() + + _, err := pubscoop.ParseRepository("not-a-repo") + require.Error(t, err) + assert.Contains(t, err.Error(), "must be owner/name") +}