Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .mockery.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Interfaces live in the consumer package (I2). Generated mocks live under
# the implementing adapter's mocks/ package (T3), not next to the interface.
# Add an interface here only when its slice lands. The port budget is closed
# at 13; do not invent a fourteenth.
# Add an interface here only when its implementation slice lands.
all: false
dir: '{{.InterfaceDir}}'
filename: '{{ .InterfaceName | snakecase }}.go'
Expand All @@ -16,6 +15,16 @@ pkgname: mocks
recursive: false
template: testify
packages:
github.com/meigma/release/internal/stage/pubbrew:
config:
dir: internal/adapter/ghtap/mocks
interfaces:
RepositoryReader:
config:
filename: repository_reader.go
RepositoryWriter:
config:
filename: repository_writer.go
github.com/meigma/release/internal/stage/pubgh:
config:
dir: internal/adapter/ghact/mocks
Expand Down
8 changes: 8 additions & 0 deletions cmd/release-cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ import (
"github.com/meigma/release/internal/adapter/cosign"
"github.com/meigma/release/internal/adapter/ghact"
"github.com/meigma/release/internal/adapter/ghrel"
"github.com/meigma/release/internal/adapter/ghtap"
"github.com/meigma/release/internal/adapter/ghup"
"github.com/meigma/release/internal/adapter/gitx"
"github.com/meigma/release/internal/adapter/melange"
"github.com/meigma/release/internal/adapter/reg"
"github.com/meigma/release/internal/cli"
"github.com/meigma/release/internal/rel"
"github.com/meigma/release/internal/stage/image"
"github.com/meigma/release/internal/stage/pubbrew"
"github.com/meigma/release/internal/stage/pubgh"
"github.com/meigma/release/internal/stage/puboci"
)
Expand Down Expand Up @@ -88,6 +90,12 @@ func run() int {
Stderr: os.Stderr,
}), nil
},
NewTapReader: func(token rel.Secret, endpoint cli.GitHubEndpoint) (pubbrew.RepositoryReader, error) {
return ghtap.NewAuthenticated(token, endpoint.APIURL, endpoint.ServerURL)
},
NewTapWriter: func(token rel.Secret, endpoint cli.GitHubEndpoint) (pubbrew.RepositoryWriter, error) {
return ghtap.NewAuthenticated(token, endpoint.APIURL, endpoint.ServerURL)
},
NewAPKBuilder: func(path string) (image.APKBuilder, error) {
return melange.New(melange.Options{
Path: path,
Expand Down
77 changes: 73 additions & 4 deletions docs/reference/release-cli-contract.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# `release-cli` contract reference

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

## Commands

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

`stage`, `verify bundle`, and `publish github` require a distribution path. The only accepted profile is `go`. `verify bundle` also requires an exact certificate identity. `verify handoff` requires artifact ID and digest values. Supply handoff values with `--artifact-id` and `--digest`, or with `RELEASE_ARTIFACT_ID` and `RELEASE_DIGEST`. An explicitly set flag takes precedence over its environment variable.
`stage`, `verify bundle`, `publish github`, and `publish homebrew` require a distribution path. The only accepted profile is `go`. `verify bundle` also requires an exact certificate identity. `verify handoff` requires artifact ID and digest values. Supply handoff values with `--artifact-id` and `--digest`, or with `RELEASE_ARTIFACT_ID` and `RELEASE_DIGEST`. An explicitly set flag takes precedence over its environment variable.

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

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

Expand Down Expand Up @@ -216,6 +217,33 @@ For example, a successful publication writes this envelope:
}
```

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

| Field | JSON type | Value |
| --- | --- | --- |
| `tap` | string | Target tap in `owner/repository` form. |
| `cask` | string | Published cask token. |
| `branch` | string | Deterministic publication branch in `release/<cask>/v<version>` form. |
| `pull_request_url` | string | Matching pull request URL. This can be empty when matching cask content reached the default branch without a discoverable pull request. |
| `state` | string | `created` when the command opened the pull request, `open` when it accepted an existing pull request, or `published` when matching content is on the default branch. |

For example, a new tap publication writes this envelope:

```json
{
"schema": "release.dev/result/v1",
"command": "publish homebrew",
"ok": true,
"result": {
"tap": "owner/homebrew-tap",
"cask": "example",
"branch": "release/example/v1.2.3",
"pull_request_url": "https://github.com/owner/homebrew-tap/pull/42",
"state": "created"
}
}
```

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

| Field | JSON type | Value |
Expand Down Expand Up @@ -380,7 +408,7 @@ parse or dispatch failures skip the envelope. These include an unknown command
or flag, an invalid flag value, or the wrong number of arguments. The usage
error goes to stderr and the process exits with code `2`.

Without `--json`, a successful `image build`, `image verify`, `plan tags`, `publish github`, `publish oci prepare`, `publish oci finalize`, `stage`, `verify bundle`, or `verify handoff` command writes nothing to stdout. A successful `version` command writes `release-cli <version> (<commit>, protocol <n>)` to stdout because the version data is the requested output and can be piped. This human format is a convenience, not a stable interface. Human diagnostics and warnings go to stderr. With `--json`, the envelope is the stable machine-readable stdout contract for all commands.
Without `--json`, a successful `image build`, `image verify`, `plan tags`, `publish github`, `publish homebrew`, `publish oci prepare`, `publish oci finalize`, `stage`, `verify bundle`, or `verify handoff` command writes nothing to stdout. A successful `version` command writes `release-cli <version> (<commit>, protocol <n>)` 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

Expand Down Expand Up @@ -686,6 +714,47 @@ A retryable operation uses at most four attempts, waiting 1 second, 2 seconds, a

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

## Homebrew cask publication

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

| Value | Flag | Environment variable | Default |
| --- | --- | --- | --- |
| Distribution directory | `--dist` | `RELEASE_DIST` | None. A path is required. |
| Target tap | `--tap` | None. | None. Use `owner/repository` form. |
| Cask token | `--cask` | None. | None. Use lowercase letters, digits, and interior hyphens. |
| Release App installation token | None. | `RELEASE_APP_TOKEN` | None. A token is required. |
| JSON output | `--json` | `RELEASE_JSON` | Disabled. |

The command requires this GitHub Actions context:

| Variable | Value |
| --- | --- |
| `GITHUB_REPOSITORY` | Source repository in `owner/name` form. |
| `GITHUB_REF_NAME` | Stable release tag. |
| `GITHUB_SHA` | Expected 40-character lowercase commit SHA for the workflow run. |
| `GITHUB_API_URL` | Optional absolute GitHub API base URL. The public GitHub API is the default. |
| `GITHUB_SERVER_URL` | Optional absolute GitHub server and upload base URL used with a custom API URL. |

The command opens `homebrew/Casks/<cask>.rb` beneath the distribution root. The path must resolve to a nonempty regular file no larger than 1 MiB. Root-confined file access rejects a symbolic link that escapes the distribution directory. The cask must contain one literal `version "<version>"` declaration, and that version must equal `GITHUB_REF_NAME` after removal of its leading `v`.

Publication enforces these guarantees in order:

1. Read the tap's default branch, head commit, and current cask.
2. Find the unique pull request whose base is the default branch and whose head is `release/<cask>/v<version>`. Multiple matching pull requests are a conflict.
3. Return `published` without mutation when the default branch already contains the exact generated bytes.
4. Refuse a different cask at the same or a newer version. A malformed current version also fails before mutation.
5. Create the deterministic publication branch from the observed default-branch commit when the branch is absent.
6. Accept an existing publication commit only when it has the observed default-branch commit as its sole parent, changes only `Casks/<cask>.rb`, classifies that path as added or modified, and contains the exact generated bytes. The command refuses every other branch state.
7. Commit the generated cask to an unchanged new branch. The update uses the observed blob SHA when the cask already exists.
8. Return `open` when a matching pull request already exists. Otherwise, open a non-draft pull request with maintainer edits and auto-merge disabled, then return `created`.

After a pull request is merged, a later invocation returns `published` only when the default branch contains the exact generated cask. A merged pull request without those bytes, or a closed unmerged pull request, is a conflict.

Repository reads and retryable writes use at most four attempts, waiting 1 second, 2 seconds, and 4 seconds between attempts. After a failed branch, file, or pull-request write, the command reads fresh state before retrying. This accepts a write that GitHub applied before losing the response without creating a duplicate commit or pull request.

A missing or malformed flag, Actions variable, token, endpoint, or source commit is a configuration error and exits with code `2` before a tap request. A missing, malformed, empty, non-regular, or oversized generated cask exits with code `1` before a tap request. Repository failures, conflicts, and failed postconditions also exit with code `1`. Success exits with code `0`.

## Signed release bundle verification

`release-cli verify bundle` verifies the local release bundle before the GitHub Release workflow attests or uploads it.
Expand Down
55 changes: 55 additions & 0 deletions internal/adapter/ghtap/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package ghtap

import (
"context"
"errors"
"fmt"

"github.com/google/go-github/v82/github"

"github.com/meigma/release/internal/rel"
)

// Client reads and mutates one GitHub tap through go-github.
type Client struct {
// github is the already-authenticated API client.
github *github.Client
}

// New constructs a [Client] around an already-authenticated go-github client.
func New(client *github.Client) *Client {
return &Client{github: client}
}

// NewAuthenticated constructs a [Client] for token at the given GitHub API.
//
// An empty apiURL selects public GitHub. Token text is applied only to the
// Authorization header and is never retained separately or returned in errors.
func NewAuthenticated(token rel.Secret, apiURL, serverURL string) (*Client, error) {
client := github.NewClient(nil).WithAuthToken(token.Reveal())
if apiURL == "" {
return New(client), nil
}
uploadURL := serverURL
if uploadURL == "" {
uploadURL = apiURL
}
enterprise, err := client.WithEnterpriseURLs(apiURL, uploadURL)
if err != nil {
return nil, fmt.Errorf("github enterprise urls: %w", err)
}

return New(enterprise), nil
}

// requireReady rejects a nil context or uninitialized client.
func (c *Client) requireReady(ctx context.Context) error {
if ctx == nil {
return errors.New("context is nil")
}
if c == nil || c.github == nil {
return errors.New("github client is nil")
}

return nil
}
6 changes: 6 additions & 0 deletions internal/adapter/ghtap/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Package ghtap implements Homebrew tap repository reads and writes with the
// GitHub REST API.
//
// The adapter maps remote metadata into pubbrew snapshots. Publication policy,
// reconciliation, and retry decisions remain in the pubbrew stage package.
package ghtap
66 changes: 66 additions & 0 deletions internal/adapter/ghtap/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package ghtap

import (
"context"
"errors"
"fmt"
"net/http"

"github.com/google/go-github/v82/github"

"github.com/meigma/release/internal/stage/pubbrew"
)

// errNotFound marks a repository resource that GitHub does not expose.
var errNotFound = errors.New("github repository resource not found")

// classify maps a go-github failure onto a safe domain sentinel or diagnostic.
//
// It never includes request headers, response bodies, URLs, or token text.
func classify(err error, resource string) error {
if errors.Is(err, context.Canceled) {
return fmt.Errorf("%w: request canceled", context.Canceled)
}
if errors.Is(err, context.DeadlineExceeded) {
return fmt.Errorf("%w: request deadline exceeded", context.DeadlineExceeded)
}

var rateLimit *github.RateLimitError
if errors.As(err, &rateLimit) {
return fmt.Errorf("%w: rate limited", pubbrew.ErrRetryable)
}
var abuse *github.AbuseRateLimitError
if errors.As(err, &abuse) {
return fmt.Errorf("%w: secondary rate limited", pubbrew.ErrRetryable)
}

var apiErr *github.ErrorResponse
if !errors.As(err, &apiErr) || apiErr.Response == nil {
return fmt.Errorf("%s request failed", resource)
}

switch code := apiErr.Response.StatusCode; {
case code == http.StatusNotFound:
return fmt.Errorf("%w: %s", errNotFound, resource)
case code == http.StatusUnauthorized || code == http.StatusForbidden:
return fmt.Errorf("github authentication failed: status %d", code)
case code == http.StatusConflict || code == http.StatusUnprocessableEntity:
return fmt.Errorf("%w: %s status %d", pubbrew.ErrConflict, resource, code)
case code == http.StatusTooManyRequests || code >= http.StatusInternalServerError:
return fmt.Errorf("%w: %s status %d", pubbrew.ErrRetryable, resource, code)
default:
return fmt.Errorf("%s request failed: status %d", resource, code)
}
}

// isNotFound reports whether err is a raw or classified GitHub 404 response.
func isNotFound(err error) bool {
if errors.Is(err, errNotFound) {
return true
}
var apiErr *github.ErrorResponse

return errors.As(err, &apiErr) &&
apiErr.Response != nil &&
apiErr.Response.StatusCode == http.StatusNotFound
}
3 changes: 3 additions & 0 deletions internal/adapter/ghtap/mocks/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Package mocks contains generated test doubles for Homebrew tap repository
// ports.
package mocks
Loading