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
1 change: 1 addition & 0 deletions .github/workflows/go-pre-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion .github/workflows/publish-github-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/publish-homebrew.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
269 changes: 269 additions & 0 deletions .github/workflows/publish-scoop.yml
Original file line number Diff line number Diff line change
@@ -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}"
20 changes: 20 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Loading