Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pkg/flagutil/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ type GitHubOptions struct {
AllowDirectAccess bool
AppID string
AppPrivateKeyPath string
SigningKeyPath string

ThrottleHourlyTokens int
ThrottleAllowBurst int
Expand Down Expand Up @@ -147,6 +148,7 @@ func (o *GitHubOptions) addFlags(fs *flag.FlagSet, paramFuncs ...FlagParameter)
fs.IntVar(&o.max404Retries, "github-client.max-404-retries", github.DefaultMax404Retries, "Maximum number of retries that will be used for a 404-ing request to the GitHub API.")
fs.DurationVar(&o.maxSleepTime, "github-client.backoff-timeout", github.DefaultMaxSleepTime, "Largest allowable Retry-After time for requests to the GitHub API.")
fs.DurationVar(&o.initialDelay, "github-client.initial-delay", github.DefaultInitialDelay, "Initial delay before retries begin for requests to the GitHub API.")
fs.StringVar(&o.SigningKeyPath, "git-signing-key-path", "", "Path to an SSH private key for signing git commits. When set, all commits made by the git client are signed using SSH.")
}

func (o *GitHubOptions) parseOrgThrottlers() error {
Expand Down Expand Up @@ -341,6 +343,7 @@ func (o *GitHubOptions) GitClientFactory(cookieFilePath string, cacheDir *string
CookieFilePath: cookieFilePath,
Host: o.Host,
Persist: &persistCache,
SigningKeyPath: o.SigningKeyPath,
}
if cacheDir != nil && *cacheDir != "" {
opts.CacheDirBase = cacheDir
Expand Down
48 changes: 40 additions & 8 deletions pkg/git/v2/client_factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ type ClientFactoryOpts struct {
CookieFilePath string
// If set, cacheDir persist. Otherwise temp dir will be used for CacheDir
Persist *bool
// SigningKeyPath is the path to an SSH private key for signing commits.
// When set, cloned repos are configured with gpg.format=ssh and commit.gpgsign=true.
SigningKeyPath string
}

// These options are scoped to the repo, not the ClientFactory level. The reason
Expand Down Expand Up @@ -186,6 +189,9 @@ func (cfo *ClientFactoryOpts) Apply(target *ClientFactoryOpts) {
if cfo.Persist != nil {
target.Persist = cfo.Persist
}
if cfo.SigningKeyPath != "" {
target.SigningKeyPath = cfo.SigningKeyPath
}
}

func defaultTempDir() *string {
Expand Down Expand Up @@ -270,6 +276,13 @@ func WithPersist(persist bool) ClientFactoryOpt {
}
}

// WithSigningKeyPath sets the SigningKeyPath option.
func WithSigningKeyPath(path string) ClientFactoryOpt {
return func(o *ClientFactoryOpts) {
o.SigningKeyPath = path
}
}

func defaultClientFactoryOpts(cfo *ClientFactoryOpts) {
if cfo.Host == "" {
cfo.Host = "github.com"
Expand Down Expand Up @@ -337,24 +350,30 @@ func NewClientFactory(opts ...ClientFactoryOpt) (ClientFactory, error) {
repoLocks: map[string]*sync.Mutex{},
logger: logrus.WithField("client", "git"),
cookieFilePath: o.CookieFilePath,
signingKeyPath: o.SigningKeyPath,
}, nil
}

// NewLocalClientFactory allows for the creation of repository clients
// based on a local filepath remote for testing
func NewLocalClientFactory(baseDir string, gitUser GitUserGetter, censor Censor) (ClientFactory, error) {
func NewLocalClientFactory(baseDir string, gitUser GitUserGetter, censor Censor, opts ...ClientFactoryOpt) (ClientFactory, error) {
o := ClientFactoryOpts{}
for _, opt := range opts {
opt(&o)
}
cacheDir, err := os.MkdirTemp("", "gitcache")
if err != nil {
return nil, err
}
return &clientFactory{
cacheDir: cacheDir,
remote: &pathResolverFactory{baseDir: baseDir},
gitUser: gitUser,
censor: censor,
masterLock: &sync.Mutex{},
repoLocks: map[string]*sync.Mutex{},
logger: logrus.WithField("client", "git"),
cacheDir: cacheDir,
remote: &pathResolverFactory{baseDir: baseDir},
gitUser: gitUser,
censor: censor,
signingKeyPath: o.SigningKeyPath,
masterLock: &sync.Mutex{},
repoLocks: map[string]*sync.Mutex{},
logger: logrus.WithField("client", "git"),
}, nil
}

Expand All @@ -364,6 +383,7 @@ type clientFactory struct {
censor Censor
logger *logrus.Entry
cookieFilePath string
signingKeyPath string

// cacheDir is the root under which cached clones of repos are created
cacheDir string
Expand Down Expand Up @@ -474,6 +494,18 @@ func (c *clientFactory) ClientForWithRepoOpts(org, repo string, repoOpts RepoOpt
}
gitMetrics.secondaryCloneDuration.WithLabelValues(org, repo).Observe(time.Since(timeBeforeSecondaryClone).Seconds())

if c.signingKeyPath != "" {
for _, args := range [][]string{
{"gpg.format", "ssh"},
{"user.signingkey", c.signingKeyPath},
{"commit.gpgsign", "true"},
} {
if err := repoClient.Config(args...); err != nil {
return nil, fmt.Errorf("failed to configure commit signing: %w", err)
}
}
}

return repoClient, nil
}

Expand Down
124 changes: 124 additions & 0 deletions pkg/git/v2/client_factory_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
Copyright 2025 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package git

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}

func TestClientFactorySigningKey(t *testing.T) {
t.Parallel()

// Generate an SSH signing key.
keyDir := t.TempDir()
keyPath := filepath.Join(keyDir, "id_ed25519")
if out, err := exec.Command("ssh-keygen", "-t", "ed25519", "-f", keyPath, "-N", "").CombinedOutput(); err != nil {
t.Fatalf("generating SSH key: %v\n%s", err, out)
}

// Create a source repo with a commit.
repoBase := t.TempDir()
repoDir := filepath.Join(repoBase, "org", "repo")
if err := os.MkdirAll(repoDir, 0755); err != nil {
t.Fatal(err)
}
runGit(t, repoDir, "init")
runGit(t, repoDir, "config", "user.email", "test@test.test")
runGit(t, repoDir, "config", "user.name", "test")
runGit(t, repoDir, "config", "commit.gpgsign", "false")
if err := os.WriteFile(filepath.Join(repoDir, "file.txt"), []byte("hello\n"), 0644); err != nil {
t.Fatal(err)
}
runGit(t, repoDir, "add", "file.txt")
runGit(t, repoDir, "commit", "-m", "initial")

// Create a patch to apply.
patchFile := filepath.Join(keyDir, "test.patch")
patchContent := `From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Test User <test@test.test>
Date: Thu, 01 Jan 2026 00:00:00 +0000
Subject: [PATCH] update file

---
file.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/file.txt b/file.txt
index ce01362..94954ab 100644
--- a/file.txt
+++ b/file.txt
@@ -1 +1 @@
-hello
+world
--
2.40.0
`
if err := os.WriteFile(patchFile, []byte(patchContent), 0644); err != nil {
t.Fatal(err)
}

// Create factory with signing key.
factory, err := NewLocalClientFactory(repoBase,
func() (string, string, error) { return "test", "test@test.test", nil },
func(in []byte) []byte { return in },
WithSigningKeyPath(keyPath),
)
if err != nil {
t.Fatalf("creating factory: %v", err)
}
defer factory.Clean()

client, err := factory.ClientFor("org", "repo")
if err != nil {
t.Fatalf("getting client: %v", err)
}
defer client.Clean()

if err := client.Config("user.name", "test"); err != nil {
t.Fatalf("setting user.name: %v", err)
}
if err := client.Config("user.email", "test@test.test"); err != nil {
t.Fatalf("setting user.email: %v", err)
}

// Apply patch — should produce a signed commit.
if err := client.Am(patchFile); err != nil {
t.Fatalf("git am: %v", err)
}

// Verify the commit is signed.
out, err := exec.Command("git", "-C", client.Directory(), "cat-file", "-p", "HEAD").CombinedOutput()
if err != nil {
t.Fatalf("inspecting commit: %v\n%s", err, out)
}
if !strings.Contains(string(out), "BEGIN SSH SIGNATURE") {
t.Errorf("expected commit to contain SSH signature, got:\n%s", out)
}
}