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)