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: 13 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions .mockery.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -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"
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
79 changes: 75 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, 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

Expand All @@ -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 <n> --digest <sha256:...> [--json]` | Verify an Actions artifact's GitHub API metadata before download. |
| `release-cli version [--json]` | Report the CLI version, source commit, and protocol integer. |

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

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

Expand All @@ -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. |

Expand Down Expand Up @@ -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/<manifest>/v<version>` 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 |
Expand Down Expand Up @@ -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 <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 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 <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 @@ -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/<manifest>.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 `<manifest>.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/<manifest>/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 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 `<manifest>.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.
Expand Down
55 changes: 55 additions & 0 deletions internal/adapter/ghbucket/client.go
Original file line number Diff line number Diff line change
@@ -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
}
6 changes: 6 additions & 0 deletions internal/adapter/ghbucket/doc.go
Original file line number Diff line number Diff line change
@@ -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
66 changes: 66 additions & 0 deletions internal/adapter/ghbucket/errors.go
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions internal/adapter/ghbucket/mocks/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Package mocks contains generated test doubles for Scoop bucket repository
// ports.
package mocks
Loading