This document is the family-wide standard for getting a built binary to end
users — the goreleaser build, the pre-publish safety gate, the artifact
naming, and the package channels. It begins where release.md ends: a version
tag exists and release.yml has fired.
This is normative for new CLIs. It supersedes the older root-level guides
platform-distribution.md and linux-distribution.md, which are stale
(anchored on confluence-cli PR #96, since folded into atlassian-cli).
Companion pillars:
release.md— mints the tag this doc consumes. This doc defers torelease.mdfor the tag/version contract and never re-defines it.repo-layout.md— owns.goreleaser.*placement and the Go-version the build uses.working-with-secrets.md§1.4 — the credstore Keychain backend that makes the §2 CGO gate load-bearing.
The kept channels: macOS Homebrew (cask), Windows winget, Windows
chocolatey, Linux apt/.deb, Linux rpm, and Arch Linux/pacman. Snap is being decommissioned (§7).
The build produces six binaries: {darwin, linux, windows} × {amd64, arm64}. Archives are tar.gz (Unix) and zip (Windows).
The CGO split is mandatory for any CLI using the credstore Keychain backend:
- darwin build IDs set
CGO_ENABLED=1(linksSecurity.frameworkfor Keychain). - linux + windows build IDs set
CGO_ENABLED=0(static, pure-Go keyring backends).
This split is the single most error-prone part of the goreleaser config; CI
exercises all three OSes pre-merge (ci.md §4) and §2 verifies darwin at
release.
Before publishing darwin artifacts, release.yml MUST verify the macOS build
actually has working Keychain support — two checks, both observed in
slack-chat-api/newrelic-cli and both required:
- Link check —
otool -Lthe amd64 binary and confirm it is dynamically linked against/System/Library/Frameworks/Security.framework; fail the release if absent. - Functional check — run the arm64 binary in a hermetic
HOME/XDG_CONFIG_HOME(with<SERVICE>_KEYRING_BACKENDunset) and assert the keyring resolves to Keychain. The command and assertions are manifest-driven — the identity manifest'skeychain_probe(§8), not hardcoded:slck/nrqrun<bin> --output json config showand assert.backend == "keychain"and.backend_source == "auto"(the JSON field isbackend_source, notsource);atlassian-clitext-grepsconfig show. A CLI whoseconfig showcan't surface the backend declares its own probe in the manifest.
Runner: a single Apple Silicon macOS runner satisfies both — otool -L
statically inspects the amd64 Mach-O (it reads the file, does not execute it,
so no Rosetta is needed) and the arm64 binary runs natively. No separate Intel
(macos-13) leg is required for this gate. Pin the runner to the current
known-good label (e.g. macos-15) rather than the moving macos-latest, as the
release workflows already do.
This is regression insurance from the credstore keyring saga: a silently
CGO_ENABLED=0 darwin build compiles and passes tests but ships without
Keychain support, breaking every macOS user on upgrade. The link check proves
the symbol is bound; the functional check proves it actually resolves. Any CLI
using credstore's Keychain backend MUST gate the release on both.
macOS Keychain "Always Allow" grants do not trust a file — they store a
designated requirement (DR) in the Keychain item's ACL and re-check the calling
binary against it on every access. An ad-hoc-signed binary (Go's linker
ad-hoc-signs arm64; Homebrew ad-hoc-signs on install) has the DR cdhash H"…" — the
literal content hash — so every brew upgrade produces a new hash, the DR no longer
matches, and the user is re-prompted. Any CLI whose keychain_probe (§8) selects the
macOS Keychain backend has this problem.
credstore reads Keychain secrets through the fixed, system-signed /usr/bin/security executable while retaining native writes and management. That reduces rebuild prompts for consumers that adopt this version of the library; release signing remains required for direct Keychain users and for other native accesses by a CLI. A newly trusted helper may require one grant per existing item. Normal CLI operation must not rewrite credentials with security -i; the dedicated repair-macos-keychain-credentials.sh script uses it only when repairing an existing item's ACL.
Standard: sign every darwin binary at release time with the family's single
self-signed code-signing cert and a constant per-tool identifier. That makes the DR
identifier "org.open-cli-collective.<binary>" and certificate leaf = H"<cert hash>"
— no cdhash — so the grant survives rebuilds as long as the cert and identifier
never change. No Apple Developer account, no notarization: Homebrew binaries aren't
quarantined and we establish a local identity, not Gatekeeper trust — so codesign
MUST omit --timestamp and --options runtime.
Invariants (unforgiving):
- The signing cert is generated once and reused forever. Never generate a cert in CI — a fresh cert changes the leaf hash and re-breaks every existing grant.
- Cert validity is long but finite. The DR pins the leaf hash, not expiry, so an
expired cert does not break existing grants — but
codesignrefuses to sign new builds with an expired cert. Replacing the cert is its only expiry remedy, and that new leaf hash re-breaks every grant (one re-prompt per user), so pick a long life up front and don't let it lapse mid-life (current cert expires 2036). - The identifier is
org.open-cli-collective.<binary>, constant per tool across all versions. Changing it changes the DR.
Where the logic lives (centralized — a repo adds one line). Signing MUST run inside
goreleaser's build lifecycle, in the per-build hooks.post (after each binary is
linked, before it is archived/checksummed — signing the loose dist/ binary
afterward would leave the shipped tarball unsigned). The script, cert import, identifier
derivation, and DR verification are centralized in open-cli-collective/.github:
actions/macos-codesign-setup writes the canonical codesign-darwin.sh and exports
CODESIGN_DARWIN_SCRIPT; actions/darwin-gate check-signature enforces the DR. Each
darwin build adds exactly one byte-identical hook:
hooks:
post:
- cmd: bash -c 'f="${CODESIGN_DARWIN_SCRIPT:-}"; if [ -z "$f" ]; then echo "skip codesign (CODESIGN_DARWIN_SCRIPT unset, local build)"; exit 0; fi; [ -x "$f" ] || { echo "CODESIGN_DARWIN_SCRIPT not executable ($f)" >&2; exit 1; }; exec "$f" "$0" "$1"' "{{ .Path }}" "{{ .Os }}"The hook uses the absolute $CODESIGN_DARWIN_SCRIPT (a build hook's CWD is the
build's dir:, e.g. tools/cfl, so a repo-relative path would miss) and is
fail-loud: env unset → skip (the local-build / opt-out path); env set but the
script is missing or non-executable → error and fail the build, rather than silently
shipping an unsigned binary in a release that intended to sign. Signing setup and check-signature enforcement
are both self-gated on whether the cert secrets were passed (secrets.* is not
available in if:, so the gate lives inside the composites, keyed on their inputs):
both off for a caller that hasn't opted in, both on the moment it passes the four
secrets — which keeps a rolling @v1 bump from breaking untouched callers.
Verification (the acceptance criterion). check-signature parses only the
designated => … requirement line of codesign -d -r- and fails the release unless it
pins certificate leaf = H"<MACOS_CERT_LEAF_SHA>" and identifier "org.open-cli-collective.<binary>" with no cdhash. Never grep the whole verbose
dump — a valid signature still prints CDHash= metadata, which is not the
requirement-language cdhash.
Expect one final re-prompt per machine per tool the first time a stable-signed build replaces the old ad-hoc one (the Keychain item still holds the old ad-hoc DR); the user grants once more and it then sticks. Note this in the release notes.
- Published to the shared tap
open-cli-collective/homebrew-tapasCasks/<canonical_cask>.rb(the manifest'spackages.homebrew.canonical_cask; new CLIs set it to the binary short name, e.g.slck— grandfathered tools may differ). - A cask, not a formula — we ship a prebuilt binary, not a source build. The
cask also removes any quarantine metadata for the non-notarized binary (signed
per §2A, but not notarized). The tap's
Formula/directory is deprecated (cask-only since 2026-01-16); new CLIs MUST NOT add a formula, and the survivingFormula/*.rbare legacy remnants (§10). - Standard: goreleaser
homebrew_casks. goreleaser owns the canonical cask — URL/checksum wiring,caveats, and install hooks (slck's Gatekeeperxattr -dr com.apple.quarantinestep is expressible as a cask hook). It is the single place release logic lives; see goreleaser'shomebrew_casksdocs. - Alias casks via a thin post-step.
alternative_namesis goreleaser Pro only, so a tool with an alias (slck→slack-chat-cli,jtk→jira-ticket-cli) needs a post-step for the extra token/name. Implementation (atomic form): goreleaser renders the canonical cask but does not push it (skip_upload: true); a single post-step then commits the goreleaser-rendered canonical cask plus the alias copies to the tap in one commit/push. The alias copies take the canonical cask's URL/checksum verbatim (never recompute) and differ only in token/name. One atomic push means there is no window where the canonical cask is live but its alias isn't; the post-step MUST fail visibly on any push error — it MUST NOT setcontinue-on-error: trueand MUST exit non-zero so the release job fails (a swallowed error here leaves the tap with no cask at all). - The push to the tap uses the dedicated
TAP_GITHUB_TOKEN(§6). - Current split (divergences, §10):
google-readonly,salesforce-cli,hubspot-cli, and both atlassian tools already use goreleaserhomebrew_casks;slack-chat-apiandnewrelic-clihand-roll the cask via heredoc inrelease.yml, andnewrelic-cliadditionally regenerates aFormula/newrelic-cli.rb. The reusable workflow standardizes on goreleaser casks + the alias post-step; the heredocs (and the nrq formula) are removed on migration.
Three-manifest YAML (version / installer / locale) submitted to
microsoft/winget-pkgs via winget-publish.yml; test-winget.yml validates
the manifests first. The version comes from the tag (release.md).
A .nuspec + install script published via chocolatey-publish.yml, validated
by test-chocolatey.yml. Chocolatey runs automated moderation on every
submission; the package MUST satisfy the CPMR rule series — principally
checksums on all downloaded artifacts, reachable/authoritative download URLs,
and complete package metadata. The exact rule set is encoded in the existing
chocolatey-publish.yml; when authoring the reusable workflow, lift the current
checks verbatim rather than re-deriving them.
This channel is live and automated — verified 2026-05-29: the shared repo
holds signed packages for slck, jtk, cfl, sfdc, and google-readonly
(gro's Linux package uses the long repo name, not the binary — a grandfathered
divergence, §10), plus nrq / hspt by config.
goreleaser's nfpms block builds .deb, .rpm, and .pkg.tar.zst. Standard shape:
nfpms:
- package_name: <binary>
vendor: Open CLI Collective
maintainer: Open CLI Collective <https://github.com/open-cli-collective>
description: <one line>
license: MIT
formats: [deb, rpm, archlinux]
mtime: "{{ .CommitDate }}"
bindir: /usr/bin
contents:
- src: LICENSE
dst: /usr/share/licenses/<binary>/LICENSEOmarchy uses Arch's pacman package manager. Publish native .pkg.tar.zst
packages through the existing linux-packages repository, with the same
package-release dispatch used for APT/RPM. Its receiver reads .PKGINFO,
places packages under arch/x86_64 or arch/aarch64, signs each package and
repository database, and deploys the result through GitHub Pages.
Use archlinux in every Go CLI's nFPM formats. Preserve each manifest's Linux
package name, including the existing Google and Spotify names; executable names
remain unchanged. Users enroll the signing key and repository once using the
repository instructions,
then install with pacman -Syu <package> and update with pacman -Syu.
The AUR is an optional discovery channel; it is not required for this binary
repository. Retune's Tauri pipeline packages the same validated Linux payload
with explicit Arch desktop dependencies before using the same receiver.
Use whole-second package timestamps (mtime: "{{ .CommitDate }}" in
GoReleaser) so tar entries and pacman MTREE integrity metadata agree. Validate
both architectures and exercise installation, file ownership/integrity,
uninstall, and reinstall in a clean Arch container. Packaging config changes
must be included in automatic-release path gates per release.md §3 so packaging-only fixes ship;
monorepos must include each tool’s config in both release and tool path scopes.
release.yml has a linux-packages job that hands the built packages off to
the shared repo via repository_dispatch:
-
action:
peter-evans/repository-dispatch -
repository:
open-cli-collective/linux-packages -
event-type:
package-release -
client-payload:
{ "package": "<binary>", "version": "${{ github.ref_name }}", "repo": "open-cli-collective/<repo>" } -
token:
LINUX_PACKAGES_DISPATCH_TOKEN -
continue-on-error: true— a publish hiccup MUST NOT fail the release. But the failure MUST still be surfaced, not swallowed (release.md§4.1). Surface it concretely — a follow-up step keyed on the dispatch step's outcome:- if: steps.linux_dispatch.outcome == 'failure' run: echo '::warning::linux-packages dispatch failed — packages not refreshed' >> "$GITHUB_STEP_SUMMARY"
Its receive-package.yml downloads the .deb/.rpm from the source release,
GPG-signs them (LINUX_PACKAGES_GPG_PRIVATE_KEY / …_PASSPHRASE), rebuilds
APT metadata with reprepro and RPM metadata with createrepo_c,
commits, and deploys GitHub Pages. The public signing key lives at
keys/gpg.asc. End users add the apt/rpm repo URL + that key.
A new CLI's only obligation here is §5.1 (emit deb/rpm) + §5.2 (dispatch);
linux-packages is the shared sink and needs no per-CLI changes beyond being
listed in its README.
| Secret | Used for |
|---|---|
RELEASE_TAG_TOKEN (or a GitHub App token) |
the tag re-trigger (release.md §3.1) |
TAP_GITHUB_TOKEN |
push the cask to homebrew-tap (§3) |
CHOCOLATEY_API_KEY |
chocolatey publish (§4.2) |
WINGET_GITHUB_TOKEN |
winget-pkgs submission (§4.1) |
LINUX_PACKAGES_DISPATCH_TOKEN |
the §5.2 repository_dispatch |
MACOS_CERT_P12 |
base64 of the code-signing cert+key .p12 (§2A) |
MACOS_CERT_PASSWORD |
the .p12 export password (§2A) |
MACOS_CERT_CN |
the cert Common Name = the codesign --sign identity (§2A) |
MACOS_CERT_LEAF_SHA |
the leaf SHA-1 the DR must pin, asserted by check-signature (§2A) |
The GPG signing keys (LINUX_PACKAGES_GPG_*) live in the linux-packages repo,
not in the CLI repos. TAP_GITHUB_TOKEN is intentionally scoped to Homebrew tap
publishing; auto-release tag pushes use RELEASE_TAG_TOKEN or a GitHub App
token and MUST NOT reuse the tap credential.
The four MACOS_CERT_* secrets (§2A) are org-level — one self-signed cert shared
by every keychain-backed tool. Each caller forwards them to the reusable workflow with
an explicit four-line secrets: pass-through (macos-cert-p12|password|cn|leaf-sha),
never secrets: inherit: the existing secrets: blocks remap names (e.g.
TAP_GITHUB_TOKEN→homebrew-tap-token), and inherit can't remap, so it would blank
those inputs and break the release. _CN/_LEAF_SHA aren't truly secret (both are
embedded in every signed binary) but are kept as secrets for uniform pass-through wiring.
Snap is out of scope for the family and is being decommissioned. It is not a vestige — it is still wired up across the family in three states:
- Active
snapjobs inrelease.yml(snapcore/action-build+snapcore/action-publish, readingsnap/snapcraft.yaml):slack-chat-api,hubspot-cli,newrelic-cli. - Gated off:
google-readonlyhas the same job butif: false("temporarily disabled — waiting for personal-files interface approval"). - Orphaned
snap/dirs with no job:salesforce-cli,atlassian-clicfl,atlassian-clijtk.
(Snap is a snap/snapcraft.yaml file plus a workflow job — there is no goreleaser
snapcrafts block anywhere. "Active" means the job would publish on release; the
live Snapcraft-store listings were not independently confirmed.)
- New CLIs MUST NOT add snap — no
snap/dir, nosnapjob, no Snapcraft listing. - Decommission steps: for the active publishers (
slck,hspt,nrq) and the gated-offgro: (1) remove thesnapjob fromrelease.yml; (2) delete thesnap/dir; (3) optionally unpublish or archive any Snapcraft-store listing. For the orphaned dirs (sfdc,cfl,jtk): delete thesnap/dir. - User impact: any existing snap users stop receiving updates — communicate the migration path (Homebrew/apt/rpm) before pulling a listing.
Package identifiers are user-facing and sticky — a published winget/choco ID is the string a user types to install, and changing it strands them. Two rules:
-
New CLIs derive identifiers from the binary short name (which is not the repo name —
slack-chat-apishipsslck). -
Every repo declares its identifiers in a machine-readable manifest,
packaging/identity.yml— the authoritative declaration. The model is authoritative manifest + enforced duplicates, with a clean split:- Read-from-manifest for everything the reusable workflows generate: the
§5.2 dispatch
package, the Homebrew alias list, the §2 keychain probe, the tag form. No duplication exists, so there is nothing to check. - Enforced duplicate for values that must also live in a tool-native file
the tool owns (
.goreleasername_template/package_name/homebrew_casks, the winget manifests, the chocolatey.nuspec). The manifest stays authoritative; §8.2identity-checkenforces the native copies match it.
The manifest is not a renderer for goreleaser/winget/choco config — those keep their tool-native files; the manifest is the authority those files are checked against.
- Read-from-manifest for everything the reusable workflows generate: the
§5.2 dispatch
schema: open-cli-identity/v1
repo: slack-chat-api
module: github.com/open-cli-collective/slack-chat-api
binary: slck
service_name: slack-chat-api # keyring service / config dir
version_file: version.txt
goreleaser_config: .goreleaser.yaml
tag:
prefix: v # 'cfl-v' for a monorepo tool
version_scheme: major_minor_run_patch # version.txt = MAJOR.MINOR, patch = run number (release.md §2)
archives:
name_template: "slck_v{{ .Version }}_{{ .Os }}_{{ .Arch }}" # load-bearing — download URLs depend on it
packages:
homebrew:
canonical_cask: slck
alias_casks: [slack-chat-cli] # emitted by the thin alias post-step (§3)
caveats: packaging/homebrew/caveats.txt
postflight: packaging/homebrew/postflight.rb
winget: { id: OpenCLICollective.slack-chat-cli } # grandfathered long form (§10)
chocolatey: { id: slack-chat-cli }
linux: { package_name: slck } # nfpm package_name AND the §5.2 dispatch 'package' key
snap: { state: decommissioning } # §7
keychain_probe: # drives the §2 darwin functional gate; one shared runner
env_unset: [SLACK_CHAT_API_KEYRING_BACKEND] # clear backend overrides so auto-detect runs
seed_config: # written under the hermetic XDG_CONFIG_HOME
path: slack-chat-api/config.yml
content: |
credential_ref: slack-chat-api/default
workspace: smoke
command: ["--output", "json", "config", "show"]
output: json # json → assert jq paths; text → match regexes
assertions: # output: json
.backend: keychain
.backend_source: auto
.credential_ref: slack-chat-api/default
# output: text alternative (e.g. atlassian) —
# output: text
# match: ['backend:\s*keychain', 'source:\s*auto']New CLIs populate this from the binary short name (cask slck, winget
OpenCLICollective.slck, choco slck, linux slck, archive
<binary>_v{{ .Version }}_…). Existing tools record their grandfathered
values — slack-chat-cli (slck's winget/choco) matches neither repo nor
binary; google-readonly is gro's linux package_name; archive templates vary
(hspt_{{ .Version }}, {{ .ProjectName }}_…). The manifest captures reality
rather than forcing a user-facing ID change (§10).
A manifest that nothing verifies is just one more drift source. The
identity-check composite action (ci.md §7, required on PRs) asserts the
tool-native duplicates (the "enforced duplicate" half of rule 2) match the
manifest: .goreleaser (binary, archives.name_template, nfpm
package_name, the homebrew_casks token), the winget manifests, and the
chocolatey .nuspec. The values the workflows generate directly from the
manifest (the §5.2 dispatch package, the alias-cask list, the keychain probe)
aren't duplicated, so there's nothing to check there. A mismatch fails the PR —
that's what makes the manifest authoritative rather than a fourth, silently
drifting copy.
One identity file per binary — tools/cfl/packaging/identity.yml,
tools/jtk/packaging/identity.yml (or a single file with a top-level tools:
map) — so each binary's release identity stays explicit. The alias cask
(jira-ticket-cli ← jtk) is just an alias_casks entry.
Path resolution is asymmetric — tool-local identity, repo-root release config.
The tool-local identity (the manifest, its packaging/* dirs, and version_file)
resolves relative to the tool root (identity-check's working-directory, e.g.
tools/cfl). But goreleaser_config resolves relative to the repo root
(identity-check's repo-root input, default .), because GoReleaser is a
repo-root release operation even in a monorepo — it needs go.work, shared
modules, root tags, and a root dist/, and the configs live at the root
(.goreleaser-cfl.yml with per-build dir: tools/cfl). The reusable release.yml
already runs GoReleaser from the repo root, so the same goreleaser_config value
serves both. A caller therefore passes working-directory: tools/cfl and leaves
repo-root at .. (For a flat repo both are ., so this is invisible.)
The nfpm package_name and the dispatch package key MUST match the manifest's
packages.linux.package_name — a mismatch routes packages under the wrong name
in linux-packages.
A repo that ships more than one binary from one module, one goreleaser config and
one tag stream (google-cli ships gro and grw as v1.2.N) declares them in
a single manifest with binaries: instead of binary:. repo, goreleaser_config,
version_file and tag stay top-level; everything that is per-binary
(archives, packages, keychain_probe) moves under each entry:
schema: open-cli-identity/v1
repo: google-cli
goreleaser_config: .goreleaser.yaml
version_file: version.txt
tag: { prefix: v, version_scheme: major_minor_run_patch }
binaries:
- name: gro
archives: { name_template: "gro_v{{ .Version }}_{{ .Os }}_{{ .Arch }}" }
packages:
homebrew: { canonical_cask: gro, alias_casks: [google-readonly] }
winget: { id: OpenCLICollective.google-readonly }
chocolatey: { id: google-readonly }
linux: { package_name: google-readonly }
keychain_probe: { ... }
- name: grw
archives: { name_template: "grw_v{{ .Version }}_{{ .Os }}_{{ .Arch }}" }
packages: { ... }A manifest declares exactly one of binary: or binaries:; identity-check
normalizes both to a binaries list for the workflows. The per-binary rules:
- Every goreleaser build sets
id:andbinary:. Eacharchives[],nfpms[]andhomebrew_casks[]entry must select one binary's builds/archives viaids:; an unfiltered entry (fine for a single binary) is a drift error here. Templates, package names and cask names are checked against the owning binary. - Chocolatey packages nest per id:
packaging/chocolatey/<id>/<id>.nuspecandpackaging/chocolatey/<id>/tools/. winget manifests stay inpackaging/winget/(they are id-named already). release.ymlbuilds once, then runs each channel (homebrew, chocolatey, winget, linux-packages) as a matrix over the binaries that declare it, resolving each binary's Windows archives fromdist/artifacts.json; the darwin gate and the code-signing identifier (org.open-cli-collective.<name>) run per binary.
This is distinct from §8.3: a monorepo of independently versioned tools keeps one
manifest per tool and prefixed tags; binaries: is for binaries that always ship
together under one version.
Canonical implementation:
open-cli-collective/.github/.github/workflows/release.yml. The local caller
passes the channels it ships and the goreleaser config path; the publish jobs
(homebrew / winget / chocolatey / linux-packages dispatch) are parameterized and
isolated per release.md §4.1. Pin the @v1 ref. Secrets (§6) pass through
from the calling repo.
- Snap: active jobs in
slck/hspt/nrq, gated off (if: false) ingro, orphanedsnap/dirs insfdc+ atlassiancfl/jtk; decommission pending (§7). - Long-form winget/choco IDs (
OpenCLICollective.slack-chat-cli,.newrelic-cli,.google-readonly) predate the §8 binary-short-name convention — grandfathered, not migration targets. - Mixed archive templates:
slck/nrq/grouse<binary>_v{{ .Version }}_…(literalv);hsptuseshspt_{{ .Version }}_…(nov);sfdc+atlassian-cliuse{{ .ProjectName }}_{{ .Version }}_…(nov). New CLIs standardize on the_vform (§8). - gro's Linux package name is
google-readonly(the repo/long name), not the binarygro— both the nfpmpackage_nameand the §5.2 dispatchpackagekey usegoogle-readonly. Grandfathered; new CLIs use the binary short name (§8). - Homebrew
Formula/remnants inhomebrew-tap:newrelic-cli(orphaned — formula stuck at 1.0.26 while the cask is 1.0.11),groandgmail-ro(both marked deprecated 2026-01-28). Cask-only is the standard (§3); these should be removed and release automation kept from re-adding formulae. - Homebrew cask is split: goreleaser
homebrew_casksingoogle-readonly,salesforce-cli,hubspot-cli,cfl,jtk; hand-rolled heredoc inslack-chat-apiandnewrelic-cli(andnrqalso regenerates aFormula/newrelic-cli.rb). Standard is goreleaser casks + a thin alias post-step (§3); the heredocs and the nrq formula are removed on migration. hubspot-cli'slinux-packagesdispatch lackscontinue-on-error: true(§5.2) — a dispatch failure would fail its release; add it on migration.- Overloaded token: repos that still use
TAP_GITHUB_TOKENfor tag pushes must split that path:RELEASE_TAG_TOKENor a GitHub App token for the auto-release tag re-trigger, andTAP_GITHUB_TOKENfor Homebrew tap pushes (§6,release.md§3.1). - No reusable workflow yet —
release.yml,chocolatey-publish.yml,winget-publish.yml, and the per-channel test workflows are copy-pasted across repos (doubled per-tool inatlassian-cli). - CGO verification gate is present in
slack-chat-apiandnewrelic-cli; confirm it exists in every credstore-Keychain CLI'srelease.yml(§2).