Skip to content

feat: add support for signing archlinux packages - #1065

Open
flecno wants to merge 2 commits into
goreleaser:mainfrom
flecno:sign-archlinux-packages
Open

flecno wants to merge 2 commits into
goreleaser:mainfrom
flecno:sign-archlinux-packages

Conversation

@flecno

@flecno flecno commented Apr 3, 2026

Copy link
Copy Markdown

Archlinux packages can now be signed with a detached PGP signature, producing a binary .sig file alongside the package — matching the format expected by pacman-key --verify.

The signing reads back the finalized .pkg.tar.zst from disk via info.Target to avoid buffering the entire package in memory. The passphrase is taken from $NFPM_ARCHLINUX_PASSPHRASE with a fallback to $NFPM_PASSPHRASE, consistent with deb/rpm/apk.

Also adds sign.PGPDetachedSignWithKeyID, a streaming variant of PGPSignerWithKeyID that accepts an io.Reader instead of []byte.

See #628

@pull-request-size pull-request-size Bot added the size/L Denotes a PR that changes 100-499 lines, ignoring generated files. label Apr 3, 2026
Comment thread internal/sign/pgp.go
@flecno
flecno force-pushed the sign-archlinux-packages branch 2 times, most recently from b1a4386 to 2cebadb Compare April 4, 2026 22:28
@flecno

flecno commented Apr 8, 2026

Copy link
Copy Markdown
Author

@Elara6331 Do you want to take a look at this?

@flecno

flecno commented Apr 27, 2026

Copy link
Copy Markdown
Author

@caarlos0 Is there any chance to get this reviewed and merged? It's a very straight forward change

@caarlos0 caarlos0 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looking good overall, a couple of comments though.

thanks for the PR 🙏🏻

Comment thread arch/arch.go
Comment thread internal/sign/pgp.go
key, err := readSigningKey(keyFile, passphrase)
sig, err := PGPArmoredDetachSignWithKeyID(bytes.NewReader(data), keyFile, passphrase, hexKeyID)
if err != nil {
return nil, &nfpm.ErrSigningFailure{Err: err}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changing the error types returned is a breaking change. I don't think its really necessary...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't change the error type here. This is the helper function used as RPM signer that wraps the error. Maybe we should extract it to the RPM module?

Comment thread internal/sign/pgp.go
DefaultHash: crypto.SHA256,
},
); err != nil {
return nil, &nfpm.ErrSigningFailure{Err: err}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

Comment thread arch/arch.go
Comment thread arch/arch.go Outdated
@flecno

flecno commented May 4, 2026

Copy link
Copy Markdown
Author

Thanks! I'll go over it in the next few days

@flecno
flecno force-pushed the sign-archlinux-packages branch 2 times, most recently from 3b0eeb6 to 62030db Compare June 20, 2026 15:29
flecno added 2 commits June 20, 2026 17:32
Archlinux packages can now be signed with a detached PGP signature,
producing a binary .sig file alongside the package — matching the
format expected by pacman-key --verify.

The signing reads back the finalized .pkg.tar.zst from disk via
info.Target to avoid buffering the entire package in memory.
The passphrase is taken from $NFPM_ARCHLINUX_PASSPHRASE with a
fallback to $NFPM_PASSPHRASE, consistent with deb/rpm/apk.

Also adds sign.PGPDetachedSignWithKeyID, a streaming variant of
PGPSignerWithKeyID that accepts an io.Reader instead of []byte.

See goreleaser#628
@flecno
flecno force-pushed the sign-archlinux-packages branch from 62030db to 4494ec6 Compare June 20, 2026 15:32
@flecno

flecno commented Jun 20, 2026

Copy link
Copy Markdown
Author

@caarlos0 I rebased the branch, fixed your comments and the lint errors and added an acceptance test for signed Arch Linux packages

@codecov

codecov Bot commented Jul 2, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 63.49206% with 23 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.72%. Comparing base (64b788a) to head (4494ec6).
⚠️ Report is 28 commits behind head on main.

Files with missing lines Patch % Lines
arch/arch.go 57.50% 9 Missing and 8 partials ⚠️
internal/sign/pgp.go 76.47% 2 Missing and 2 partials ⚠️
nfpm.go 66.66% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1065      +/-   ##
==========================================
- Coverage   73.97%   73.72%   -0.25%     
==========================================
  Files          22       22              
  Lines        2778     2824      +46     
==========================================
+ Hits         2055     2082      +27     
- Misses        497      507      +10     
- Partials      226      235       +9     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds detached PGP signature generation for Arch Linux (.pkg.tar.zst) packages, producing a companion .sig file suitable for pacman-key --verify, and extends the signing utilities to support streaming signing from an io.Reader.

Changes:

  • Add archlinux.signature configuration + schema/docs updates, with passphrase support via $NFPM_ARCHLINUX_PASSPHRASE (fallback $NFPM_PASSPHRASE).
  • Implement Arch Linux package signing that re-reads the finalized package from info.Target and writes <target>.sig.
  • Introduce sign.PGPDetachedSignWithKeyID(io.Reader, ...) and add unit/acceptance tests for detached signature verification.
Show a summary per file
File Description
www/static/schema.json Adds ArchLinuxSignature definition and archlinux.signature field to the JSON schema.
www/content/docs/configuration.md Documents archlinux.signature configuration and passphrase environment variables.
testdata/acceptance/core.signed.yaml Enables Arch Linux signing in the shared “signed” acceptance config.
testdata/acceptance/archlinux.dockerfile Updates the Arch Linux “signed” acceptance stage to verify the generated .sig.
nfpm.go Adds ArchLinuxSignature config wiring + env var expansion/passphrase population.
internal/sign/pgp.go Adds streaming detached-sign helper and refactors rpm-compatible signer to use it.
internal/sign/pgp_test.go Adds unit test for detached signing + verification.
arch/arch.go Implements signature creation by reading the built package from disk and writing <target>.sig.
arch/arch_test.go Adds unit tests for Arch signing success, error propagation, and callback signing.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 9/9 changed files
  • Comments generated: 5
  • Review effort level: Low

Comment thread arch/arch.go
Comment on lines +167 to +173
// finalize the tar/zstd writer before creating the signature
if err = tw.Close(); err != nil {
return fmt.Errorf("closing data tarball: %w", err)
}
if err = zw.Close(); err != nil {
return fmt.Errorf("closing zstd writer: %w", err)
}
Comment thread arch/arch.go
Comment on lines +436 to +439
f, err := os.Open(info.Target)
if err != nil {
return nil, fmt.Errorf("open package for signing: %w", err)
}
Comment thread arch/arch.go
Comment on lines +466 to +469
_, err = sigFile.Write(sig)
if err != nil {
return fmt.Errorf("write signature to file: %w", err)
}
Comment thread internal/sign/pgp.go
Comment on lines +33 to +36
keyID, err := parseKeyID(hexKeyID)
if err != nil {
return nil, fmt.Errorf("%v is not a valid key id: %w", hexKeyID, err)
}
Comment thread internal/sign/pgp_test.go
if testCase.keyID != nil {
pgpSignature := crypto.NewPGPSignature(sig)

sigID, _ := pgpSignature.GetSignatureKeyIDs()

@caarlos0 caarlos0 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. The nfpm package CLI path works, and I verified a few things independently so they do not come up again:

  • 866F6C83BAB3E49381ADE4C1BC8ACDD415BD80B3 really is the primary key of both internal/sign/testdata/privkey_unprotected.asc and testdata/acceptance/keys/pubkey.asc, and the UID is exactly nfpm test key <test@example.com>, so the acceptance grep matches.
  • The .sig does reach the Docker build context: acceptance_test.go sets info.Target before pkg.Package, and the build context is testdata/acceptance.
  • The double Close() of the tar and zstd writers is safe. archive/tar returns nil on the second call via its ErrWriteAfterClose guard, and klauspost/compress@v1.18.6 returns nil via errors.Is(s.err, ErrEncoderClosed). Neither emits extra bytes, including on the fullFrameWritten path.
  • TestArchSignature genuinely guards the new close ordering: without the explicit tw.Close()/zw.Close() the signed bytes are a truncated prefix and PGPVerify fails.

Requesting changes for two things:

  1. The packager signs bytes it reads back from info.Target instead of the bytes it wrote to w. In one variant this fails loudly for any library caller; in another it silently signs the wrong bytes. See the comment on arch/arch.go:436.
  2. TestArchSignatureError passes for the wrong reason and asserts nothing about key handling. See the comment on arch/arch_test.go:233-242.

The rest are smaller: one real coverage gap in the acceptance stage, a missing binary-vs-armored assertion, stale .sig cleanup, and some nits.

Verification gap, stated plainly: I was asked not to build or run tests locally. Every claim here comes from reading the source, reading the module sources in GOMODCACHE, running gpg against the test keys, and pacman.conf(5). I have not empirically demonstrated that TestArchSignatureError passes for the wrong reason, and I have not shown that any suggested regression test fails without its fix. Please confirm both before acting on them.

Reviewed by an AI agent (GitHub Copilot CLI) at a maintainer's request.

Comment thread arch/arch.go
}

func createSignature(info *nfpm.Info) ([]byte, error) {
f, err := os.Open(info.Target)

@caarlos0 caarlos0 Sep 9, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Package signs the bytes it reads back from info.Target, but the contract of Packager.Package(info, w) is that the package goes to w. Nothing ties the two together.

Two reachable outcomes:

1. Target unset — loud failure. Any library caller gets signing error: create signature: open package for signing: open : no such file or directory. goreleaser's nfpm pipe is exactly this shape: it opens its own file and calls packager.Package(info, w) without ever assigning info.Target. Nothing is broken today only because goreleaser's NFPMArchLinux has no signature field yet — but that means this feature is unusable outside the nfpm CLI, and it breaks the moment goreleaser plumbs it through.

2. Target set but not the same bytes as w — silent, and worse. A buffered writer, or a caller that writes to a temp path and renames afterwards, makes os.Open(info.Target) read stale or partial data. The result is a valid PGP signature over bytes that are not the package, with no error and no warning. deb, rpm and apk cannot do this because they sign the bytes they produce.

Target is undocumented (nfpm.go:328, yaml:"-" json:"-", no comment) and assigned only at internal/cmd/package.go:109. This PR turns it into a silent precondition of a signing feature.

Smallest fix: sign what you write. When a signature is configured, wrap the writer once with io.MultiWriter(w, &buf) and sign buf. deb and rpm already buffer the whole package in memory, so this is consistent and costs nothing new. Keep Target only to name the .sig file, and return an explicit error when it is empty rather than letting os.Open("") speak for you.

Test: Default.Package(info, &bytes.Buffer{}) with a valid key file and Target unset, asserting a specific, actionable error.

Correction: an earlier revision of this comment quoted the error with create signature: doubled. On the os.Open path it appears once, as shown above; the doubling happens on the signing-failure path (see the comment on arch/arch.go:442-453).

Comment thread arch/arch_test.go
Comment on lines +233 to +242
func TestArchSignatureError(t *testing.T) {
info := exampleInfo()
info.ArchLinux.Signature.KeyFile = "/does/not/exist"

var pkg bytes.Buffer
err := Default.Package(info, &pkg)
require.Error(t, err)

var expectedError *nfpm.ErrSigningFailure
require.ErrorAs(t, err, &expectedError)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test passes for the wrong reason.

exampleInfo() never sets Target, and createSignature opens info.Target (arch.go:436) before it ever reads KeyFile (arch.go:451). So the error under test is open : no such file or directory, not anything to do with /does/not/exist.

Two consequences: the test would pass unchanged if you swapped in a valid key file, and it would pass if sign.PGPDetachedSignWithKeyID were deleted outright. It asserts nothing about key handling.

Fix: set info.Target to a real temp file the way the other two tests do, then tighten the assertion so it can only pass for the intended reason:

require.ErrorContains(t, err, "/does/not/exist")

Then add the empty-Target case as its own test, per the comment on arch/arch.go:436.

Comment thread arch/arch_test.go
Comment on lines +226 to +229

_, err = f.Seek(0, io.SeekStart)
require.NoError(t, err)
err = sign.PGPVerify(f, signature, "../internal/sign/testdata/pubkey.asc")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing in the repository asserts that the signature is binary — which is the entire reason PGPDetachedSignWithKeyID was added instead of reusing the existing PGPArmoredDetachSignWithKeyID.

PGPVerify branches on isASCII(signature) (internal/sign/pgp.go:143) and happily accepts either form. TestArchSignatureCallback just below deliberately returns an armored signature and passes. pacman-key --verify shells out to gpg, which also auto-detects armor. So swapping the binary signer for the armored one would pass every test in this repo while emitting a .sig that is not the format pacman expects.

One line closes it:

require.False(t, bytes.HasPrefix(signature, []byte("-----BEGIN")))

Minor, same test: line 218 passes info.Target to os.CreateTemp as the name pattern, but info.Target is "" at that point. A literal like "pkg" says what is actually meant.

RUN pacman-key --init
RUN pacman-key --add /tmp/pubkey.asc
RUN pacman-key --lsign-key 866F6C83BAB3E49381ADE4C1BC8ACDD415BD80B3
RUN pacman-key --verify /tmp/foo.pkg.tar.zst.sig 2>&1 | grep "gpg: Good signature from \"nfpm test key <test@example.com>\" \[full\]"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This stage proves that gpg accepts the signature, but never that pacman does. signed is FROM min, so pacman -U already ran a layer earlier — before the .sig was copied in — and therefore never saw it.

Per pacman.conf(5), the Arch default LocalFileSigLevel = Optional means "signatures are checked if present; absence of a signature is not an error. An invalid signature is a fatal error." So one extra line after the lsign-key gives real end-to-end coverage without touching any config, and it is the only check here that would catch an armored-vs-binary regression at the libalpm level:

RUN pacman --noconfirm -U /tmp/foo.pkg.tar.zst

Comment thread arch/arch.go
}

func createSignatureFile(info *nfpm.Info, sig []byte) error {
sigFile, err := os.Create(info.Target + ".sig")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The .sig is a second output file that nothing ever cleans up.

  • internal/cmd/package.go:112 does os.Remove(target) when Package fails, but never os.Remove(target + ".sig").
  • Deterministic footgun: build a signed package, then rebuild the same target with key_file removed. The package is replaced; the old .sig survives beside it and now covers different bytes. Since LocalFileSigLevel = Optional treats an invalid signature as fatal, pacman -U rejects the new package with nothing pointing at the cause.

Fix: remove target + ".sig" in the CLI error path, and remove a pre-existing .sig here when no signature is configured.

Test: in internal/cmd, create a .sig, run a package command with signing disabled, assert the .sig is gone.

Comment thread arch/arch.go
Comment on lines +459 to +470
func createSignatureFile(info *nfpm.Info, sig []byte) error {
sigFile, err := os.Create(info.Target + ".sig")
if err != nil {
return fmt.Errorf("create signature file: %w", err)
}
defer sigFile.Close()

_, err = sigFile.Write(sig)
if err != nil {
return fmt.Errorf("write signature to file: %w", err)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This helper is os.WriteFile:

return os.WriteFile(info.Target+".sig", sig, 0o644)

One line instead of twelve, an explicit mode rather than whatever umask gives you, and — unlike defer sigFile.Close() — it reports a Close failure instead of discarding it.

Comment thread arch/arch.go
Comment on lines +442 to +453
data := bufio.NewReader(f)

var sig []byte
if signFn := info.ArchLinux.Signature.SignFn; signFn != nil {
sig, err = signFn(data)
if err != nil {
return nil, fmt.Errorf("create signature: %w", err)
}
} else {
sig, err = sign.PGPDetachedSignWithKeyID(data, info.ArchLinux.Signature.KeyFile, info.ArchLinux.Signature.KeyPassphrase, info.ArchLinux.Signature.KeyID)
if err != nil {
return nil, fmt.Errorf("create signature: %w", err)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two nits in this function.

bufio.NewReader(f) costs more than it saves. openpgp.DetachSign copies through io.Copy, which already uses a 32 KiB buffer, so a 4 KiB bufio.Reader only adds a copy and forces smaller reads. Pass f directly.

The "create signature: " prefix is applied here and again by the caller at arch.go:179, so a real failure reads signing error: create signature: create signature: detach sign: reading PGP key file: .... Drop the inner wrap and return the error unchanged — the caller already names the operation.

Comment thread nfpm.go
Comment on lines +274 to +277
archlinuxPassphrase := os.Expand("$NFPM_ARCHLINUX_PASSPHRASE", c.envMappingFunc)
if archlinuxPassphrase != "" {
c.ArchLinux.Signature.KeyPassphrase = archlinuxPassphrase
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nfpm_test.go covers the global passphrase plus the per-format override for deb, rpm and apk, but this new branch has no unit test. One assertion added to the existing table keeps NFPM_ARCHLINUX_PASSPHRASE and its $NFPM_PASSPHRASE fallback from regressing silently.

key_file: ./internal/sign/testdata/rsa_unprotected.priv
archlinux:
signature:
key_file: ./internal/sign/testdata/privkey_unprotected.asc No newline at end of file

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing trailing newline (\ No newline at end of file in the diff). Worth fixing, especially since this same PR adds one to www/static/schema.json.

Comment on lines +516 to +526
# The package is signed if a key_file is set
signature:
# PGP secret key, the passphrase is taken from the environment variable $NFPM_ARCHLINUX_PASSPHRASE with
# a fallback to $NFPM_PASSPHRASE.
# This will expand any env var you set in the field, e.g. key_file: ${SIGNING_KEY_FILE}
key_file: key.gpg

# PGP secret key id in hex format, if it is not set it will select the first subkey
# that has the signing flag set. You may need to set this if you want to use the primary key as the signing key.
# This will expand any env var you set in the field, e.g. key_id: ${ARCHLINUX_SIGNING_KEY_ID}
key_id: bc8acdd415bd80b3

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block never tells the reader that archlinux signing produces a second file, <package>.sig, or that pacman needs it sitting next to the package. That is the user-visible novelty here — every other format embeds its signature — so it deserves an explicit line.

Worth adding too: a SignFn must return a binary detached signature, not an ASCII-armored one. The SignFn doc comment in nfpm.go now says archlinux receives "the full package content", but says nothing about the expected output format.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L Denotes a PR that changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants