Skip to content

Add signing to your CLI and sign your first release

By the end of this you'll have a small Cobra CLI of your own that carries working sign and keys commands, and you'll have used it to sign two files: a checksum manifest with OpenPGP, and a release tarball with minisign. You'll verify the first one with plain gpg, so you can see the signature isn't tied to anything of ours.

Allow about twenty minutes. Everything happens in one scratch directory, with a key on disk — no cloud account, no CI, nothing to pay for.

signing-cli is a library, not a tool. There is no signing-cli binary to install; you get the commands by attaching them to a CLI you build. That is what step one does.

What you'll need

  • Go 1.26 or later.
  • gpg (GnuPG 2.x), for the verification step. On Debian/Ubuntu, apt install gnupg.
  • A scratch directory, because this creates a dozen files:

    mkdir -p ~/scratch/mytool && cd ~/scratch/mytool
    

Nothing here writes outside that directory, and nothing needs sudo.

Build a CLI that carries the commands

Start a module and add the two dependencies — the command builders, and one signing backend for them to talk to:

go mod init example.com/mytool
go get gitlab.com/phpboyscout/go/signing-cli
go get gitlab.com/phpboyscout/go/signing

Now main.go:

package main

import (
    "log/slog"
    "os"

    "github.com/spf13/cobra"

    signingcli "gitlab.com/phpboyscout/go/signing-cli"

    // Blank imports activate the backends this binary offers.
    _ "gitlab.com/phpboyscout/go/signing/local"
)

func main() {
    log := slog.New(slog.NewTextHandler(os.Stderr, nil))

    root := &cobra.Command{Use: "mytool"}
    root.AddCommand(
        signingcli.NewCmdSign(log),
        signingcli.NewCmdKeys(log),
    )

    if err := root.Execute(); err != nil {
        os.Exit(1)
    }
}

Two things are doing the work there. NewCmdSign and NewCmdKeys return plain *cobra.Command values, so they attach to any Cobra root. And the blank import of signing/local is what makes --backend local exist at all — this module ships no backend of its own, and a binary with no backend imported has nothing to sign with. See Compile in signing backends for the list.

slog.New(...) is passed straight in with no adapter, because *slog.Logger already has the four methods signingcli.Logger asks for.

Build it and check the commands arrived:

go mod tidy
go build -o mytool .
./mytool keys --help

You should see five subcommands listed — generate, minisign, mint, publish and wkd.

Generate a signing key on disk

keys generate makes a fresh keypair in-process and writes both halves:

./mytool keys generate \
    --algorithm rsa \
    --name "Demo Release" \
    --email release@example.com \
    --output release.asc
level=INFO msg="Generated OpenPGP keypair" algorithm=rsa public_output=release.asc private_output=release.pem creation_time=2026-08-02T18:36:19Z fingerprint=EFF46A11B8F08B9F096E72F31853BB8BF675341E
level=WARN msg="Move the private-half file to offline storage now." private_output=release.pem

Two files now exist. release.asc is the armored public half, mode 0644, meant to be handed out. release.pem is the private half, mode 0600, and it is not encrypted — anyone who can read that file can sign as you. That is fine for a scratch directory you're about to delete, and it is why the command warns you. For anything real, the private half lives in a KMS and never lands on disk; keys mint is the command for that case.

RSA is deliberate here. The OpenPGP path in the next step only handles RSA keys — an Ed25519 key gets used later, for a different signature format.

Sign a checksum manifest with OpenPGP

Make something to sign:

printf 'a1b2c3  mytool_1.0.0_linux_amd64.tar.gz\n' > checksums.txt

Then sign it:

./mytool sign \
    --backend local \
    --key-id ./release.pem \
    --public-key ./release.asc \
    checksums.txt
level=INFO msg="Signed file" backend=local key_id=./release.pem public_key=./release.asc input=checksums.txt output=checksums.txt.sig sig_creation_time=2026-08-02T18:36:20Z fingerprint=EFF46A11B8F08B9F096E72F31853BB8BF675341E

--key-id is whatever the backend needs to find the key; for local that's the PEM path. --public-key is the armored public half, and sign refuses to continue unless it matches the key the backend resolved — a signature naming a fingerprint it wasn't made with would fail for every verifier, silently, later.

The signature lands at checksums.txt.sig because --output defaults to <input>.sig. Note that sign overwrites an existing output file without asking; there is no --force on this command and no prompt.

Verify the signature with gpg

Nothing about the output is specific to these tools, and this is where you see it. Import the public half and check the signature:

gpg --import release.asc
gpg --verify checksums.txt.sig checksums.txt
gpg: Signature made Sun 02 Aug 2026 18:36:20 UTC
gpg:                using RSA key EFF46A11B8F08B9F096E72F31853BB8BF675341E
gpg: Good signature from "Demo Release <release@example.com>" [unknown]
gpg: WARNING: This key is not certified with a trusted signature!

"Good signature" is the line that matters. The warning underneath is gpg saying it has no reason to trust this key belongs to who it claims — you imported it yourself thirty seconds ago and never certified it. That's expected here.

The fingerprint gpg prints is the same one sign logged. That's the point of the log line: an operator can confirm which identity signed without running gpg at all.

Sign a release artefact with minisign

The OpenPGP path works for checksum manifests read by Go tooling. Release artefacts consumed by cargo-binstall or rtb-update need minisign instead — those verifiers don't speak OpenPGP.

That format needs an Ed25519 key, so generate a second one. The --private-format pem part matters: it writes a PKCS#8 PEM, which is the form the local backend can read. The default for Ed25519 is an armored OpenPGP secret block, and the local backend cannot load that.

./mytool keys generate \
    --algorithm ed25519 --private-format pem \
    --name "Demo Artefact" --email release@example.com \
    --output artefact.asc --private-output artefact.pem

Now sign a stand-in tarball:

printf 'fake tarball\n' > mytool_1.0.0_linux_amd64.tar.gz

./mytool sign \
    --format minisign \
    --backend local \
    --key-id ./artefact.pem \
    --project mytool \
    mytool_1.0.0_linux_amd64.tar.gz
level=INFO msg="Signed artefact" format=minisign backend=local key_id=./artefact.pem minisign_key_id=EA178AC2BBCE6C1B input=mytool_1.0.0_linux_amd64.tar.gz output=mytool_1.0.0_linux_amd64.tar.gz.minisig project=mytool trusted_comment_time=2026-08-02T18:36:28Z

The .minisig extension is load-bearing — rtb-update picks its parser by extension — so leave --output alone unless you know the consumer.

Look inside the signature:

cat mytool_1.0.0_linux_amd64.tar.gz.minisig
untrusted comment: minisign public key EA178AC2BBCE6C1B
RUTqF4rCu85sGydnzP6sSqJL7N8Qo8s6lxxp+ahJi73g76MFW9DJE4gVAeCzJEDSFO5NkS5yBuqA1fsPPcsZ2wwqDzZvguR/5QM=
trusted comment: timestamp:1785695788   file:mytool_1.0.0_linux_amd64.tar.gz    hashed  project:mytool  key:EA178AC2BBCE6C1B
p38sV+C+9uLgCuMMRk1cLPJuVqbPgYfokgetaCXhYS8MirRd88NnGrfxpNzfDsy1f0OkEltjVQc+V35NF3vvBA==

project:mytool is there because you passed --project. It sits in the trusted comment, which the signature covers, so it can't be edited afterwards without breaking verification. hashed says this is the prehashed variant, which is what lets a KMS-held key sign a file of any size.

Verifying this one needs the minisign tool or one of the Rust consumers, neither of which is a dependency of this tutorial — go/signing covers that in Verify a release.

Get the public key consumers will pin

A minisign signature carries a key identifier but not the key. Consumers pin the public key as a base64 string, and keys minisign prints it:

./mytool keys minisign --backend local --key-id ./artefact.pem
RWTqF4rCu85sG84yeyHhh49uezqdSZ6VSkn3z77iXBF+CxNZThw+6c7V

That string goes into cargo-binstall's pubkey field verbatim. It's derived from the public key itself, so running this again on any machine gives the same answer — there's no state to keep.

Stage the key for publication

keys publish writes the key into the layout a static keys site expects, with a manifest beside it:

./mytool keys minisign --backend local --key-id ./artefact.pem --output artefact.pub
./mytool keys publish --output ./keys-site --project mytool artefact.pub
cat ./keys-site/keys.json
{
  "keys": [
    {
      "id": "EA178AC2BBCE6C1B",
      "project": "mytool",
      "generation": 1,
      "algorithm": "minisign-ED",
      "purpose": "artefact",
      "status": "active",
      "valid_from": "2026-08-02",
      "pubkey": "RWTqF4rCu85sG84yeyHhh49uezqdSZ6VSkn3z77iXBF+CxNZThw+6c7V",
      "path": "minisign/mytool/v1.pub"
    }
  ]
}

./keys-site is now a directory you'd deploy to a static host. Publishing is add-only: run the same command again and it's a no-op, but point it at a different key for mytool generation 1 and it refuses. Consumers pin these values, so a published key changing underneath them is the one thing that must never happen.

Clean up

The verification step imported a key into your real GnuPG keyring, so remove that first — the fingerprint is the one sign logged and gpg printed:

gpg --delete-keys EFF46A11B8F08B9F096E72F31853BB8BF675341E   # yours will differ

Then delete the scratch directory:

cd .. && rm -rf ~/scratch/mytool

That takes both private keys with it, which is what you want — they were never meant to outlive the tutorial.

If you would rather not touch your real keyring at all, run the gpg steps with GNUPGHOME pointed somewhere disposable:

export GNUPGHOME=$(mktemp -d)

Set that before the import and there is nothing to delete afterwards.

Where to go next