Skip to content

Automate releases via tag-triggered workflow - #332

Open
alex-vulncheck wants to merge 2 commits into
mainfrom
3338-automate-cli-releases
Open

Automate releases via tag-triggered workflow#332
alex-vulncheck wants to merge 2 commits into
mainfrom
3338-automate-cli-releases

Conversation

@alex-vulncheck

@alex-vulncheck alex-vulncheck commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Replaces the manual release process with a tag-triggered workflow.

Why

.github/workflows/deployment.yml has never run, and could not work if it did. It is an unmodified fork of github/cli's release pipeline:

  • references six files that do not exist in this repo (script/sign.ps1, script/label-assets, script/createrepo.sh, script/rpmmacros, script/distributions, build/windows/gh.wixproj)
  • globs dist/gh_*, but GoReleaser here emits vulncheck_*; combined with shopt -s failglob, every asset step hard-fails
  • pushes documentation to github/cli.github.com and a Homebrew formula to a third-party homebrew-core fork
  • expects roughly a dozen secrets and two deployment environments that were never configured

So releases have in practice been built and uploaded by hand, one machine, about ten steps, with signature and notarization success confirmed manually each time.

What this does

Releases are now cut with:

script/tag-release v1.2.0

That validates (semver, on main, clean tree, in sync with origin/main, tag unused), confirms, tags and pushes. The tag triggers .github/workflows/release.yml.

One macos-latest runner builds all nine artifacts. codesign and notarytool are macOS-only, and osslsigncode runs there too, so github/cli's three-runner matrix — which exists because they sign Windows with Azure signtool — is unnecessary here. Cross-compilation is CGO-free, which test.yml already documents as the assumption.

Publishing is the last step, after verification, so a failed signature or notarization leaves nothing public.

script/verify-release

Enforces what was previously a manual checklist: archive set, checksum manifest, Developer ID authority, secure timestamp, Gatekeeper assessment against a quarantined copy, embedded version, and Windows signature digest plus RFC3161 timestamp.

Asserting on the authority matters: codesign --verify on its own is satisfied by the ad-hoc signature the Go toolchain applies to arm64 binaries, so an unsigned build would otherwise pass.

script/sign hardening

  • notarytool runs with --wait and fails on any non-Accepted status, instead of returning as soon as the upload finishes
  • osslsigncode passes -ts, so signatures survive certificate rollover — Authenticode validates against current time, not signing time, without a countersignature
  • the signing certificate is checked for validity before use
  • REQUIRE_SIGNING=1 makes a missing credential an error, so a misconfigured secret cannot silently ship unsigned binaries

Other

  • publishes vulncheck_X.Y.Z_checksums.txt, which was being built and discarded, and attests build provenance for it
  • .goreleaser.yml migrated to schema v2 (archives.formatformats, archives.buildsids); goreleaser check previously failed on deprecated properties
  • drops the unused nfpms deb/rpm block
  • deletes deployment.yml, script/release, script/sign-old

Verification

  • goreleaser check passes on the migrated config
  • full snapshot build produces all nine artifacts plus checksums, correct names, man pages and completions bundled
  • script/verify-release rejects an unsigned snapshot (13 failures, non-zero exit) and passes all five macOS assertions against the real signed v1.1.0 artifact — no false positives or negatives
  • signing hooks still skip cleanly with no credentials, so local development is unaffected

Not yet exercised: keychain import, osslsigncode installation, and notarization in CI can only be proven by a real run. Intended shakedown is a prerelease tag (v1.1.1-rc.1), which also covers the --prerelease branch.

Note on Windows signing

The workflow requires a currently-valid Windows code-signing certificate and fails fast if it is not, rather than emitting a signature that will not be trusted.

Releases were cut by hand from a single machine in ~10 steps. The workflow
that was supposed to do this, deployment.yml, has never run and could not
work: it is an unmodified fork of github/cli's pipeline, referencing six
files that do not exist, globbing dist/gh_* where GoReleaser emits
vulncheck_*, and pushing to github/cli.github.com and a third-party
homebrew-core fork.

Releases are now cut with `script/tag-release vX.Y.Z`, which validates and
pushes a tag. The tag triggers release.yml, which builds all nine artifacts
on a single macos-latest runner. codesign and notarytool are macOS-only and
osslsigncode also runs there, so github/cli's three-runner matrix (needed
because they sign Windows with Azure signtool) is unnecessary here.

Publishing happens last, after verification, so a failed signature or
notarization leaves nothing public.

script/verify-release enforces what was previously a manual checklist:
archive set, checksum manifest, Developer ID authority, secure timestamp,
Gatekeeper assessment against a quarantined copy, embedded version, and
Windows signature digest plus RFC3161 timestamp. Asserting on the authority
matters because codesign --verify alone is satisfied by the ad-hoc signature
the Go toolchain applies to arm64 binaries.

script/sign is hardened: notarytool now runs with --wait and fails on any
non-Accepted status rather than returning as soon as the upload completes,
osslsigncode passes -ts so signatures survive certificate expiry, the
certificate is checked for expiry before signing, and REQUIRE_SIGNING=1
turns a missing credential into an error so a misconfigured secret cannot
silently ship unsigned binaries.

Also publishes the checksum manifest, which was being built and discarded,
and attests build provenance for it.

.goreleaser.yml moves to schema v2, dropping deprecated archives.format and
archives.builds, and the unused nfpms block goes with it.
@alex-vulncheck alex-vulncheck self-assigned this Aug 20, 2026
A single hardcoded timestamp authority could block a release on its own,
since signing now refuses to emit an untimestamped signature. Try each
authority in turn until one returns a usable token.

GlobalSign is first because it issued the signing certificate, so it adds
no trust relationship we do not already depend on; the others are there
for an outage. Timestamping cannot be self-hosted — Windows only honours a
token whose TSA certificate carries the Time Stamping EKU and chains to a
root in the Microsoft Trusted Root Program.

Each attempt is accepted only once the timestamp is confirmed present in
the output, so an authority that answers without returning a usable token
falls through instead of yielding a signature that dies with the
certificate.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant