diff --git a/.github/workflows/go-pre-publish.yml b/.github/workflows/go-pre-publish.yml index c5aa0c5..8f82dc6 100644 --- a/.github/workflows/go-pre-publish.yml +++ b/.github/workflows/go-pre-publish.yml @@ -224,6 +224,7 @@ jobs: dist/checksums.txt dist/checksums.txt.sigstore.json dist/homebrew/Casks/*.rb + dist/scoop/*.json if-no-files-found: error retention-days: 7 compression-level: 0 diff --git a/.github/workflows/publish-github-release.yml b/.github/workflows/publish-github-release.yml index 5171ac4..e96a6fb 100644 --- a/.github/workflows/publish-github-release.yml +++ b/.github/workflows/publish-github-release.yml @@ -160,13 +160,14 @@ jobs: path: dist digest-mismatch: error - - name: Exclude Homebrew control from GitHub Release + - name: Exclude package-manager controls from GitHub Release uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | const fs = require('fs/promises') const path = require('path') await fs.rm(path.resolve('dist/homebrew'), {recursive: true, force: true}) + await fs.rm(path.resolve('dist/scoop'), {recursive: true, force: true}) - name: Verify authoritative release bundle id: bundle diff --git a/.github/workflows/publish-homebrew.yml b/.github/workflows/publish-homebrew.yml index adb6566..664843d 100644 --- a/.github/workflows/publish-homebrew.yml +++ b/.github/workflows/publish-homebrew.yml @@ -151,6 +151,14 @@ jobs: path: dist digest-mismatch: error + - name: Exclude Scoop control from bundle verification + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require('fs/promises') + const path = require('path') + await fs.rm(path.resolve('dist/scoop'), {recursive: true, force: true}) + - name: Isolate generated cask control id: isolate-cask uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/.github/workflows/publish-scoop.yml b/.github/workflows/publish-scoop.yml new file mode 100644 index 0000000..225cafd --- /dev/null +++ b/.github/workflows/publish-scoop.yml @@ -0,0 +1,269 @@ +name: Reusable Scoop Publisher + +on: + workflow_call: + inputs: + artifact-id: + description: ID of the authoritative release-assets artifact. + required: true + type: string + artifact-digest: + description: Expected SHA-256 digest of the release-assets artifact. + required: true + type: string + checksum-signing-workflow-ref: + description: Exact workflow ref expected in the checksum signing certificate identity. + required: true + type: string + bucket: + description: Scoop bucket repository in owner/name form. + required: false + default: '' + type: string + manifest: + description: Manifest name generated by GoReleaser. + required: false + default: '' + type: string + release-app-client-id: + description: Client ID of the GitHub App that writes the bucket pull request. + required: false + default: '' + type: string + publish-scoop: + description: Reconcile the generated manifest through a bucket pull request. + required: false + default: false + type: boolean + secrets: + release-app-private-key: + description: Private key of the GitHub App that writes the bucket pull request. + required: false + outputs: + branch: + description: Deterministic bucket publication branch. + value: ${{ jobs.publish.outputs.branch }} + pull-request-url: + description: Open bucket pull request URL, when one exists. + value: ${{ jobs.publish.outputs.pull-request-url }} + state: + description: Reconciled publication state. + value: ${{ jobs.publish.outputs.state }} + +permissions: {} + +jobs: + publish: + name: Publish Scoop manifest + if: inputs.publish-scoop + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + branch: ${{ steps.publish.outputs.branch }} + pull-request-url: ${{ steps.publish.outputs.pull-request-url }} + state: ${{ steps.publish.outputs.state }} + permissions: + actions: read + attestations: read + contents: read + env: + MISE_EXEC_AUTO_INSTALL: 'false' + steps: + - name: Validate publication configuration + id: config + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + BUCKET: ${{ inputs.bucket }} + MANIFEST: ${{ inputs.manifest }} + RELEASE_APP_CLIENT_ID: ${{ inputs.release-app-client-id }} + RELEASE_APP_PRIVATE_KEY: ${{ secrets.release-app-private-key }} + with: + script: | + if (context.ref.startsWith('refs/tags/') === false) { + core.setFailed('Scoop publication must run against a tag ref.') + return + } + + const required = [ + 'BUCKET', + 'MANIFEST', + 'RELEASE_APP_CLIENT_ID', + 'RELEASE_APP_PRIVATE_KEY', + ] + const missing = required.filter((name) => !process.env[name]) + if (missing.length !== 0) { + core.setFailed(`Scoop publication is enabled but these values are missing: ${missing.join(', ')}`) + return + } + + const bucket = process.env.BUCKET.match(/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/) + if (!bucket) { + core.setFailed('Scoop bucket must use owner/repository form.') + return + } + if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(process.env.MANIFEST)) { + core.setFailed('Scoop manifest must contain lowercase letters, digits, and interior hyphens.') + return + } + + core.setOutput('bucket-owner', bucket[1]) + core.setOutput('bucket-repository', bucket[2]) + + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 1 + filter: 'blob:none' + persist-credentials: false + + - name: Setup mise + uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 + with: + version: 2026.8.8 + install_args: 'aqua:sigstore/cosign' + cache: true + add_shims_to_path: false + export_path: false + + - name: Verify publication tools + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: await exec.exec('mise', ['exec', '--', 'cosign', 'version']) + + - name: Set up release-cli + id: setup-cli + uses: $/.github/actions/setup-release-cli + + - name: Verify artifact handoff + env: + ARTIFACT_ID: ${{ inputs.artifact-id }} + EXPECTED_DIGEST: ${{ inputs.artifact-digest }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_CLI: ${{ steps.setup-cli.outputs.cli-path }} + shell: bash + run: | + "${RELEASE_CLI}" verify handoff --artifact-id "${ARTIFACT_ID}" --digest "${EXPECTED_DIGEST}" + + - name: Download authoritative release assets + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ inputs.artifact-id }} + path: dist + digest-mismatch: error + + - name: Isolate generated Scoop control + id: isolate-scoop + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + MANIFEST: ${{ inputs.manifest }} + with: + script: | + const fs = require('fs/promises') + const path = require('path') + const scoop = path.resolve('dist/scoop') + const control = path.join(process.env.RUNNER_TEMP, 'scoop-control') + + const entries = await fs.readdir(scoop, {withFileTypes: true}) + const expected = `${process.env.MANIFEST}.json` + if (entries.length !== 1 || entries[0].name !== expected || !entries[0].isFile()) { + throw new Error(`The release artifact must contain exactly scoop/${expected}.`) + } + + await fs.rm(control, {recursive: true, force: true}) + await fs.rename(scoop, control) + core.setOutput('control', control) + + - name: Exclude Homebrew control from bundle verification + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require('fs/promises') + const path = require('path') + await fs.rm(path.resolve('dist/homebrew'), {recursive: true, force: true}) + + - name: Verify authoritative release bundle + env: + CERTIFICATE_IDENTITY: https://github.com/${{ inputs.checksum-signing-workflow-ref }} + RELEASE_CLI: ${{ steps.setup-cli.outputs.cli-path }} + shell: bash + run: | + set -euo pipefail + cosign_path="$(mise which cosign)" + if [ ! -x "${cosign_path}" ]; then + echo "::error::Resolved cosign path ${cosign_path} is not executable." + exit 1 + fi + + RELEASE_COSIGN_PATH="${cosign_path}" \ + "${RELEASE_CLI}" verify bundle \ + --dist dist \ + --identity "${CERTIFICATE_IDENTITY}" \ + --json + + - name: Restore generated Scoop control + env: + CONTROL: ${{ steps.isolate-scoop.outputs.control }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const fs = require('fs/promises') + const path = require('path') + await fs.rename(process.env.CONTROL, path.resolve('dist/scoop')) + + - name: Create bucket app token + id: bucket-app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ inputs.release-app-client-id }} + private-key: ${{ secrets.release-app-private-key }} + owner: ${{ steps.config.outputs.bucket-owner }} + repositories: ${{ steps.config.outputs.bucket-repository }} + permission-contents: write + permission-pull-requests: write + + - name: Publish generated Scoop manifest + id: publish + env: + BUCKET: ${{ inputs.bucket }} + MANIFEST: ${{ inputs.manifest }} + RELEASE_APP_TOKEN: ${{ steps.bucket-app.outputs.token }} + RELEASE_CLI: ${{ steps.setup-cli.outputs.cli-path }} + shell: bash + run: | + set -euo pipefail + envelope="$( + "${RELEASE_CLI}" publish scoop \ + --dist dist \ + --bucket "${BUCKET}" \ + --manifest "${MANIFEST}" \ + --json + )" + printf '%s\n' "${envelope}" + + state="$(jq -r '.result.state' <<<"${envelope}")" + branch="$(jq -r '.result.branch' <<<"${envelope}")" + url="$(jq -r '.result.pull_request_url // ""' <<<"${envelope}")" + case "${state}" in + created|open) + if [ -z "${url}" ]; then + echo "::error::Scoop publication reported ${state} without a pull request URL." + exit 1 + fi + ;; + published) + ;; + *) + echo "::error::Scoop publication reported unexpected state ${state}." + exit 1 + ;; + esac + if [ -z "${branch}" ] || [ "${branch}" = 'null' ]; then + echo '::error::Scoop publication reported no branch.' + exit 1 + fi + + { + printf 'branch=%s\n' "${branch}" + printf 'pull-request-url=%s\n' "${url}" + printf 'state=%s\n' "${state}" + } >>"${GITHUB_OUTPUT}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 246d22e..f7f2e2d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,3 +101,23 @@ jobs: publish-homebrew: true secrets: release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} + scoop-publish: + name: Open Scoop bucket pull request + needs: + - release-assets + - github-release + permissions: + actions: read + attestations: read + contents: read + uses: ./.github/workflows/publish-scoop.yml + with: + artifact-id: ${{ needs.release-assets.outputs.artifact-id }} + artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} + checksum-signing-workflow-ref: ${{ github.repository }}/.github/workflows/go-pre-publish.yml@${{ github.ref }} + bucket: meigma/scoop-bucket + manifest: meigma-release-cli + release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} + publish-scoop: true + secrets: + release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} diff --git a/docs/reference/github-release-contract.md b/docs/reference/github-release-contract.md index 6c09adb..7745abc 100644 --- a/docs/reference/github-release-contract.md +++ b/docs/reference/github-release-contract.md @@ -6,7 +6,9 @@ For configuration steps, see [Configure GitHub releases](../how-to/configure-git ## Canonical workflow references -The complete caller pins all four reusable workflows to one full revision. The GitHub Release path directly calls the producer and GitHub publisher: +The released cross-repository GitHub Release path pins its four reusable +workflows to one full revision. Its producer and GitHub publisher references +are: ```yaml uses: meigma/release/.github/workflows/go-pre-publish.yml@0fc99489d31d400bc3f69d6636d60e7d3f3d0251 @@ -104,6 +106,36 @@ jobs: release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} ``` +The repository's production caller adds the Scoop publisher after the public +GitHub Release: + +```yaml + scoop-publish: + name: Open Scoop bucket pull request + needs: + - release-assets + - github-release + permissions: + actions: read + attestations: read + contents: read + uses: ./.github/workflows/publish-scoop.yml + with: + artifact-id: ${{ needs.release-assets.outputs.artifact-id }} + artifact-digest: ${{ needs.release-assets.outputs.artifact-digest }} + checksum-signing-workflow-ref: ${{ github.repository }}/.github/workflows/go-pre-publish.yml@${{ github.ref }} + bucket: meigma/scoop-bucket + manifest: meigma-release-cli + release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} + publish-scoop: true + secrets: + release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} +``` + +`scoop-publish` and `homebrew-publish` both require `release-assets` and +`github-release`. They can run independently after the public release succeeds. +Neither package publisher can run when the GitHub Release job fails. + The top-level `permissions: {}` prevents permissions from being granted implicitly. Each called job grants its reusable workflow only the permissions listed above. A called workflow cannot elevate permissions beyond those granted by its caller. The caller concurrency key serializes runs for the same workflow and tag. `cancel-in-progress: false` prevents a later run for that tag from canceling an earlier run. The OCI publisher adds repository-wide serialization across different release tags so shared channel tags cannot race. @@ -112,7 +144,9 @@ The caller concurrency key serializes runs for the same workflow and tag. `cance ### `go-pre-publish.yml` -The Go producer accepts no inputs and no secrets. +The Go producer accepts the default-off `sign-and-notarize-macos` boolean and +five optional macOS signing secrets. Disabled signing requires none of those +secrets. The producer loads `setup-release-cli` from the same pinned release revision with `uses: $/.github/actions/setup-release-cli`. The caller does not pin the @@ -192,6 +226,42 @@ The final CLI command rebuilds the expected closed asset set from `dist`, binds The workflow mints the short-lived Release App installation token with `actions/create-github-app-token` and passes it to the CLI as `RELEASE_APP_TOKEN`. The CLI holds the value as a redacted secret. It does not receive the App private key or client ID and does not mint a token. +### `publish-scoop.yml` + +| Input | Type | Required | Default | Value | +| --- | --- | --- | --- | --- | +| `artifact-id` | string | Yes | None | Positive integer ID from `go-pre-publish.yml`. | +| `artifact-digest` | string | Yes | None | Expected SHA-256 digest from `go-pre-publish.yml`. | +| `checksum-signing-workflow-ref` | string | Yes | None | Exact owner, repository, workflow path, and revision used as the checksum certificate identity. | +| `bucket` | string | No | Empty | Target Scoop bucket in `owner/repository` form. Required when publication is enabled. | +| `manifest` | string | No | Empty | GoReleaser manifest name without `.json`. Required when publication is enabled. | +| `release-app-client-id` | string | No | Empty | Release App client ID. Required when publication is enabled. | +| `publish-scoop` | boolean | No | `false` | Whether to reconcile the generated manifest through a bucket pull request. | + +| Secret | Required | Value | +| --- | --- | --- | +| `release-app-private-key` | No | Release App private key. Required only when `publish-scoop` is `true`. | + +| Output | Value | +| --- | --- | +| `branch` | Deterministic `release//v` branch. | +| `pull-request-url` | Open bucket pull request URL for `created` and `open`; empty for `published`. | +| `state` | One of `created`, `open`, or `published`. | + +The job requires `actions: read`, `attestations: read`, and `contents: read` +from its caller. The default-off job is skipped before configuration +validation, token creation, or a bucket request. When enabled, it verifies the +artifact metadata and transport digest, requires exactly +`scoop/.json`, removes both package-manager controls from the bundle +verification view, and verifies the signed bundle. It restores only the Scoop +control before minting a short-lived App token scoped to the selected bucket +repository with `contents: write` and `pull-requests: write`. + +The workflow then runs `release-cli publish scoop`. It accepts only the three +documented states and requires a branch for every state and a pull request URL +for `created` or `open`. It never writes the bucket's default branch, merges a +pull request, enables auto-merge, deletes a ref, or changes repository policy. + ## Versioning and credentials The current versioning workflow runs Release Please on pushes to `main` and on `workflow_dispatch`. It declares `permissions: {}` at workflow scope. Its job declares `contents: write`, `pull-requests: write`, and `issues: write`, then passes a Release App installation token to `googleapis/release-please-action`. @@ -294,7 +364,8 @@ use their own project and binary names. ## Authoritative artifact and asset contract -The producer uploads one Actions artifact named `release-assets`. Its upload set is limited to: +The producer uploads one Actions artifact named `release-assets`. Its upload set +contains the signed release payload and two package-manager controls: ```text dist/*.tar.gz @@ -305,9 +376,15 @@ dist/*.apk dist/*.sbom.json dist/checksums.txt dist/checksums.txt.sigstore.json +dist/homebrew/Casks/*.rb +dist/scoop/*.json ``` -For the supported three-operating-system, two-architecture Go profile, this is six archives, six native Linux packages, twelve SBOMs, `checksums.txt`, and `checksums.txt.sigstore.json`: 26 files in total. +The supported three-operating-system, two-architecture Go payload contains six +archives, six native Linux packages, twelve SBOMs, `checksums.txt`, and +`checksums.txt.sigstore.json`: 26 signed/public files. This repository's +Homebrew cask and Scoop manifest increase the digest-protected Actions artifact +to 28 files without changing the signed or public payload count. Before upload, the producer obtains `release-cli` through the shared setup action and runs `release-cli stage --profile go --dist dist`. The command first builds the release bundle through GoReleaser. It then verifies every payload listed in `checksums.txt`, requires a nonempty regular `checksums.txt.sigstore.json`, verifies the two canonical Linux binaries described in the [`release-cli` contract](release-cli-contract.md), and writes the OCI input projection. @@ -317,6 +394,16 @@ The publisher's artifact handoff has three independent owners: 2. The SHA-pinned `actions/download-artifact` step, configured with `digest-mismatch: error`, verifies the transport digest of the artifact ZIP. 3. `release-cli verify bundle` verifies the extracted content and the detached Sigstore bundle. +The cask and Scoop manifest are Actions control files, not signed release +payloads. They are integrity-bound by the Actions artifact digest and must not +appear in `checksums.txt`, the build-provenance subjects, or the GitHub Release. +After handoff verification, the GitHub Release publisher removes both controls +before bundle verification, attestation, and upload. The Homebrew publisher +removes the Scoop control and temporarily isolates its cask; the Scoop publisher +removes the Homebrew control and temporarily isolates its manifest. Each +package publisher restores only its own control after the same signed bundle +passes verification. + `release-cli verify handoff` does not download the artifact and never reproduces the Actions ZIP digest. `checksums.txt` is the authoritative payload list. It may end with a newline; every entry line must contain a 64-digit hexadecimal SHA-256 digest, a standard text or binary marker, and a flat filename matching this character set: @@ -389,7 +476,7 @@ An undraft request has no rollback. A failure from the undraft call is indetermi This contract does not provide or imply: - OCI construction or publication behavior beyond the dependency ordering defined here; see the separate [OCI image contract](oci-image-contract.md). -- Homebrew, MacPorts, Nix, Scoop, mise registry, or other package-manager publication. Tagged `release-cli` archives remain installable through mise's built-in GitHub backend; see [Install `release-cli` with mise](../how-to/install-release-cli-with-mise.md). +- Package-manager repository validation and reconciliation beyond the dependency and control-file boundaries defined here. See the [`release-cli` contract](release-cli-contract.md) for Homebrew and Scoop behavior. - Native package repository publication or native package-manager signing. DEB, RPM, and APK files are standalone GitHub Release assets protected by the release checksum, Cosign, and attestation contract. - Release support for languages other than the documented Go producer profile. - Consumer CI policy or tests in the OIDC-enabled release job. diff --git a/docs/reference/release-cli-contract.md b/docs/reference/release-cli-contract.md index 1538f21..a5d926b 100644 --- a/docs/reference/release-cli-contract.md +++ b/docs/reference/release-cli-contract.md @@ -876,6 +876,41 @@ Repository reads and retryable writes use at most four attempts, waiting 1 secon 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`. +### Reusable Scoop publisher + +`.github/workflows/publish-scoop.yml` publishes one generated manifest only +after the public GitHub Release succeeds. The caller passes the authoritative +`release-assets` artifact ID and digest, the exact checksum-signing workflow +ref, the bucket and manifest names, and the Release App client ID. Set +`publish-scoop` to `true` to enable publication. The default is `false`. + +The reusable workflow declares `release-app-private-key` as optional because a +disabled call must not require credentials, mint a token, or contact a bucket. +An enabled call requires the App client ID and private key before any bucket +request. It verifies the artifact handoff and signed release bundle before +minting a token scoped to only the selected bucket with `contents: write` and +`pull-requests: write`. + +The generated `scoop/.json` control is protected by the Actions +artifact digest but is deliberately excluded from `checksums.txt`, GitHub +attestations, and public release assets. The Scoop publisher requires exactly +one expected manifest beneath `dist/scoop`, isolates it, removes the unrelated +`dist/homebrew` control, verifies the remaining signed bundle, then restores the +manifest for `release-cli publish scoop`. The GitHub Release publisher removes +both package-manager controls before verification and upload. The Homebrew +publisher removes the Scoop control while preserving its existing cask +isolation, verification, restoration, and publication sequence. + +The reusable workflow exposes `branch`, `pull-request-url`, and `state`. It +accepts only `created`, `open`, and `published`. `created` and `open` require a +pull request URL; every state requires the deterministic branch. The workflow +never merges or enables auto-merge. + +The production release caller runs Scoop and Homebrew publication independently +after `github-release`. Both jobs require the successful `release-assets` and +`github-release` jobs, so neither package-manager repository can receive a pull +request for an unpublished or invalid release. + ## Signed release bundle verification `release-cli verify bundle` verifies the local release bundle before the GitHub Release workflow attests or uploads it.