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
176 changes: 176 additions & 0 deletions docs/how-to/set-up-scoop-bucket.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# Set up a Scoop bucket

Use this guide to create a Scoop bucket and connect a producer repository to the shared publisher. The [`release-cli` contract](../reference/release-cli-contract.md#scoop-bucket-initialization) defines the generated files and failure behavior.

## Prerequisites

Confirm that:

- the producer already builds a Scoop manifest with GoReleaser;
- a released, attestation-verified `release-cli` is installed;
- GitHub CLI is authenticated;
- you can create repositories and rulesets in the target organization; and
- an organization owner can change the Meigma Release App installation and Actions credentials.

Use a public repository. Keep manifests at the repository root because the publisher writes `<manifest>.json` there and the validation workflow inspects root `*.json` files. `scoop bucket list` counts only a `bucket/` subdirectory and therefore reports `Manifests 0` for this layout; use search, install, and the required validation check to verify the bucket.

## 1. Generate the bucket

Choose the repository and an absent or empty local directory:

```bash
export BUCKET_REPOSITORY=acme/scoop-tools
export BUCKET_DIRECTORY="$PWD/scoop-tools"

release-cli init scoop-bucket \
--bucket "$BUCKET_REPOSITORY" \
--output "$BUCKET_DIRECTORY"
```

The command writes `.gitattributes`, `.github/workflows/manifests.yml`, `.github/dependabot.yml`, and `README.md`. The reusable validation workflow is pinned to the full source commit of the installed CLI. `.gitattributes` makes text files use CRLF in Windows checkouts, as required by the pinned Scoop bucket tests.

Inspect the output before creating the repository:

```bash
find "$BUCKET_DIRECTORY" -type f -print | sort
```

Do not run the command over an existing checkout. A nonempty output directory is rejected rather than merged or overwritten.

## 2. Create the GitHub repository

Initialize and publish the generated directory:

```bash
cd "$BUCKET_DIRECTORY"
git init
git add .
git commit -m "chore: initialize Scoop bucket"
git branch -M main
gh repo create "$BUCKET_REPOSITORY" --public --source=. --remote=origin --push
```

In the bucket repository's **Settings** > **Actions** > **General**, confirm that Actions is enabled and that the Actions policy permits `meigma/release` and the actions used by its reusable workflow.

## 3. Grant the Release App access

An organization owner must add both the producer and bucket repositories to the Meigma Release App installation:

1. Open the organization settings in GitHub.
2. Open **Third-party access** > **GitHub Apps** > **Installed GitHub Apps**.
3. Configure the Meigma Release App.
4. Under **Repository access**, keep **Only select repositories** selected.
5. Add the producer repository and the new bucket repository, then save.

The App must have `contents: write` and `pull requests: write` for the bucket. The publisher mints a short-lived token scoped to that repository. It does not use a personal access token.

## 4. Grant the producer its Actions credentials

In the organization settings, open **Secrets and variables** > **Actions**:

1. Make the variable `MEIGMA_RELEASE_APP_CLIENT_ID` available to the producer repository.
2. Make the secret `MEIGMA_RELEASE_APP_PRIVATE_KEY` available to the producer repository.
3. Keep both resources limited to selected repositories.

The bucket does not need either credential. Its validation workflow is secret-free.

## 5. Configure manifest generation

Add a `scoops` entry to the producer's `.goreleaser.yaml`. Keep `skip_upload: true`; `release-cli publish scoop`, not GoReleaser, owns the bucket pull request.

```yaml
scoops:
- name: example
ids:
- example
repository:
owner: acme
name: scoop-tools
homepage: https://github.com/acme/example
description: Example command
license: MIT
url_template: "https://github.com/acme/example/releases/download/{{ .Tag }}/{{ .ArtifactName }}"
skip_upload: true
```

Replace every example value. The manifest name must use lowercase letters, digits, and interior hyphens. Configure GoReleaser's archive names so the manifest selects both Windows AMD64 and ARM64 assets when the producer ships both architectures.

## 6. Add the publisher job

Add a job after the public GitHub Release job in the producer's tag workflow. Pin `publish-scoop.yml` and `checksum-signing-workflow-ref` to the same full `meigma/release` commit used by the other shared release workflows.

```yaml
scoop-publish:
name: Open Scoop bucket pull request
needs:
- release-assets
- github-release
permissions:
actions: read
attestations: read
contents: read
uses: meigma/release/.github/workflows/publish-scoop.yml@<FULL_RELEASE_COMMIT>
with:
artifact-id: ${{ needs.release-assets.outputs.artifact-id }}
artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }}
checksum-signing-workflow-ref: meigma/release/.github/workflows/go-pre-publish.yml@<FULL_RELEASE_COMMIT>
bucket: acme/scoop-tools
manifest: example
release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }}
publish-scoop: true
secrets:
release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }}
```

Keep `publish-scoop: false` until the bucket settings are ready. The publisher runs only after `github-release` succeeds, so generated manifest URLs point to public release assets.

## 7. Protect the bucket branch

The publisher opens a pull request and never merges it. Protect `main` before merging the first generated manifest:

1. In the bucket repository, open **Settings** > **Rules** > **Rulesets**.
2. Create a branch ruleset targeting the default branch.
3. Require changes through a pull request.
4. Require the `manifests / Scoop manifest validation` status check.
5. Block force pushes and branch deletion.
6. Activate the ruleset.

GitHub may not offer the status check until it has run once. If necessary, enable the publisher for one real release, wait for its bucket pull request and checks, configure the ruleset, and only then merge the pull request.

Dependabot updates `.github/workflows/manifests.yml`, so the manifest-only workflow does not report the required check on those pull requests. Do not bypass branch protection. After the bucket contains a manifest, reproduce the action update on a maintainer branch and include a semantically neutral formatting change to one root manifest so the Scoop validation runs. Before the first manifest exists, defer the action update.

## 8. Verify publication and the Scoop lifecycle

For the first real release:

1. Set `publish-scoop: true` before the release tag is created.
2. Confirm that the GitHub Release becomes public.
3. Confirm that the producer workflow opens one bucket pull request and changes only `<manifest>.json`.
4. Wait for `manifests / Scoop manifest validation` to pass on both Windows AMD64 and ARM64.
5. Review and merge the bucket pull request.
6. On clean Windows AMD64 and ARM64 systems, add the bucket, install the app, run its version command, uninstall it, and remove the bucket.

```powershell
scoop bucket add scoop-tools https://github.com/acme/scoop-tools
scoop install scoop-tools/example
example version
scoop uninstall example
scoop bucket rm scoop-tools
```

Verify updates after publishing and merging the next release. Install the earlier version from the bucket commit that contained it, return the bucket checkout to `main`, then update and uninstall the app:

```powershell
scoop bucket add scoop-tools https://github.com/acme/scoop-tools
$bucket = Join-Path $env:USERPROFILE 'scoop\buckets\scoop-tools'
git -C $bucket checkout <PREVIOUS_BUCKET_COMMIT>
scoop install scoop-tools/example
git -C $bucket checkout main
git -C $bucket pull --ff-only
scoop update example
example version
scoop uninstall example
scoop bucket rm scoop-tools
```

Run the update check on Windows AMD64 and ARM64 when both archives are published. A failed publisher run does not merge or auto-merge a bucket pull request. Correct the producer or bucket configuration, then rerun the failed job.
30 changes: 28 additions & 2 deletions docs/reference/release-cli-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@
| `release-cli publish oci finalize --result - [--plain-http] [--json]` | Re-read registry state and apply verified OCI image tags after attestation. |
| `release-cli publish github --dist PATH [--no-undraft] [--json]` | Reconcile a verified bundle with its matching GitHub Release and optionally publish the draft. |
| `release-cli init homebrew-tap --tap OWNER/HOMEBREW-NAME --output DIR [--json]` | Write a cask-only tap scaffold into a new or empty local directory. |
| `release-cli init scoop-bucket --bucket OWNER/REPOSITORY --output DIR [--json]` | Write a root-layout Scoop bucket 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`, `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.
`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>`. `init scoop-bucket` requires `--bucket` in `owner/repository` form and `--output`. Both initializers require 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 @@ -37,7 +38,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 scoop`, `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`, `init scoop-bucket`, `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 All @@ -58,6 +59,14 @@ For `init homebrew-tap --json`, `command` is exactly `init homebrew-tap`. The `r
| `output` | string | Clean local output path. |
| `files` | array of strings | Generated slash-separated paths in lexical order. |

For `init scoop-bucket --json`, `command` is exactly `init scoop-bucket`. The `result` object contains these fields:

| Field | JSON type | Value |
| --- | --- | --- |
| `bucket` | string | Validated target repository in `owner/repository` form. |
| `output` | string | Clean local output path. |
| `files` | array of strings | Generated slash-separated paths in lexical order. |

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

| Field | JSON type | Value |
Expand Down Expand Up @@ -768,6 +777,23 @@ The reusable workflow reference is pinned to the full source commit stamped into

The command does not generate `Formula/`, a publisher workflow, repository settings, branch protection, secrets, or a GitHub App. Follow [Set up a Homebrew tap](../how-to/set-up-homebrew-tap.md) to create the repository and connect a producer.

## Scoop bucket initialization

`release-cli init scoop-bucket` writes a root-layout Scoop bucket into a local output directory. It performs no Git or GitHub operation. The output path must not exist or must be an empty directory. A file, symlink, or nonempty directory is rejected without changing its contents.

The command writes exactly these files:

| Path | Purpose |
| --- | --- |
| `.gitattributes` | Makes text files use CRLF in Windows checkouts for the pinned Scoop tests. |
| `.github/workflows/manifests.yml` | Calls the reusable `scoop-bucket-ci.yml` workflow for pull requests that change root `*.json` manifests. |
| `.github/dependabot.yml` | Checks weekly for GitHub Actions updates. |
| `README.md` | Records the bucket and install syntax. |

The reusable workflow reference is pinned to the full source commit stamped into the running `release-cli` binary. Development builds stamped with `none`, malformed commits, and abbreviated commits exit with code `2` before creating the output directory. The generated workflow grants `contents: read` to its only caller job and sets top-level permissions to an empty map.

The command does not generate a sample manifest, a publisher workflow, repository settings, branch protection, secrets, or a GitHub App. Follow [Set up a Scoop bucket](../how-to/set-up-scoop-bucket.md) to create the repository and connect a producer.

## Homebrew cask publication

`release-cli publish homebrew` reads the cask generated by GoReleaser and reconciles it through a tap pull request. The command never writes the tap's default branch, force-updates a branch, deletes a path, enables auto-merge, or merges the pull request.
Expand Down
9 changes: 5 additions & 4 deletions internal/cli/homebrew_init.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ func newInitCommand(options Options) *cobra.Command {
},
}
cmd.AddCommand(newHomebrewTapInitCommand(options))
cmd.AddCommand(newScoopBucketInitCommand(options))

return cmd
}
Expand Down Expand Up @@ -83,7 +84,7 @@ func runHomebrewTapInit(cmd *cobra.Command, options Options) error {
return writeCommandResult(options, commandInitHomebrewTap, nil, UsageError(err))
}
files := renderHomebrewTapScaffold(config)
if err := writeHomebrewTapScaffold(config.Output, files); err != nil {
if err := writeScaffold(config.Output, files); err != nil {
return writeCommandResult(options, commandInitHomebrewTap, nil, err)
}
if options.settings == nil || !options.settings.JSON {
Expand Down Expand Up @@ -193,8 +194,8 @@ updates:
}
}

// writeHomebrewTapScaffold atomically publishes files into a new or empty output directory.
func writeHomebrewTapScaffold(output string, files []scaffoldFile) error {
// writeScaffold atomically publishes files into a new or empty output directory.
func writeScaffold(output string, files []scaffoldFile) error {
absoluteOutput, err := filepath.Abs(output)
if err != nil {
return fmt.Errorf("resolve output %s: %w", output, err)
Expand All @@ -212,7 +213,7 @@ func writeHomebrewTapScaffold(output string, files []scaffoldFile) error {
return fmt.Errorf("output parent %s is not a directory", parent)
}

temporary, err := os.MkdirTemp(parent, ".release-cli-homebrew-tap-*")
temporary, err := os.MkdirTemp(parent, ".release-cli-scaffold-*")
if err != nil {
return fmt.Errorf("create temporary scaffold in %s: %w", parent, err)
}
Expand Down
10 changes: 10 additions & 0 deletions internal/cli/result.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ type HomebrewTapInitResult struct {
Files []string `json:"files"`
}

// ScoopBucketInitResult is the --json payload for init scoop-bucket.
type ScoopBucketInitResult struct {
// Bucket is the initialized owner/repository bucket.
Bucket string `json:"bucket"`
// Output is the local scaffold directory.
Output string `json:"output"`
// Files lists every generated slash-separated path in lexical order.
Files []string `json:"files"`
}

// BinaryResult describes one verified canonical binary.
type BinaryResult struct {
// Path is the original GoReleaser path, including the --dist basename prefix.
Expand Down
Loading