Linux package signing & packaging β GPG-signed .deb/.rpm via nfpm and signed repository metadata¶
- Authors
- Matt Cockayne
- Date
- 21 June 2026
- Status
- DRAFT
Summary¶
GTB already mints OpenPGP keys (pkg/openpgpkey), publishes them over a Web Key
Directory (gtb keys wkd, spec 2026-06-09-keys-wkd-command.md), and produces a
detached OpenPGP signature over checksums.txt in the release pipeline
(.goreleaser.yaml signs: block β scripts/sign-release.sh β gtb sign
--backend aws-kms, spec 2026-06-10-signing-release-pipeline.md). The
self-updater verifies that signature against an embedded trust set before
replacing the running binary (pkg/setup/update_signature.go, spec
2026-04-02-remote-update-checksum-verification.md).
That trust chain covers the tarball + self-update distribution path and the
macOS path (Apple notarization, .goreleaser.yaml notarize: block). It
does not cover the Linux OS package manager path. There is no nfpms:
block in .goreleaser.yaml today β GTB ships no .deb or .rpm, so there is no
apt install gtb / dnf install gtb story, and no trust equivalent to
notarization for users who install via their distro's package manager.
On Linux the trust equivalent of macOS notarization is two layers of GPG signing:
- Package signing β each
.deb/.rpmcarries an embedded GPG signature (debsigs/RPM header signature) thatdpkg/rpmverify against an imported public key. - Repository metadata signing β the apt
Release/InReleasefiles and the yumrepomd.xmlare GPG-signed soapt update/dnftrust the index that lists package hashes, closing the gap between "this package is signed" and "this is the package the repo says you should get".
This spec adds package signing (layer 1) to the release pipeline, reusing the existing OpenPGP key and WKD publication so there is one trust root, and assesses repository-metadata signing (layer 2) as in-scope or a documented follow-on (see Open Questions). It also makes the self-updater install-method aware so a package-manager install is not clobbered by an in-place binary swap.
This is feature item A2 (Linux package signing & packaging). The decision
logs (docs/development/feature-decisions.md,
docs/development/security-decisions.md) contain no prior decision on OS
packaging β this is open territory, explicitly noted as deferred future work in
2026-04-02-remote-update-checksum-verification.md ("Package managers (apt, yum,
etc.) β future β¦ when added, they have their own signing requirements"). No
conflict exists with an earlier decision.
Motivation¶
- Trust parity across platforms. macOS users get a notarized binary;
Homebrew users get a tap-pinned cask. Linux users who reach for their native
package manager currently get nothing β they must download a tarball and
trust it out-of-band. A signed
.deb/.rpmbrings Linux to parity. - The key already exists. GTB mints and publishes an OpenPGP signing key over
WKD. Package signing wants exactly such a key. Minting a second, unrelated
GPG key for packaging would fragment the trust root and confuse downstreams who
have already imported the WKD key to verify
checksums.txt.sig. We reuse the one key. apt/dnfare how Linux operators actually install software. A framework that scaffolds CLI tools should let those tools ship through the channel their operators already trust and automate (apt-get install, Ansibleapt/dnfmodules, immutable-image base layers).- The self-updater will otherwise fight the package manager. Today
SelfUpdater.resolveTargetPath(pkg/setup/update.go:826) resolves the running executable's path and overwrites it in place. Ifgtbwas installed to/usr/bin/gtbbydpkg, an in-place self-update silently desynchronises the package database from the on-disk binary β the nextapt upgrademay revert or conflict. The updater must detect this and defer.
Goals¶
- Add an
nfpms:block to.goreleaser.yamlthat builds.deband.rpm(and optionally.apk) forlinux/amd64andlinux/arm64, GPG-signed with GTB's existing OpenPGP key. - Reuse the existing WKD-published OpenPGP key as the single packaging trust
root; document the import step (
apt-key-free, keyring-drop pattern) and therpm --importstep. - Make
internal/generatorscaffold the samenfpms:block into a generated tool's.goreleaser.yamlwhen signing/packaging is enabled, manifest-driven, consistent with how thesigns:block is already generated (spec2026-06-10-signing-release-pipeline.md). - Make
pkg/setup.SelfUpdaterdetect a package-manager-owned install and refuse / redirect the in-place update with an actionable hint. - Decide, and record, whether signed apt/yum repository metadata is in this spec or a follow-on.
Non-Goals¶
- Hosting an apt/yum repository. Whether GTB publishes a live signed repo
(e.g. an
aptrepo on GitLab Pages / object storage with a stable URL) is a distribution-infrastructure decision separate from producing signed artefacts. This spec produces signed packages and (per the open question) may produce signed metadata; standing up the served repository is tracked separately if pursued. - Windows packaging (MSI/winget/Chocolatey) β out of scope; a sibling item.
- Replacing the self-update mechanism for package-manager installs. The
updater becomes aware of package installs and defers; it does not learn to
drive
apt/dnf. - A new signing backend. This spec reuses
pkg/signingand the existing OpenPGP key material; it does not add a packaging-specific backend (but see the KMS tension in Design and the open questions).
Background: the existing trust chain (anchor files)¶
| Concern | Where it lives today |
|---|---|
| OpenPGP key minting | pkg/openpgpkey/openpgpkey.go (ArmoredPublicKey, Entity), gtb keys mint |
| Detached OpenPGP signing | pkg/openpgpkey/sign.go (DetachSign) |
| WKD tree generation | pkg/openpgpkey/wkd.go (WriteWKDTree), gtb keys wkd (spec 2026-06-09-keys-wkd-command.md) |
| Release-checksum signing (produce) | .goreleaser.yaml signs: β scripts/sign-release.sh β gtb sign --backend aws-kms (spec 2026-06-10-signing-release-pipeline.md) |
| Checksum-signature verification (consume) | pkg/setup/update_signature.go (verifyManifestSignature, verifyAgainstTrustSet) (spec 2026-04-02-remote-update-checksum-verification.md) |
| macOS notarization | .goreleaser.yaml notarize: block |
| Self-update target resolution | pkg/setup/update.go:826 (resolveTargetPath) |
| Generator signing wiring | internal/generator/assets/skeleton/.goreleaser.yaml, internal/cmd/enable/signing.go (spec 2026-06-10-signing-release-pipeline.md) |
The notable gap: the produce-side signing currently signs only the checksum manifest, with a KMS-backed key, via a custom command. OS package signing signs the package files themselves and (for nfpm) requires a local GPG key file, not a custom command β this is the central design tension below.
Design¶
The nfpms: block¶
Add to .goreleaser.yaml:
nfpms:
- id: gtb-packages
package_name: gtb
ids:
- gtb # the linux/windows build id; nfpm filters to linux
formats:
- deb
- rpm
# - apk # gated on the apk open question
maintainer: "Matt Cockayne <[email protected]>"
homepage: "https://gitlab.com/phpboyscout/go-tool-base"
description: "A helper utility for interacting and managing gtb repos and resources"
license: "<spdx-id>"
bindir: /usr/bin
file_name_template: >-
{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}
deb:
signature:
key_file: "{{ .Env.NFPM_GPG_KEY_FILE }}"
type: origin
rpm:
signature:
key_file: "{{ .Env.NFPM_GPG_KEY_FILE }}"
GoReleaser's nfpm support signs packages with a local GPG private-key file
(deb.signature.key_file / rpm.signature.key_file), with the passphrase
supplied via $NFPM_<ID>_<FORMAT>_PASSPHRASE / $NFPM_<ID>_PASSPHRASE /
$NFPM_PASSPHRASE. It does not accept a custom signing command, and it does
not sign repository metadata.
The KMS-vs-local-key tension¶
This is the load-bearing design problem and the reason this spec exists as more than a one-line config addition.
GTB's checksum signing keeps the private key in AWS KMS β it never touches
disk (scripts/sign-release.sh, gtb sign --backend aws-kms; the signer's IAM
role is OIDC-derived in CI, .gitlab-ci.yml). nfpm package signing requires the
private key as a file on disk in the CI runner. These are incompatible: nfpm
cannot call out to KMS.
Three resolutions, to be chosen in review (see Open Questions):
- Separate file-based packaging subkey (recommended). Mint a dedicated
OpenPGP signing subkey under the same primary identity (the WKD-published
key), export its private half, and store it as a masked/protected CI variable
(
NFPM_GPG_KEY_FILEmaterialised from a file-type CI variable; passphrase inNFPM_PASSPHRASE). The packaging key shares the WKD trust root (same primary key β users import one key) but is a distinct subkey with a file-based private half, isolating the KMS-protected primary/checksum key from disk exposure.pkg/openpgpkeyalready builds entities and we havegtb keys mint; this spec extends minting to emit an exportable packaging subkey. - Reuse the KMS public key only, sign packages out-of-band. Pre-sign
packages with a separate
gtb sign-driven step that wraps each package, then feed pre-built signed artefacts to GoReleaser. Rejected as fragile β it reimplements nfpm's embedded-signature format (debsigs / RPM header) in GTB rather than letting nfpm own it. - Accept a file-based key for all signing. Drop KMS for checksums too and
use one on-disk key everywhere. Rejected β it regresses the KMS hardening that
2026-06-10-signing-release-pipeline.mddeliberately landed.
Resolution (1) keeps the KMS guarantee for the checksum/self-update path and
gives packaging a purpose-built, file-exportable subkey under the same trust
root. The CI job gates nfpm signing on NFPM_GPG_KEY_FILE being present, exactly
as checksum signing gates on AWS_WEB_IDENTITY_TOKEN and notarization gates on
APPLE_DEV_CERT (--skip the relevant GoReleaser steps when the secret is
absent, so forks and snapshot builds still succeed).
Trust root and key import (WKD reuse)¶
The packaging key is published in the same WKD tree already produced by gtb
keys wkd. Downstream import documentation (in docs/):
- apt: drop the dearmored public key into
/usr/share/keyrings/gtb-archive-keyring.gpgand reference it withsigned-by=in the.sources/.listentry (the modern,apt-key-free pattern). - rpm/dnf:
rpm --import <public-key.asc>(or agpgkey=line in the.repofile), withgpgcheck=1.
Because the key is the same primary identity already on WKD, an operator who has
imported the GTB key to verify checksums.txt.sig does not import a second key.
Repository-metadata signing (layer 2) β scope decision¶
Package signing alone proves a package is authentic but not that it is the
right one for a given version (downgrade/rollback and index-substitution
defence comes from signed metadata). Full apt/yum repo-metadata signing
(Release β InRelease/Release.gpg, repomd.xml β repomd.xml.asc) is a
property of the served repository, which nfpm/GoReleaser do not produce.
This spec's position: package signing (layer 1) is in-scope now;
repository-metadata signing (layer 2) is deferred to a follow-on tied to the
repository-hosting decision (a Non-Goal here), unless review decides otherwise
(see Open Questions). Until a served repo exists, users
install a directly-downloaded signed .deb/.rpm (apt install ./gtb_*.deb,
dnf install ./gtb-*.rpm), where the embedded package signature is the operative
trust check and metadata signing has no surface to protect.
Self-updater install-method awareness¶
SelfUpdater must not overwrite a binary owned by dpkg/rpm. Add a detection
step in the update path (before resolveTargetPath overwrites in place):
- Resolve the target path as today (
pkg/setup/update.go:826). - Classify the install: query whether the resolved path is owned by a package manager. Pure-Go, no shell-out where avoidable:
- dpkg: the path appears under a
*.listin/var/lib/dpkg/info/(read the directory; nodpkg-queryshell-out required), or fall back todpkg -S <path>viainternal/exectest-injectableexecif present. - rpm:
rpm -qf <path>(injectable exec), or check membership without shelling out where feasible. - If package-manager-owned, refuse the in-place swap and emit an
errorhandlinghint: e.g. "gtb was installed by your system package manager (dpkg). Update withapt update && apt install --only-upgrade gtbinstead, or reinstall the tarball build to enable self-update." Honour--forceto override for power users who accept the desync. - This classification is exposed as a small, testable helper (its own file under
pkg/setup/), injected like the existingexecLookPath/osExecutableseams so it is race-safe undert.Parallel()(no package-levelvar execβ¦hooks β per the project testing rules, use struct-field/functional-option injection andinternal/exectest).
Generator parity¶
internal/generator already scaffolds the signs: block driven by the manifest
Signing block (internal/generator/assets/skeleton/.goreleaser.yaml, gated on
{{ if and .Signing.Enabled .Signing.KeyID }}). Add a parallel, manifest-driven
nfpms: block (e.g. gated on a Packaging.Enabled field, populated by a gtb
enable packaging command analogous to gtb enable signing in
internal/cmd/enable/signing.go). The generated block references the consumer's
own packaging key path/env, not GTB's. Any user-influenced field flowing into the
generated YAML (maintainer, description, package name, license) routes through the
existing escapeYAML helper per internal/generator/template_escape.go and
docs/development/template-security.md.
Public API / surface changes¶
.goreleaser.yamlβ newnfpms:block (GTB's own release)..gitlab-ci.ymlβgoreleaserjob materialisesNFPM_GPG_KEY_FILEfrom a protected file variable and setsNFPM_PASSPHRASE; gates nfpm signing on its presence (skip otherwise).pkg/openpgpkeyand/orgtb keys mintβ extend to emit an exportable packaging subkey under the existing primary identity (resolution 1).pkg/setup.SelfUpdaterβ new install-method classification + refusal path; new injectable seam(s) fordpkg/rpmownership queries;--forceoverride.internal/generatorβ new manifestPackagingblock and scaffoldednfpms:template;gtb enable packagingcommand (mirrorsgtb enable signing).- Docs: a Linux install/verify page (
apt/dnfimport + install + verify), cross-referenced fromdocs/components/and the signing docs.
Testing strategy¶
- Unit (β₯90% for new
pkg/code): - Packaging-subkey minting in
pkg/openpgpkey(entity has the expected primary identity + subkey; armored export round-trips). SelfUpdaterinstall-method classification: table-driven over (dpkg-owned, rpm-owned, tarball/unowned, ambiguous) using injected fake exec / fake-filesystem (afero,internal/exectest), asserting refusal + hint vs proceed, and--forceoverride.t.Parallel()throughout, no package-level mock hooks.- Generator:
nfpms:block renders only whenPackaging.Enabled; escaping of user fields viaescapeYAML; golden-file comparison like the existingsigning_goreleaser_test.go. - Integration (env-gated,
testutil.SkipIfNotIntegration): build a real signed.deb/.rpmviagoreleaser --snapshotand verify the embedded signature withdpkg-sig --verify/rpm --checksigagainst the imported public key. Desktop/CI-gated (needsdpkg/rpmtooling), in a*_integration_test.gofile, perdocs/development/integration-testing.md. - E2E (Godog): a CLI scenario asserting that
gtb updateon a package-manager-owned binary prints the defer-to-apt hint and exits without swapping the binary (fake-owned path), per the BDD strategy spec. - Manual release rehearsal:
just snapshotand confirmdist/contains signed.deb/.rpm;goreleaser checkpasses with the new block.
Migration / compatibility notes¶
- Pre-1.0: additive. No existing public type changes signature; the self-updater
gains a refusal path that only triggers for package-manager installs (the
tarball/self-update path is unchanged). Note the updater behaviour change in
the commit body and a
docs/migration/note for downstreams that embedpkg/setupand may now see a refusal where an overwrite previously occurred.
Open Questions¶
Per project convention, resolve or explicitly defer each of these before implementation begins.
- KMS-vs-local-key resolution. Adopt resolution (1) β a separate file-based packaging subkey under the WKD primary identity β or an alternative? This determines the key-management and CI-secret design.
- Repository-metadata signing scope. Is signed
apt/yumrepo metadata (InRelease,repomd.xml.asc) in this spec, or a follow-on gated on the repo-hosting decision? Current proposal: follow-on. If in-scope now, GTB needs a metadata-signing step (likely agtb sign-driven post-build hook, since nfpm does not do this). - Served repository. Will GTB host a live apt/yum repo (stable URL, e.g.
GitLab Pages / object storage), or only attach signed
.deb/.rpmto the Release for direct download? Layer-2 signing is moot without a served repo. .apk(Alpine) inclusion. nfpm supports APK signing too. Ship Alpine packages in the initial cut, or defer? (Affects formats list and key-name handling.)- Updater refusal vs. silent-defer for non-interactive contexts. In CI/cron
(non-interactive), should a package-manager-owned update hard-fail
(non-zero), or warn-and-no-op? Mirror the existing non-interactive handling in
resolveTargetPath(which warns and proceeds) or invert it for package installs? - Single primary key vs. dedicated packaging identity. Resolution (1) keeps
one primary identity with a packaging subkey. Is there a compliance reason a
downstream would want a fully separate packaging key (different fingerprint,
separate WKD entry)? If so, the generator's
Packagingblock must support an independent key, not just a subkey of the signing key. gtb enable packagingvs. folding intogtb enable signing. Is packaging a separate enable command, or a flag on the existing signing enablement (since it shares the trust root)?
Resolutions (open questions confirmed with user 2026-06-21)¶
- KMS-vs-local key β RESOLVED: file-based packaging subkey under the WKD primary identity. nfpm signs with an exportable disk subkey; the self-update checksum path keeps its KMS-never-on-disk guarantee. One identity, two keys; users' existing WKD trust resolves the subkey.
- Repo-metadata signing β RESOLVED: follow-on, gated on the repo-hosting
decision (Q3). This spec signs the
.deb/.rpm/.apkpackages (Layer 1); signedInRelease/repomd.xml.asc(Layer 2) is moot without a served repo. - Served repository β RESOLVED: attach signed packages to the Release for
direct download only (no hosted apt/yum repo for now).
apt install ./file.debverifies the embedded signature; a served repo is deferred until there's demand. .apk(Alpine) β RESOLVED: include.apkin the initial cut alongside.deb/.rpm(nfpm supports APK signing). Formats list and key-name handling account for all three. (Departs from the draft's defer recommendation.)- Non-interactive updater behaviour β RESOLVED: warn and no-op (exit 0),
mirroring the existing
resolveTargetPathconvention. Detect the dpkg/rpm-owned binary, warn that updates are package-manager-managed, and don't swap β doesn't break CI/cron. - Key identity β RESOLVED: packaging subkey under the primary by default,
with opt-in support for a fully separate packaging key (own fingerprint + WKD
entry) in the generator's
Packagingblock, for downstreams with a compliance reason to separate. Default-simple, escape hatch present. - Enable command β RESOLVED: separate
gtb enable packaging. Shares the signing trust root but is a distinct capability (nfpm config, formats, packaging key); a tool can sign releases without producing OS packages.
References¶
.goreleaser.yamlβ existingsigns:,sboms:,notarize:,homebrew_casks:blocks; absentnfpms:.pkg/openpgpkey/βDetachSign,WriteWKDTree,ArmoredPublicKey,Entity.pkg/setup/update.go(resolveTargetPath, line 826),update_signature.go.internal/generator/assets/skeleton/.goreleaser.yaml,internal/cmd/enable/signing.go.- Spec
2026-06-09-keys-wkd-command.mdβ WKD generation. - Spec
2026-06-10-signing-release-pipeline.mdβ generatedsigns:block, KMS shim. - Spec
2026-04-02-remote-update-checksum-verification.mdβ checksum verify; notes apt/yum as deferred future work with their own signing requirements. docs/development/template-security.mdβescapeYAMLfor generated YAML fields.docs/development/integration-testing.md, BDD strategy spec β test gating.- GoReleaser nfpm docs β
deb.signature.key_file/rpm.signature.key_file,$NFPM_*_PASSPHRASE; signs with a local GPG key file (no custom command); does not sign repository metadata.