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.