From dce44d766a5a0347996666ece668788091f55fba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Apr 2026 01:35:58 +0000 Subject: [PATCH] feat: sign release tags when git signing is enabled When tag.gpgSign or tag.forceSignAnnotated is set in any git config scope, TagRelease now creates an annotated signed tag by delegating to the git CLI, which handles key lookup and passphrase prompts. --- vcs/tags.go | 51 +++++++++++++++++++++++++++++++++++++++++++++++- vcs/tags_test.go | 42 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/vcs/tags.go b/vcs/tags.go index f9b87b8..ecf9dc7 100644 --- a/vcs/tags.go +++ b/vcs/tags.go @@ -2,11 +2,13 @@ package vcs import ( "errors" + "fmt" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/rogpeppe/go-internal/semver" "github.com/sirupsen/logrus" + "os/exec" "strings" "time" ) @@ -177,8 +179,23 @@ func compareSemantically(v *plumbing.Reference, w *plumbing.Reference) int { return semver.Compare(a, b) } -// TagRelease tags the repository with the given version. +// TagRelease tags the repository with the given version. When git is +// configured to sign tags (via tag.gpgSign or tag.forceSignAnnotated) an +// annotated, signed tag is created instead of a lightweight one. func TagRelease(repoPath string, hash string, version string) error { + shouldSign, err := isTagSigningEnabled(repoPath) + if err != nil { + return fmt.Errorf("failed to check tag signing config: %w", err) + } + + if shouldSign { + if err := createSignedTag(repoPath, hash, version); err != nil { + return err + } + logrus.Debugf("signed tag %s created for %s", version, hash) + return nil + } + r, err := git.PlainOpen(repoPath) if err != nil { return err @@ -190,3 +207,35 @@ func TagRelease(repoPath string, hash string, version string) error { logrus.Debugf("tagged %s with %s", hash, version) return nil } + +// isTagSigningEnabled returns true if git is configured to sign tags in any +// config scope. Checks tag.gpgSign and tag.forceSignAnnotated. +func isTagSigningEnabled(repoPath string) (bool, error) { + for _, key := range []string{"tag.gpgSign", "tag.forceSignAnnotated"} { + cmd := exec.Command("git", "-C", repoPath, "config", "--bool", "--get", key) + out, err := cmd.Output() + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { + // key is not set in any scope + continue + } + return false, err + } + if strings.TrimSpace(string(out)) == "true" { + return true, nil + } + } + return false, nil +} + +// createSignedTag creates a signed, annotated tag by delegating to the git +// CLI, which handles GPG/SSH key lookup and passphrase prompting. +func createSignedTag(repoPath, hash, version string) error { + cmd := exec.Command("git", "-C", repoPath, "tag", "-s", "-m", version, version, hash) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("git tag -s failed: %s: %w", strings.TrimSpace(string(out)), err) + } + return nil +} diff --git a/vcs/tags_test.go b/vcs/tags_test.go index 6126ad1..48c27e3 100644 --- a/vcs/tags_test.go +++ b/vcs/tags_test.go @@ -5,10 +5,52 @@ import ( "github.com/go-git/go-git/v5/plumbing/object" "os" "path" + "strings" "testing" "time" ) +func Test_isTagSigningEnabled(t *testing.T) { + tests := []struct { + name string + key string + value string + want bool + }{ + {name: "unset", key: "", want: false}, + {name: "tag.gpgSign=true", key: "tag.gpgSign", value: "true", want: true}, + {name: "tag.gpgSign=false", key: "tag.gpgSign", value: "false", want: false}, + {name: "tag.forceSignAnnotated=true", key: "tag.forceSignAnnotated", value: "true", want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repoDir := createTestRepo(t) + if tt.key != "" { + repo, err := git.PlainOpen(repoDir) + if err != nil { + t.Fatal(err) + } + cfg, err := repo.Config() + if err != nil { + t.Fatal(err) + } + cfg.Raw.Section("tag").SetOption(strings.TrimPrefix(tt.key, "tag."), tt.value) + if err := repo.SetConfig(cfg); err != nil { + t.Fatal(err) + } + } + + got, err := isTagSigningEnabled(repoDir) + if err != nil { + t.Fatalf("isTagSigningEnabled() error = %v", err) + } + if got != tt.want { + t.Errorf("isTagSigningEnabled() = %v, want %v", got, tt.want) + } + }) + } +} + func Test_getEndTag(t *testing.T) { repoDir := createTestRepo(t)