From 37846054d7bff1da9cba53cca6aaf8260fac6784 Mon Sep 17 00:00:00 2001 From: Jon Hadfield Date: Wed, 2 Sep 2026 18:06:07 +0100 Subject: [PATCH 1/2] ship a homebrew formula rather than a cask. certreader installed from the tap does not run: $ certreader -version $ echo $? 137 Nothing is printed, so it reads as a broken build rather than a refused one. The bytes are fine: what homebrew installed is identical to the release archive, and the same bytes run when extracted by hand, because extracting by hand does not set com.apple.quarantine. Homebrew marks what a cask installs with that attribute, and gatekeeper kills a quarantined binary that carries no stapled notarization ticket. A formula is not quarantined, which is how every other go command line tool in homebrew arrives able to run. Formula binaries on this machine carry no quarantine attribute at all; the cask's certreader does. Signing and notarizing the binary was the first attempt at this and does not settle it. There is nowhere to staple a ticket to in a bare executable, stapler wanting an app bundle, a disk image or an installer package, and on macos 26.5 a binary signed with a Developer ID certificate and accepted by the notary service was still killed under quarantine on three clean attempts across ten minutes, with spctl calling it an unnotarized Developer ID and syspolicy_check saying the ticket was missing. An earlier attempt appeared to succeed and was a false pass: the kill puts a gatekeeper dialog on screen, and approving one sets a bit in the attribute that lets that binary through afterwards, which homebrew reads back as Quarantine::USER_APPROVED_FLAG. The signing configuration stays, off unless a certificate is configured, because it is worth having and is what a stapled package would be built on. Preflight fails a half-configured set rather than publishing quietly unsigned, since goreleaser skips signing when the certificate is absent. goreleaser calls brews deprecated in favour of homebrew_casks and fails its own check for a deprecation as well as for a mistake. Preflight now tells those apart by goreleaser's wording rather than dropping the check. Also documents brew trust, without which homebrew 6 refuses to load anything from a third-party tap, and how to replace an installed cask. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WcPAJjNzG6bqy2FKqKKY1v --- .github/workflows/release.yml | 53 ++++++++++++++++++++++- .goreleaser/.goreleaser.darwin.yml | 41 +++++++++++++++--- README.md | 67 +++++++++++++++++++++++++++--- 3 files changed, 149 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ddf0922..ea28478 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,37 @@ jobs: exit 1 fi echo "RELEASE_TOKEN is configured" + - + # Signing is optional: what makes a homebrew install run is shipping a + # formula rather than a cask, since a cask quarantines what it installs. + # Half-configured is worth saying out loud though, because goreleaser + # silently skips signing when the certificate is absent. + name: check macos signing credentials + env: + MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} + MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} + run: | + missing="" + present="" + for name in MACOS_SIGN_P12 MACOS_SIGN_PASSWORD MACOS_NOTARY_ISSUER_ID MACOS_NOTARY_KEY_ID MACOS_NOTARY_KEY; do + eval "value=\$$name" + if [ -z "$value" ]; then + missing="$missing $name" + else + present="$present $name" + fi + done + if [ -z "$present" ]; then + echo "the darwin binaries will not be signed, which is supported: see the release section of the README" + elif [ -n "$missing" ]; then + echo "::error::macos signing is half configured, missing:$missing. goreleaser skips signing entirely when the certificate is absent, so set all five or none of them." + exit 1 + else + echo "macos signing credentials are configured" + fi - name: install goreleaser uses: goreleaser/goreleaser-action@v6 @@ -45,7 +76,20 @@ jobs: run: | for config in .goreleaser/.goreleaser.*.yml; do echo "checking $config" - goreleaser check --config "$config" + # `brews` is deprecated in favour of homebrew_casks, which is not + # usable here: a cask quarantines what it installs and the binary + # is then killed. goreleaser fails the check for a deprecation as + # well as for a mistake, so let its own wording tell them apart. + if out="$(goreleaser check --config "$config" 2>&1)"; then + echo "$out" + continue + fi + echo "$out" + if echo "$out" | grep -q "configuration is valid, but uses deprecated properties"; then + echo "::warning::$config uses deprecated goreleaser properties, tolerated because the replacement quarantines what it installs" + continue + fi + exit 1 done - # created once here so the platform jobs can upload in parallel. Left to @@ -131,6 +175,13 @@ jobs: run: make ${{ matrix.target }} env: GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} + # only the darwin target reads these; the others are built in a + # container that never sees them + MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} + MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} - # the linux and windows targets build as root inside the container, so # the archives come back owned by root and unreadable to the next step diff --git a/.goreleaser/.goreleaser.darwin.yml b/.goreleaser/.goreleaser.darwin.yml index 8d34e67..fc52e3e 100644 --- a/.goreleaser/.goreleaser.darwin.yml +++ b/.goreleaser/.goreleaser.darwin.yml @@ -34,22 +34,53 @@ archives: formats: - tar.gz +# Signing the binaries with a Developer ID certificate, for anyone who would +# rather have it than not. It is off unless the certificate is configured, and +# it is not what makes a homebrew install run: a bare binary has nowhere to +# staple a notarization ticket, and on macos 26 an unstapled ticket did not +# satisfy gatekeeper for a quarantined binary in testing. The formula above is +# what fixes that, by not being quarantined in the first place. +notarize: + macos: + - enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}' + ids: + - darwin-arm64 + - darwin-amd64 + 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 }}" + # the archives are uploaded from this same run, so the binaries they + # carry must be signed and accepted before it moves on + wait: true + timeout: 20m + checksum: name_template: "checksums_darwin.txt" -homebrew_casks: +# A formula rather than a cask, because homebrew marks what a cask installs +# com.apple.quarantine and gatekeeper then kills a bare binary that carries no +# stapled notarization ticket, which a bare binary has nowhere to keep. A +# formula is not quarantined, which is how every other go command line tool in +# homebrew arrives able to run. +# +# goreleaser calls this deprecated in favour of homebrew_casks. The cask is what +# certreader used until it turned out not to run when installed, so this stays +# until there is somewhere to staple a ticket to. +brews: - name: certreader # IMPORTANT: these are *archive* IDs, not build IDs: ids: - darwin - binaries: - - certreader homepage: https://github.com/jonhadfield/certreader description: Output detailed information about TLS certificates... - commit_msg_template: "Brew cask update for {{ .ProjectName }} {{ .Tag }}" + commit_msg_template: "Brew formula update for {{ .ProjectName }} {{ .Tag }}" # a prerelease must not become what brew installs skip_upload: "auto" - directory: Casks + directory: Formula repository: owner: jonhadfield name: homebrew-certreader diff --git a/README.md b/README.md index f693e52..4542b8c 100644 --- a/README.md +++ b/README.md @@ -565,8 +565,22 @@ workflow; it does not say the source is good. ### brew -- brew tap jonhadfield/certreader -- brew install --cask certreader +```shell script +brew tap jonhadfield/certreader +brew trust jonhadfield/certreader +brew install certreader +``` + +Homebrew 6 refuses to load anything from a third-party tap until the tap is trusted, so `brew trust` +comes before the install rather than after it fails. + +certreader was a cask until v0.24.0 and is a formula from v0.25.0. If you installed the cask, replace +it once: + +```shell script +brew uninstall --cask certreader +brew install certreader +``` ### go @@ -607,7 +621,7 @@ git tag -a -m "add super cool feature" v1.0.0 git push --follow-tags ``` -### required secret +### required secrets Platform builds run in parallel, so a release takes about as long as its slowest target rather than the sum of all five. A prerelease tag (one containing a hyphen, such as `v1.0.0-rc1`) is published as @@ -617,12 +631,53 @@ Release notes come from the annotated tag message, so write the tag with the not The workflow needs a `RELEASE_TOKEN` repository secret: a personal access token with `repo` scope on both `jonhadfield/certreader` and `jonhadfield/homebrew-certreader`. The token built into Actions -cannot write to another repository, and the darwin build pushes the cask update to the tap. Without -the secret the workflow stops at its preflight job and publishes nothing. +cannot write to another repository, and the darwin build pushes the cask update to the tap. + +Signing the darwin binaries is optional, and off unless all five of these are set. It is not what +makes an install work — the formula is, see [why a formula and not a cask](#why-a-formula-and-not-a-cask) +— so treat it as something to have rather than something to fix: + +| secret | what it is | +| --- | --- | +| `MACOS_SIGN_P12` | base64 of the Developer ID Application certificate, exported as `.p12` | +| `MACOS_SIGN_PASSWORD` | the password that `.p12` was exported with | +| `MACOS_NOTARY_KEY` | base64 of an App Store Connect `.p8` key | +| `MACOS_NOTARY_KEY_ID` | that key's id, also in its filename | +| `MACOS_NOTARY_ISSUER_ID` | the issuer uuid shown when the key was created | + +```shell script +gh secret set MACOS_SIGN_P12 --repo jonhadfield/certreader < <(base64 -i DeveloperID.p12) +gh secret set MACOS_NOTARY_KEY --repo jonhadfield/certreader < <(base64 -i AuthKey_XXXXXXXXXX.p8) +``` + +Without `RELEASE_TOKEN` the workflow stops at its preflight job and publishes nothing. The signing +secrets are all-or-nothing: GoReleaser skips signing entirely when the certificate is absent, so +preflight fails a half-configured set rather than letting it publish quietly unsigned. -Run the workflow manually from the Actions tab to check the secret and the GoReleaser configs +Run the workflow manually from the Actions tab to check the secrets and the GoReleaser configs without cutting a tag; a manual run stops after preflight. +### why a formula and not a cask + +Homebrew marks what a **cask** installs with `com.apple.quarantine`, and gatekeeper kills a +quarantined binary that carries no stapled notarization ticket. The command exits 137 and prints +nothing, which reads like a broken build rather than a refused one: + +```shell script +$ certreader -version +$ echo $? +137 +``` + +A bare executable has nowhere to keep a ticket — `stapler` needs an app bundle, a disk image or an +installer package — so signing and notarizing the binary does not settle it. On macOS 26 a notarized +but unstapled binary was still refused in testing. + +A **formula** is not quarantined, which is how every other Go command line tool in Homebrew arrives +able to run. GoReleaser calls `brews` deprecated in favour of `homebrew_casks`; the cask is what +certreader shipped as up to v0.24.0, and it did not run when installed, so the formula stays until +there is something to staple a ticket to. + ### releasing by hand The same builds can be run locally, which is useful when debugging a release failure: From dcbd2cf588e92fc3093100bd48f9b332617a2e4c Mon Sep 17 00:00:00 2001 From: Jon Hadfield Date: Wed, 2 Sep 2026 18:42:02 +0100 Subject: [PATCH 2/2] address the review of the formula change. Two of these were wrong rather than merely improvable. A comment placed above the notarize block said the formula "above" fixed the install when the brews block is below it, and the README still described the darwin build as pushing a cask update to the tap, which is the thing this branch stops doing. The preflight match no longer turns on one exact sentence from goreleaser. It asks whether the output says DEPRECATED and does not say the configuration is invalid, so a reworded deprecation still reads as one. Wording that changes past recognising fails the release rather than waving something through, and the check is exercised both ways: a deprecated config is tolerated, an invalid one is not. The signing credentials now reach only the job that signs. The linux and windows targets never read them, and they are built in a container that was never handed them, but there is no reason for them to be in that environment at all. Indirect expansion replaces eval for reading a variable by name, which is what bash is for. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WcPAJjNzG6bqy2FKqKKY1v --- .github/workflows/release.yml | 22 +++++++++++++--------- .goreleaser/.goreleaser.darwin.yml | 4 ++-- README.md | 2 +- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea28478..9c166d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,7 +51,7 @@ jobs: missing="" present="" for name in MACOS_SIGN_P12 MACOS_SIGN_PASSWORD MACOS_NOTARY_ISSUER_ID MACOS_NOTARY_KEY_ID MACOS_NOTARY_KEY; do - eval "value=\$$name" + value="${!name}" if [ -z "$value" ]; then missing="$missing $name" else @@ -85,7 +85,11 @@ jobs: continue fi echo "$out" - if echo "$out" | grep -q "configuration is valid, but uses deprecated properties"; then + # matched on goreleaser saying DEPRECATED and not saying the + # configuration is invalid, rather than on one exact sentence. If + # the wording changes past recognising, this fails the release + # rather than waving something through. + if echo "$out" | grep -q "DEPRECATED" && ! echo "$out" | grep -q "configuration is invalid"; then echo "::warning::$config uses deprecated goreleaser properties, tolerated because the replacement quarantines what it installs" continue fi @@ -175,13 +179,13 @@ jobs: run: make ${{ matrix.target }} env: GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} - # only the darwin target reads these; the others are built in a - # container that never sees them - MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} - MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} - MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} - MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} - MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} + # only the darwin target signs, so only that job is given the + # credentials to do it with + MACOS_SIGN_P12: ${{ matrix.target == 'release-mac' && secrets.MACOS_SIGN_P12 || '' }} + MACOS_SIGN_PASSWORD: ${{ matrix.target == 'release-mac' && secrets.MACOS_SIGN_PASSWORD || '' }} + MACOS_NOTARY_ISSUER_ID: ${{ matrix.target == 'release-mac' && secrets.MACOS_NOTARY_ISSUER_ID || '' }} + MACOS_NOTARY_KEY_ID: ${{ matrix.target == 'release-mac' && secrets.MACOS_NOTARY_KEY_ID || '' }} + MACOS_NOTARY_KEY: ${{ matrix.target == 'release-mac' && secrets.MACOS_NOTARY_KEY || '' }} - # the linux and windows targets build as root inside the container, so # the archives come back owned by root and unreadable to the next step diff --git a/.goreleaser/.goreleaser.darwin.yml b/.goreleaser/.goreleaser.darwin.yml index fc52e3e..4fba759 100644 --- a/.goreleaser/.goreleaser.darwin.yml +++ b/.goreleaser/.goreleaser.darwin.yml @@ -38,8 +38,8 @@ archives: # rather have it than not. It is off unless the certificate is configured, and # it is not what makes a homebrew install run: a bare binary has nowhere to # staple a notarization ticket, and on macos 26 an unstapled ticket did not -# satisfy gatekeeper for a quarantined binary in testing. The formula above is -# what fixes that, by not being quarantined in the first place. +# satisfy gatekeeper for a quarantined binary in testing. What fixes that is the +# brews block below, by not being quarantined in the first place. notarize: macos: - enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}' diff --git a/README.md b/README.md index 4542b8c..4ca2097 100644 --- a/README.md +++ b/README.md @@ -631,7 +631,7 @@ Release notes come from the annotated tag message, so write the tag with the not The workflow needs a `RELEASE_TOKEN` repository secret: a personal access token with `repo` scope on both `jonhadfield/certreader` and `jonhadfield/homebrew-certreader`. The token built into Actions -cannot write to another repository, and the darwin build pushes the cask update to the tap. +cannot write to another repository, and the darwin build pushes the formula update to the tap. Signing the darwin binaries is optional, and off unless all five of these are set. It is not what makes an install work — the formula is, see [why a formula and not a cask](#why-a-formula-and-not-a-cask)