From 60c0c48ca5e8ca621fe99953f7a2542eaf4b751e Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 20 Aug 2026 18:24:11 -0700 Subject: [PATCH 1/6] feat(release): publish signed Homebrew casks --- .github/workflows/go-pre-publish.yml | 88 +++++- .github/workflows/publish-github-release.yml | 8 + .github/workflows/publish-homebrew.yml | 265 +++++++++++++++++++ .github/workflows/release.yml | 28 ++ .goreleaser.yaml | 31 +++ docs/reference/release-cli-contract.md | 24 ++ 6 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish-homebrew.yml diff --git a/.github/workflows/go-pre-publish.yml b/.github/workflows/go-pre-publish.yml index 1074fec..a35ffa4 100644 --- a/.github/workflows/go-pre-publish.yml +++ b/.github/workflows/go-pre-publish.yml @@ -2,6 +2,28 @@ name: Reusable Go Pre-publish on: workflow_call: + inputs: + sign-and-notarize-macos: + description: Sign and notarize Darwin binaries before archiving. + required: false + default: false + type: boolean + secrets: + macos-sign-p12: + description: Base64-encoded Developer ID Application certificate. + required: false + macos-sign-password: + description: Password for the Developer ID Application certificate. + required: false + macos-notary-key: + description: Base64-encoded App Store Connect API private key. + required: false + macos-notary-key-id: + description: App Store Connect API key ID. + required: false + macos-notary-issuer-id: + description: App Store Connect API issuer ID. + required: false outputs: artifact-id: description: ID of the authoritative release-assets artifact. @@ -28,7 +50,7 @@ jobs: release-assets: name: Build authoritative release assets runs-on: ubuntu-24.04 - timeout-minutes: 20 + timeout-minutes: 30 outputs: artifact-id: ${{ steps.upload.outputs.artifact-id }} artifact-url: ${{ steps.upload.outputs.artifact-url }} @@ -44,6 +66,33 @@ jobs: GOTOOLCHAIN: local MISE_EXEC_AUTO_INSTALL: 'false' steps: + - name: Validate macOS signing configuration + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + ENABLED: ${{ inputs.sign-and-notarize-macos }} + MACOS_SIGN_P12: ${{ secrets.macos-sign-p12 }} + MACOS_SIGN_PASSWORD: ${{ secrets.macos-sign-password }} + MACOS_NOTARY_KEY: ${{ secrets.macos-notary-key }} + MACOS_NOTARY_KEY_ID: ${{ secrets.macos-notary-key-id }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.macos-notary-issuer-id }} + with: + script: | + if (process.env.ENABLED !== 'true') { + return + } + + const required = [ + 'MACOS_SIGN_P12', + 'MACOS_SIGN_PASSWORD', + 'MACOS_NOTARY_KEY', + 'MACOS_NOTARY_KEY_ID', + 'MACOS_NOTARY_ISSUER_ID', + ] + const missing = required.filter((name) => !process.env[name]) + if (missing.length !== 0) { + core.setFailed(`macOS signing is enabled but these credentials are missing: ${missing.join(', ')}`) + } + - name: Require a tag ref shell: bash run: | @@ -92,6 +141,12 @@ jobs: - name: Stage Go release artifacts env: RELEASE_CLI: ${{ steps.setup-cli.outputs.cli-path }} + MACOS_NOTARIZE_ENABLED: ${{ inputs.sign-and-notarize-macos }} + MACOS_SIGN_P12: ${{ secrets.macos-sign-p12 }} + MACOS_SIGN_PASSWORD: ${{ secrets.macos-sign-password }} + MACOS_NOTARY_KEY: ${{ secrets.macos-notary-key }} + MACOS_NOTARY_KEY_ID: ${{ secrets.macos-notary-key-id }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.macos-notary-issuer-id }} shell: bash run: | set -euo pipefail @@ -110,6 +165,36 @@ jobs: RELEASE_GORELEASER_PATH="${goreleaser_path}" \ mise exec -- "${RELEASE_CLI}" stage --profile go --dist dist + - name: Set up Homebrew + id: homebrew + if: hashFiles('dist/homebrew/Casks/*.rb') != '' + uses: Homebrew/actions/setup-homebrew@8f3d1ec8a696b3b9d9a6c3696b6c73033cab69e4 # 2026.08.14.1 + with: + brew-gh-api-token: '' + + - name: Format generated Homebrew casks + if: steps.homebrew.outcome == 'success' + env: + HOMEBREW_NO_AUTO_UPDATE: '1' + shell: bash + run: | + set -euo pipefail + tap='meigma/release-build' + tap_root='' + cleanup() { + if [ -n "${tap_root}" ]; then + brew untap --force "${tap}" >/dev/null + fi + } + trap cleanup EXIT + + brew tap-new --no-git "${tap}" + tap_root="$(brew --repository "${tap}")" + cp dist/homebrew/Casks/*.rb "${tap_root}/Casks/" + brew style --fix --cask "${tap}" + brew style --cask "${tap}" + cp "${tap_root}"/Casks/*.rb dist/homebrew/Casks/ + - name: Upload canonical Linux binaries id: upload-oci-input uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -137,6 +222,7 @@ jobs: dist/*.sbom.json dist/checksums.txt dist/checksums.txt.sigstore.json + dist/homebrew/Casks/*.rb 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 b51405f..5171ac4 100644 --- a/.github/workflows/publish-github-release.yml +++ b/.github/workflows/publish-github-release.yml @@ -160,6 +160,14 @@ jobs: path: dist digest-mismatch: error + - name: Exclude Homebrew control 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}) + - name: Verify authoritative release bundle id: bundle env: diff --git a/.github/workflows/publish-homebrew.yml b/.github/workflows/publish-homebrew.yml new file mode 100644 index 0000000..adb6566 --- /dev/null +++ b/.github/workflows/publish-homebrew.yml @@ -0,0 +1,265 @@ +name: Reusable Homebrew 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 + tap: + description: Homebrew tap repository in owner/name form. + required: false + default: '' + type: string + cask: + description: Cask token generated by GoReleaser. + required: false + default: '' + type: string + release-app-client-id: + description: Client ID of the GitHub App that writes the tap pull request. + required: false + default: '' + type: string + publish-homebrew: + description: Reconcile the generated cask through a tap pull request. + required: false + default: false + type: boolean + secrets: + release-app-private-key: + description: Private key of the GitHub App that writes the tap pull request. + required: false + outputs: + branch: + description: Deterministic tap publication branch. + value: ${{ jobs.publish.outputs.branch }} + pull-request-url: + description: Open tap 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 Homebrew cask + if: inputs.publish-homebrew + 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: + CASK: ${{ inputs.cask }} + RELEASE_APP_CLIENT_ID: ${{ inputs.release-app-client-id }} + RELEASE_APP_PRIVATE_KEY: ${{ secrets.release-app-private-key }} + TAP: ${{ inputs.tap }} + with: + script: | + if (context.ref.startsWith('refs/tags/') === false) { + core.setFailed('Homebrew publication must run against a tag ref.') + return + } + + const required = [ + 'CASK', + 'RELEASE_APP_CLIENT_ID', + 'RELEASE_APP_PRIVATE_KEY', + 'TAP', + ] + const missing = required.filter((name) => !process.env[name]) + if (missing.length !== 0) { + core.setFailed(`Homebrew publication is enabled but these values are missing: ${missing.join(', ')}`) + return + } + + const tap = process.env.TAP.match(/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/) + if (!tap) { + core.setFailed('Homebrew tap must use owner/repository form.') + return + } + if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(process.env.CASK)) { + core.setFailed('Homebrew cask must contain lowercase letters, digits, and interior hyphens.') + return + } + + core.setOutput('tap-owner', tap[1]) + core.setOutput('tap-repository', tap[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 cask control + id: isolate-cask + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + CASK: ${{ inputs.cask }} + with: + script: | + const fs = require('fs/promises') + const path = require('path') + const homebrew = path.resolve('dist/homebrew') + const casks = path.join(homebrew, 'Casks') + const control = path.join(process.env.RUNNER_TEMP, 'homebrew-control') + + const homebrewEntries = await fs.readdir(homebrew, {withFileTypes: true}) + if (homebrewEntries.length !== 1 || homebrewEntries[0].name !== 'Casks' || !homebrewEntries[0].isDirectory()) { + throw new Error('The release artifact must contain only homebrew/Casks beneath homebrew/.') + } + + const caskEntries = await fs.readdir(casks, {withFileTypes: true}) + const expected = `${process.env.CASK}.rb` + if (caskEntries.length !== 1 || caskEntries[0].name !== expected || !caskEntries[0].isFile()) { + throw new Error(`The release artifact must contain exactly homebrew/Casks/${expected}.`) + } + + await fs.rm(control, {recursive: true, force: true}) + await fs.rename(homebrew, control) + core.setOutput('control', control) + + - 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 cask control + env: + CONTROL: ${{ steps.isolate-cask.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/homebrew')) + + - name: Create tap app token + id: tap-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.tap-owner }} + repositories: ${{ steps.config.outputs.tap-repository }} + permission-contents: write + permission-pull-requests: write + + - name: Publish generated Homebrew cask + id: publish + env: + CASK: ${{ inputs.cask }} + RELEASE_APP_TOKEN: ${{ steps.tap-app.outputs.token }} + RELEASE_CLI: ${{ steps.setup-cli.outputs.cli-path }} + TAP: ${{ inputs.tap }} + shell: bash + run: | + set -euo pipefail + envelope="$( + "${RELEASE_CLI}" publish homebrew \ + --dist dist \ + --tap "${TAP}" \ + --cask "${CASK}" \ + --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::Homebrew publication reported ${state} without a pull request URL." + exit 1 + fi + ;; + published) + ;; + *) + echo "::error::Homebrew publication reported unexpected state ${state}." + exit 1 + ;; + esac + if [ -z "${branch}" ] || [ "${branch}" = 'null' ]; then + echo '::error::Homebrew publication reported no branch.' + exit 1 + fi + + printf 'branch=%s\n' "${branch}" >>"${GITHUB_OUTPUT}" + printf 'pull-request-url=%s\n' "${url}" >>"${GITHUB_OUTPUT}" + printf 'state=%s\n' "${state}" >>"${GITHUB_OUTPUT}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 189e496..246d22e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,14 @@ jobs: contents: read id-token: write uses: ./.github/workflows/go-pre-publish.yml + with: + sign-and-notarize-macos: true + secrets: + macos-sign-p12: ${{ secrets.MACOS_SIGN_P12 }} + macos-sign-password: ${{ secrets.MACOS_SIGN_PASSWORD }} + macos-notary-key: ${{ secrets.MACOS_NOTARY_KEY }} + macos-notary-key-id: ${{ secrets.MACOS_NOTARY_KEY_ID }} + macos-notary-issuer-id: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} oci-image: name: Build OCI image needs: release-assets @@ -73,3 +81,23 @@ jobs: publish-release: true secrets: release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} + homebrew-publish: + name: Open Homebrew tap pull request + needs: + - release-assets + - github-release + permissions: + actions: read + attestations: read + contents: read + uses: ./.github/workflows/publish-homebrew.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 }} + tap: meigma/homebrew-tap + cask: meigma-release-cli + release-app-client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} + publish-homebrew: true + secrets: + release-app-private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 6e3ae10..9063752 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -31,6 +31,21 @@ builds: - -X main.commit={{ .FullCommit }} mod_timestamp: "{{ .CommitTimestamp }}" +notarize: + macos: + - enabled: '{{ eq .Env.MACOS_NOTARIZE_ENABLED "true" }}' + ids: + - release-cli + sign: + certificate: "{{ .Env.MACOS_SIGN_P12 }}" + password: "{{ .Env.MACOS_SIGN_PASSWORD }}" + notarize: + issuer_id: "{{ .Env.MACOS_NOTARY_ISSUER_ID }}" + key_id: "{{ .Env.MACOS_NOTARY_KEY_ID }}" + key: "{{ .Env.MACOS_NOTARY_KEY }}" + wait: true + timeout: 20m + archives: - id: release-cli ids: @@ -59,6 +74,22 @@ nfpms: - apk bindir: /usr/bin +homebrew_casks: + - name: meigma-release-cli + ids: + - release-cli + binaries: + - release-cli + repository: + owner: meigma + name: homebrew-tap + homepage: https://github.com/meigma/release + description: Release automation for Meigma projects + license: LicenseRef-Proprietary + url: + template: "https://github.com/meigma/release/releases/download/{{ .Tag }}/{{ .ArtifactName }}" + skip_upload: true + checksum: name_template: checksums.txt diff --git a/docs/reference/release-cli-contract.md b/docs/reference/release-cli-contract.md index 00cfa26..afc87a2 100644 --- a/docs/reference/release-cli-contract.md +++ b/docs/reference/release-cli-contract.md @@ -755,6 +755,30 @@ 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 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`. +### Reusable Homebrew publisher + +`.github/workflows/publish-homebrew.yml` publishes one generated cask only after the public GitHub Release job succeeds. The caller passes the authoritative `release-assets` artifact ID and digest, the exact checksum-signing workflow ref, the tap and cask names, and the Release App client ID. Set `publish-homebrew` to `true` to enable publication. The default is `false`. + +The reusable workflow declares `release-app-private-key` as an optional secret because a disabled call must not require or mint a tap credential. An enabled call requires the client ID and private key before any tap request. It verifies the artifact handoff and signed release bundle before minting a repository-scoped App token with only `contents: write` and `pull-requests: write` for the selected tap. The generated `homebrew/Casks/.rb` control file is protected by the Actions artifact digest but is deliberately excluded from `checksums.txt` and the GitHub Release assets. The GitHub Release publisher removes the Homebrew control after artifact-digest verification; the Homebrew publisher isolates it while verifying the signed bundle, then restores it for tap publication. + +The publisher returns the deterministic branch, pull request URL, and reconciled state. A successful first run returns `created`; a rerun while the same pull request remains open returns `open`; and a rerun after the exact cask reaches the tap's default branch returns `published`. The workflow never enables auto-merge or merges the pull request. + +The producer's `.goreleaser.yaml` must declare a `homebrew_casks` entry with `skip_upload: true`. The Go pre-publish workflow carries `dist/homebrew/Casks/*.rb` in the authoritative Actions artifact and formats generated casks with Homebrew before upload. It does not add the control file to the signed release payload set. + +### Optional macOS signing and notarization + +`.github/workflows/go-pre-publish.yml` accepts `sign-and-notarize-macos`, which defaults to `false`. Enabling it requires all five optional workflow secrets: + +- `macos-sign-p12`; +- `macos-sign-password`; +- `macos-notary-key`; +- `macos-notary-key-id`; +- `macos-notary-issuer-id`. + +The workflow fails before staging when any enabled credential is absent. GoReleaser uses Quill to sign and notarize every Darwin build, waits up to 20 minutes for Apple to accept each submission, and archives only accepted binaries. Apple rejection or timeout fails pre-publish, so neither the GitHub Release nor Homebrew publisher runs. + +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. + ## Signed release bundle verification `release-cli verify bundle` verifies the local release bundle before the GitHub Release workflow attests or uploads it. From 71aa9bc4e1401f4e06064840a48748ed6cea0d7e Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 20 Aug 2026 18:25:44 -0700 Subject: [PATCH 2/6] test(release): rehearse Homebrew producer credentials --- .../workflows/verify-homebrew-producer.yml | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 .github/workflows/verify-homebrew-producer.yml diff --git a/.github/workflows/verify-homebrew-producer.yml b/.github/workflows/verify-homebrew-producer.yml new file mode 100644 index 0000000..9b3de4c --- /dev/null +++ b/.github/workflows/verify-homebrew-producer.yml @@ -0,0 +1,169 @@ +name: Disposable Homebrew Producer Rehearsal + +on: + pull_request: + +permissions: {} + +jobs: + app-scope: + name: Verify tap app scope + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Create tap app token + id: tap-app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} + owner: meigma + repositories: homebrew-tap + permission-contents: write + permission-pull-requests: write + + - name: Read tap with scoped token + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + github-token: ${{ steps.tap-app.outputs.token }} + script: | + const tap = await github.rest.repos.get({owner: 'meigma', repo: 'homebrew-tap'}) + if (tap.data.full_name !== 'meigma/homebrew-tap') { + throw new Error(`Unexpected tap ${tap.data.full_name}.`) + } + + notarize-success: + name: Notarize both Darwin binaries + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + env: + GOTOOLCHAIN: local + MACOS_NOTARIZE_ENABLED: 'true' + MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} + MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} + MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + MISE_EXEC_AUTO_INSTALL: 'false' + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + filter: 'blob:none' + persist-credentials: false + + - name: Setup mise + uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 + with: + version: 2026.8.8 + install_args: >- + go + aqua:goreleaser/goreleaser + aqua:anchore/syft + cache: true + add_shims_to_path: false + export_path: false + + - name: Build signed and notarized snapshot + shell: bash + run: mise exec -- goreleaser release --snapshot --clean --skip=sign + + - name: Upload notarized Darwin archives + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: notarized-darwin + path: | + dist/*_darwin_amd64.tar.gz + dist/*_darwin_arm64.tar.gz + dist/homebrew/Casks/meigma-release-cli.rb + if-no-files-found: error + retention-days: 1 + compression-level: 0 + + signing-disabled: + name: Preserve credential-free build + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + env: + GOTOOLCHAIN: local + MACOS_NOTARIZE_ENABLED: 'false' + MISE_EXEC_AUTO_INSTALL: 'false' + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + filter: 'blob:none' + persist-credentials: false + + - name: Setup mise + uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 + with: + version: 2026.8.8 + install_args: >- + go + aqua:goreleaser/goreleaser + aqua:anchore/syft + cache: true + add_shims_to_path: false + export_path: false + + - name: Build without Apple credentials + shell: bash + run: mise exec -- goreleaser release --snapshot --clean --skip=sign + + apple-rejection: + name: Reject invalid Apple key + needs: notarize-success + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + env: + GOTOOLCHAIN: local + MACOS_NOTARIZE_ENABLED: 'true' + MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} + MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} + MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} + MACOS_NOTARY_KEY_ID: AAAAAAAAAA + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + MISE_EXEC_AUTO_INSTALL: 'false' + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + filter: 'blob:none' + persist-credentials: false + + - name: Setup mise + uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 + with: + version: 2026.8.8 + install_args: >- + go + aqua:goreleaser/goreleaser + aqua:anchore/syft + cache: true + add_shims_to_path: false + export_path: false + + - name: Require Apple rejection before archiving + shell: bash + run: | + set -euo pipefail + if mise exec -- goreleaser release --snapshot --clean --skip=sign; then + echo '::error::Invalid Apple credentials unexpectedly produced a release.' + exit 1 + fi + if compgen -G 'dist/*.tar.gz' >/dev/null; then + echo '::error::Apple rejection occurred after release archives were written.' + exit 1 + fi From 62dbe13c85ba185f8085cf5717d0484cdb67e4e3 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 20 Aug 2026 19:28:43 -0700 Subject: [PATCH 3/6] test(release): rehearse live tap publication --- .../workflows/verify-homebrew-producer.yml | 89 ++++++++++++++++--- 1 file changed, 77 insertions(+), 12 deletions(-) diff --git a/.github/workflows/verify-homebrew-producer.yml b/.github/workflows/verify-homebrew-producer.yml index 9b3de4c..07c4063 100644 --- a/.github/workflows/verify-homebrew-producer.yml +++ b/.github/workflows/verify-homebrew-producer.yml @@ -6,13 +6,35 @@ on: permissions: {} jobs: - app-scope: - name: Verify tap app scope + tap-publish: + name: Open valid tap pull request runs-on: ubuntu-24.04 - timeout-minutes: 5 + timeout-minutes: 10 permissions: contents: read + env: + GOTOOLCHAIN: local + MISE_EXEC_AUTO_INSTALL: 'false' steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + filter: 'blob:none' + persist-credentials: false + + - name: Setup mise + uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 + with: + version: 2026.8.8 + install_args: go + cache: true + add_shims_to_path: false + export_path: false + + - name: Build release CLI + shell: bash + run: mise exec -- go build -o release-cli ./cmd/release-cli + - name: Create tap app token id: tap-app uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 @@ -24,15 +46,58 @@ jobs: permission-contents: write permission-pull-requests: write - - name: Read tap with scoped token - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - github-token: ${{ steps.tap-app.outputs.token }} - script: | - const tap = await github.rest.repos.get({owner: 'meigma', repo: 'homebrew-tap'}) - if (tap.data.full_name !== 'meigma/homebrew-tap') { - throw new Error(`Unexpected tap ${tap.data.full_name}.`) - } + - name: Prepare released cask + shell: bash + run: | + mkdir -p dist/homebrew/Casks + cat >dist/homebrew/Casks/meigma-release-cli.rb <<'RUBY' + cask "meigma-release-cli" do + version "0.1.3" + + on_macos do + on_intel do + sha256 "5709c1fbf62ee121dfdf91d92a3c7ce5c4aaa566ee15a530067c6f161e7fc7a6" + url "https://github.com/meigma/release/releases/download/v0.1.3/release-cli_#{version}_darwin_amd64.tar.gz" + end + on_arm do + sha256 "f9ac64eb1b1191d58176216c4e9ccffe0cfce0ea39f219e9c65a429dcf5b2aaa" + url "https://github.com/meigma/release/releases/download/v0.1.3/release-cli_#{version}_darwin_arm64.tar.gz" + end + end + + on_linux do + on_intel do + sha256 "8d6cd431d18dc9e52f845bb096947f666e10a568f41873aa7e463e0e83920d0b" + url "https://github.com/meigma/release/releases/download/v0.1.3/release-cli_#{version}_linux_amd64.tar.gz" + end + on_arm do + sha256 "f45226f291519ae4e34fec9ab5ac0e4501225aad92ee3bdaa1f5d2f5c889059b" + url "https://github.com/meigma/release/releases/download/v0.1.3/release-cli_#{version}_linux_arm64.tar.gz" + end + end + + name "meigma-release-cli" + desc "Release automation for Meigma projects" + homepage "https://github.com/meigma/release" + livecheck do + skip "Disposable producer rehearsal." + end + binary "release-cli" + end + RUBY + + - name: Publish cask through tap pull request + shell: bash + env: + RELEASE_APP_TOKEN: ${{ steps.tap-app.outputs.token }} + run: | + GITHUB_REF_NAME=v0.1.3 \ + GITHUB_SHA=0fc99489d31d400bc3f69d6636d60e7d3f3d0251 \ + ./release-cli publish homebrew \ + --dist dist \ + --tap meigma/homebrew-tap \ + --cask meigma-release-cli \ + --json notarize-success: name: Notarize both Darwin binaries From 4ca09334f4a1bce5d70928573755ccc427d524b7 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 20 Aug 2026 19:32:58 -0700 Subject: [PATCH 4/6] test(release): format tap rehearsal cask --- .../workflows/verify-homebrew-producer.yml | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/verify-homebrew-producer.yml b/.github/workflows/verify-homebrew-producer.yml index 07c4063..411afb2 100644 --- a/.github/workflows/verify-homebrew-producer.yml +++ b/.github/workflows/verify-homebrew-producer.yml @@ -55,33 +55,34 @@ jobs: version "0.1.3" on_macos do - on_intel do - sha256 "5709c1fbf62ee121dfdf91d92a3c7ce5c4aaa566ee15a530067c6f161e7fc7a6" - url "https://github.com/meigma/release/releases/download/v0.1.3/release-cli_#{version}_darwin_amd64.tar.gz" - end on_arm do sha256 "f9ac64eb1b1191d58176216c4e9ccffe0cfce0ea39f219e9c65a429dcf5b2aaa" url "https://github.com/meigma/release/releases/download/v0.1.3/release-cli_#{version}_darwin_arm64.tar.gz" end - end - - on_linux do on_intel do - sha256 "8d6cd431d18dc9e52f845bb096947f666e10a568f41873aa7e463e0e83920d0b" - url "https://github.com/meigma/release/releases/download/v0.1.3/release-cli_#{version}_linux_amd64.tar.gz" + sha256 "5709c1fbf62ee121dfdf91d92a3c7ce5c4aaa566ee15a530067c6f161e7fc7a6" + url "https://github.com/meigma/release/releases/download/v0.1.3/release-cli_#{version}_darwin_amd64.tar.gz" end + end + on_linux do on_arm do sha256 "f45226f291519ae4e34fec9ab5ac0e4501225aad92ee3bdaa1f5d2f5c889059b" url "https://github.com/meigma/release/releases/download/v0.1.3/release-cli_#{version}_linux_arm64.tar.gz" end + on_intel do + sha256 "8d6cd431d18dc9e52f845bb096947f666e10a568f41873aa7e463e0e83920d0b" + url "https://github.com/meigma/release/releases/download/v0.1.3/release-cli_#{version}_linux_amd64.tar.gz" + end end name "meigma-release-cli" desc "Release automation for Meigma projects" homepage "https://github.com/meigma/release" + livecheck do skip "Disposable producer rehearsal." end + binary "release-cli" end RUBY From fd1afeedb5bd6bfd544cda5dee7ef68f9be3ea48 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 20 Aug 2026 19:35:00 -0700 Subject: [PATCH 5/6] test(release): isolate repeated tap rehearsal --- .github/workflows/verify-homebrew-producer.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/verify-homebrew-producer.yml b/.github/workflows/verify-homebrew-producer.yml index 411afb2..9b8db26 100644 --- a/.github/workflows/verify-homebrew-producer.yml +++ b/.github/workflows/verify-homebrew-producer.yml @@ -50,8 +50,8 @@ jobs: shell: bash run: | mkdir -p dist/homebrew/Casks - cat >dist/homebrew/Casks/meigma-release-cli.rb <<'RUBY' - cask "meigma-release-cli" do + cat >dist/homebrew/Casks/meigma-release-cli-rehearsal.rb <<'RUBY' + cask "meigma-release-cli-rehearsal" do version "0.1.3" on_macos do @@ -97,7 +97,7 @@ jobs: ./release-cli publish homebrew \ --dist dist \ --tap meigma/homebrew-tap \ - --cask meigma-release-cli \ + --cask meigma-release-cli-rehearsal \ --json notarize-success: From 13a36c76754380b8469a5ceac991bc75bd0b7ec2 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Thu, 20 Aug 2026 19:38:16 -0700 Subject: [PATCH 6/6] test(release): remove producer rehearsal --- .../workflows/verify-homebrew-producer.yml | 235 ------------------ 1 file changed, 235 deletions(-) delete mode 100644 .github/workflows/verify-homebrew-producer.yml diff --git a/.github/workflows/verify-homebrew-producer.yml b/.github/workflows/verify-homebrew-producer.yml deleted file mode 100644 index 9b8db26..0000000 --- a/.github/workflows/verify-homebrew-producer.yml +++ /dev/null @@ -1,235 +0,0 @@ -name: Disposable Homebrew Producer Rehearsal - -on: - pull_request: - -permissions: {} - -jobs: - tap-publish: - name: Open valid tap pull request - runs-on: ubuntu-24.04 - timeout-minutes: 10 - permissions: - contents: read - env: - GOTOOLCHAIN: local - MISE_EXEC_AUTO_INSTALL: 'false' - steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - filter: 'blob:none' - persist-credentials: false - - - name: Setup mise - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 - with: - version: 2026.8.8 - install_args: go - cache: true - add_shims_to_path: false - export_path: false - - - name: Build release CLI - shell: bash - run: mise exec -- go build -o release-cli ./cmd/release-cli - - - name: Create tap app token - id: tap-app - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.MEIGMA_RELEASE_APP_CLIENT_ID }} - private-key: ${{ secrets.MEIGMA_RELEASE_APP_PRIVATE_KEY }} - owner: meigma - repositories: homebrew-tap - permission-contents: write - permission-pull-requests: write - - - name: Prepare released cask - shell: bash - run: | - mkdir -p dist/homebrew/Casks - cat >dist/homebrew/Casks/meigma-release-cli-rehearsal.rb <<'RUBY' - cask "meigma-release-cli-rehearsal" do - version "0.1.3" - - on_macos do - on_arm do - sha256 "f9ac64eb1b1191d58176216c4e9ccffe0cfce0ea39f219e9c65a429dcf5b2aaa" - url "https://github.com/meigma/release/releases/download/v0.1.3/release-cli_#{version}_darwin_arm64.tar.gz" - end - on_intel do - sha256 "5709c1fbf62ee121dfdf91d92a3c7ce5c4aaa566ee15a530067c6f161e7fc7a6" - url "https://github.com/meigma/release/releases/download/v0.1.3/release-cli_#{version}_darwin_amd64.tar.gz" - end - end - on_linux do - on_arm do - sha256 "f45226f291519ae4e34fec9ab5ac0e4501225aad92ee3bdaa1f5d2f5c889059b" - url "https://github.com/meigma/release/releases/download/v0.1.3/release-cli_#{version}_linux_arm64.tar.gz" - end - on_intel do - sha256 "8d6cd431d18dc9e52f845bb096947f666e10a568f41873aa7e463e0e83920d0b" - url "https://github.com/meigma/release/releases/download/v0.1.3/release-cli_#{version}_linux_amd64.tar.gz" - end - end - - name "meigma-release-cli" - desc "Release automation for Meigma projects" - homepage "https://github.com/meigma/release" - - livecheck do - skip "Disposable producer rehearsal." - end - - binary "release-cli" - end - RUBY - - - name: Publish cask through tap pull request - shell: bash - env: - RELEASE_APP_TOKEN: ${{ steps.tap-app.outputs.token }} - run: | - GITHUB_REF_NAME=v0.1.3 \ - GITHUB_SHA=0fc99489d31d400bc3f69d6636d60e7d3f3d0251 \ - ./release-cli publish homebrew \ - --dist dist \ - --tap meigma/homebrew-tap \ - --cask meigma-release-cli-rehearsal \ - --json - - notarize-success: - name: Notarize both Darwin binaries - runs-on: ubuntu-24.04 - timeout-minutes: 30 - permissions: - contents: read - env: - GOTOOLCHAIN: local - MACOS_NOTARIZE_ENABLED: 'true' - MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} - MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} - MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} - MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} - MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} - MISE_EXEC_AUTO_INSTALL: 'false' - steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - fetch-depth: 0 - filter: 'blob:none' - persist-credentials: false - - - name: Setup mise - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 - with: - version: 2026.8.8 - install_args: >- - go - aqua:goreleaser/goreleaser - aqua:anchore/syft - cache: true - add_shims_to_path: false - export_path: false - - - name: Build signed and notarized snapshot - shell: bash - run: mise exec -- goreleaser release --snapshot --clean --skip=sign - - - name: Upload notarized Darwin archives - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: notarized-darwin - path: | - dist/*_darwin_amd64.tar.gz - dist/*_darwin_arm64.tar.gz - dist/homebrew/Casks/meigma-release-cli.rb - if-no-files-found: error - retention-days: 1 - compression-level: 0 - - signing-disabled: - name: Preserve credential-free build - runs-on: ubuntu-24.04 - timeout-minutes: 10 - permissions: - contents: read - env: - GOTOOLCHAIN: local - MACOS_NOTARIZE_ENABLED: 'false' - MISE_EXEC_AUTO_INSTALL: 'false' - steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - fetch-depth: 0 - filter: 'blob:none' - persist-credentials: false - - - name: Setup mise - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 - with: - version: 2026.8.8 - install_args: >- - go - aqua:goreleaser/goreleaser - aqua:anchore/syft - cache: true - add_shims_to_path: false - export_path: false - - - name: Build without Apple credentials - shell: bash - run: mise exec -- goreleaser release --snapshot --clean --skip=sign - - apple-rejection: - name: Reject invalid Apple key - needs: notarize-success - runs-on: ubuntu-24.04 - timeout-minutes: 10 - permissions: - contents: read - env: - GOTOOLCHAIN: local - MACOS_NOTARIZE_ENABLED: 'true' - MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} - MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} - MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} - MACOS_NOTARY_KEY_ID: AAAAAAAAAA - MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} - MISE_EXEC_AUTO_INSTALL: 'false' - steps: - - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - fetch-depth: 0 - filter: 'blob:none' - persist-credentials: false - - - name: Setup mise - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 - with: - version: 2026.8.8 - install_args: >- - go - aqua:goreleaser/goreleaser - aqua:anchore/syft - cache: true - add_shims_to_path: false - export_path: false - - - name: Require Apple rejection before archiving - shell: bash - run: | - set -euo pipefail - if mise exec -- goreleaser release --snapshot --clean --skip=sign; then - echo '::error::Invalid Apple credentials unexpectedly produced a release.' - exit 1 - fi - if compgen -G 'dist/*.tar.gz' >/dev/null; then - echo '::error::Apple rejection occurred after release archives were written.' - exit 1 - fi