diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ddf0922..9c166d7 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 + 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,24 @@ 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" + # 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 + exit 1 done - # created once here so the platform jobs can upload in parallel. Left to @@ -131,6 +179,13 @@ jobs: run: make ${{ matrix.target }} env: GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} + # 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 8d34e67..4fba759 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. What fixes that is the +# brews block below, 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..4ca2097 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 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) +— 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: