Skip to content

Add OpenPGP and X.509 commit signing - #2827

Open
anandghegde wants to merge 1 commit into
git-up:masterfrom
anandghegde:feature/openpgp-commit-signing
Open

anandghegde wants to merge 1 commit into
git-up:masterfrom
anandghegde:feature/openpgp-commit-signing

Conversation

@anandghegde

Copy link
Copy Markdown

I AGREE TO THE GITUP CONTRIBUTOR LICENSE AGREEMENT

Closes #42.

SSH commit signing landed in #2789. That change deliberately scoped itself to SSH and left a comment saying so:

// Only SSH commit signing is currently supported.
// Preserve existing GitUp behavior for OpenPGP/X.509 configs by creating an unsigned commit.

This picks up the other two formats, so commit.gpgsign is now honoured for every gpg.format Git supports. That closes the long-standing request in #42, where people have been dropping to the command line just to commit.

The signing call

The signature comes from running the configured program exactly the way Git runs it — <program> --status-fd=2 -bsau <key> with the commit buffer on stdin — reusing the GCTask plumbing and the once-per-operation PATH refresh that #2789 already put in place.

Program resolution follows Git's rules:

Format Config, in order Default
openpgp (and unset) gpg.openpgp.program, then gpg.program gpg
x509 gpg.x509.program gpgsm

gpg.program is Git's legacy synonym for gpg.openpgp.program and deliberately does not apply to X.509 — easy to get wrong, so there's a test pinning it.

When user.signingkey is unset, the committer identity is passed as the user ID. Git does this so GnuPG picks the key matching the commit rather than whichever key GnuPG considers its own default; omitting -u entirely would quietly sign with a different key than git commit would have used.

Why --status-fd=2

GnuPG can exit zero without having produced a signature. Git guards against that by asking for the machine-readable status and requiring SIG_CREATED, and this does the same. Trusting the exit code alone would mean writing a commit with a garbage gpgsig header.

Behaviour change worth calling out

An unrecognised gpg.format is now an error rather than a silently unsigned commit. Git rejects it too (error: invalid value for 'gpg.format': 'pgp'), and for someone who explicitly asked for signed commits, silently dropping the signature is the worst of the available outcomes. The existing test that asserted "OpenPGP config remains unsigned" is updated accordingly — it now covers the still-correct case of a format configured without commit.gpgsign.

Verification

Every rule above was checked against the real thing rather than against the documentation. I pointed gpg.program / gpg.openpgp.program / gpg.x509.program at a stub that records its argument vector, ran git commit under git 2.50.1, and compared:

  • --status-fd=2 -bsau <key>, in that order
  • user.signingkey forwarded verbatim when set
  • Bot <bot@example.com> — the committer identity — when it is genuinely unset
  • gpg.openpgp.program beating gpg.program
  • gpg.x509.program used for X.509 with gpg.program ignored

The unit tests drive the same stub through GCCreateCommitFromTreeWithOptionalSignature and assert the same argument vectors, so the parity is pinned rather than just observed once.

Seven new tests in GCCommitSigning-Tests.m cover the default format, program precedence, the signing key fallback, X.509, a GnuPG that exits zero without signing, a GnuPG that fails outright, and an invalid gpg.format. ./format-source.sh is clean and the app builds.

One gap I want to be upfront about: there is no end-to-end test against a real gpg, because that would need a throwaway keyring generated at test time and GnuPG isn't guaranteed on the runner. The stub tests pin the invocation; they can't prove GnuPG is happy with it. The SSH tests could go further there only because ssh-keygen is always present.

Unrelated: GCSingleCommitRepositoryTests/testClone fails for me on a clean master too — it clones over the network.

SSH commit signing landed in git-up#2789, which deliberately left the other two
formats alone: a repository configured with commit.gpgsign and an OpenPGP or
X.509 gpg.format silently produced an unsigned commit. This extends the same
machinery to cover both, so GitUp now honours commit.gpgsign for every format
Git itself supports.

The signature is produced by running the configured signing program the way
Git runs it, "<program> --status-fd=2 -bsau <key>", with the commit buffer on
stdin. Program resolution follows Git's rules: gpg.openpgp.program then
gpg.program (its legacy synonym) then "gpg" for OpenPGP, and gpg.x509.program
then "gpgsm" for X.509, with gpg.program deliberately not applying to X.509.
When user.signingkey is unset, the committer identity is passed as the user
ID, which is what Git does so that GnuPG selects the key matching the commit
rather than whichever key it considers its own default.

Success is decided by GnuPG's machine-readable status rather than its exit
code alone, because GnuPG can exit zero without having produced a signature.
That is the reason for --status-fd=2, and it matches how Git validates the
same invocation.

An unrecognised gpg.format is now an error instead of quietly producing an
unsigned commit. Git rejects it too, and silently dropping the signature is
the worst available outcome for someone who asked for signed commits.

Every one of these rules was verified against git 2.50.1 by pointing
gpg.program at a recording stub and comparing the resulting argument vector.
@greptile-apps

greptile-apps Bot commented Sep 19, 2026

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

The PR appears safe to merge; no actionable correctness, security, or repository-rule violations were identified.

Summary

This PR extends commit signing from SSH-only support to OpenPGP and X.509 while preserving failure propagation between external signers and commit creation.

  • Resolves signing format and executable selection from Git-compatible configuration.
  • Invokes GPG or GPGSM with the commit buffer and validates successful signature creation.
  • Rejects unknown formats and signer failures instead of silently creating unsigned commits.
  • Adds focused tests for format defaults, program precedence, signing-key fallback, X.509 signing, and failure paths.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Create commit] --> B{commit.gpgsign enabled?}
  B -- No --> C[Create unsigned commit]
  B -- Yes --> D{gpg.format}
  D -- SSH --> E[Resolve SSH key and signer]
  D -- OpenPGP --> F[Resolve gpg program and signing key]
  D -- X.509 --> G[Resolve gpgsm program and signing key]
  D -- Unknown --> H[Return configuration error]
  E --> I[Sign commit buffer]
  F --> I
  G --> I
  I --> J{Signer succeeded and produced signature?}
  J -- No --> K[Fail commit creation]
  J -- Yes --> L[Create commit with gpgsig header]
Loading

Reviews (1) · Last reviewed commit: "Add OpenPGP and X.509 commit signing"

@mluisbrown

mluisbrown commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Thanks for this @anandghegde, it's a feature I've wanted in GitUp for a long time ❤️ . The program lookup order, the SIG_CREATED check and rejecting unknown gpg.format values all match what git does. I've got one bug and one optional suggestion for this PR, then two follow-ups that are out of scope for this PR.

Non-ASCII committer names break the default signing key

GCCommitSigning.m:257:

return [NSString stringWithFormat:@"%s <%s>", committer->name, committer->email];

%s in stringWithFormat: reads the C string in the system's default encoding, not UTF-8. git stores signature names as UTF-8, so a committer like José or Łukasz with no user.signingkey set ends up passing a garbled user ID to gpg -u. gpg then can't match it to their key, and signing fails. GitUpKit already has a UTF-8-safe helper that builds this same string:

return GCUserFromSignature(committer);

A test with a non-ASCII user.name and a stub that records its last argument would cover this.

Optional: clearer errors when gpg fails

--status-fd=2 puts the [GNUPG:] … status lines on stderr, and the error message shows stderr as-is. A failure therefore shows the user lines like [GNUPG:] KEY_CONSIDERED … around the actual problem. Removing lines that start with [GNUPG:] before building the error would make the message easier to read.

The most common failure from a GUI app is gpg-agent trying to ask for a passphrase in a terminal it doesn't have (Inappropriate ioctl for device, or a pinentry error). In that case it would also help to add a hint to set up a graphical pinentry such as pinentry-mac.

Follow-up (will make a PR): commits created by history operations are still unsigned

Like #2789, this only signs commits made on HEAD: normal commits, merges, amend and conflict resolution. Every other way GitUp creates commits goes through GCRepository+Bare.m and produces unsigned commits even with commit.gpgsign=true. That covers reword, split, squash, fixup, swap, rebase/replay, cherry-pick and revert. Two methods are involved:

  • createCommitFromTree:withParents:…, which calls git_commit_create directly
  • createCommitFromCommit:withTree:…, which calls git_commit_create_from_callback

git signs those commits again (e.g. git rebase with commit.gpgsign), and repos that require signed commits will reject a branch after any GitUp history edit.

A heads-up for whoever takes this on: switching createCommitFromCommit:withTree: over to git_commit_create_buffer/git_commit_create isn't a drop-in change. Those calls take git_commit* parents, and libgit2 silently drops any parent owned by a different git_repository instance (commit_parent_from_array returns NULL, which ends the list early). They also assert that the tree belongs to the same repository. The old callback API only used OIDs and had neither restriction. Looking the parents and tree up again in self.private before creating the commit keeps behaviour identical.

Follow-up (will make a PR): look up PATH once per operation, not once per commit

Each signed commit calls _CommitSigningPATH, which starts a login shell just to read $PATH. That's fine for one commit. Once history operations are signed, though, rewriting 50 commits would start 50 shells plus 50 signer processes. Caching the PATH for the length of a GCLiveRepository performOperationWithReason: call would keep the current behaviour of re-reading it for each separate commit. testCommitSigningRefreshesPATHOncePerOperation would still pass, since it creates its commits outside any operation.

I have both follow-ups implemented on a branch, and I'm happy to open them as a separate PR on top of this one once it's merged.

@lucasderraugh

Copy link
Copy Markdown
Collaborator

I think the UTF-8 point is valid and should be addressed before merging.

Thank you @anandghegde for the PR and I'll gladly merge after that fix.

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

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Support GPG signing

3 participants