fix(github): use App installation token for destination pushes (#433)

Pass the resolved destination credential through GetOrCreate to the push helper. Cover App and configured tokens on github.com and Enterprise Server, API failures, and the backup call chain.
This commit is contained in:
Danny Sauer
2026-09-09 18:34:52 +02:00
committed by GitHub
parent 109cc1eda4
commit 55882ef63e
7 changed files with 357 additions and 21 deletions
+4 -2
View File
@@ -276,10 +276,12 @@ destination:
repositories: private # private, public, default: private
force: false # force push to destination
# alternatively, authenticate with a GitHub App:
# requires Contents: write, plus Administration: write to create repositories
# - app_id: 123456 # GitHub App ID (numeric)
# app_installation_id: 78901234 # Installation ID of the App
# app_installation_id: 78901234 # installation on the destination organization, including for enterprise-owned Apps
# app_private_key_file: /path/to/private.pem # path to the App's RSA private key PEM file
# organization: whatever # org to back up into (leave empty for personal account)
# organization: whatever # destination organization; required for App authentication
# url: https://github.example.com # GitHub Enterprise Server base URL; omit for github.com
# visibility:
# repositories: private
# force: false
+11 -10
View File
@@ -497,12 +497,13 @@ func Get(conf *types.Conf) ([]types.Repo, bool) {
return repos, ran
}
// GetOrCreate Get or create a repository
func GetOrCreate(destination types.GenRepo, repo types.Repo) (string, error) {
// GetOrCreate returns the destination's clone URL and resolved push token,
// creating the repository if needed.
func GetOrCreate(destination types.GenRepo, repo types.Repo) (string, string, error) {
sub = logger.CreateSubLogger("stage", "github", "url", githubInstanceURL(destination.URL))
client, _, err := newGithubClient(context.TODO(), destination)
client, token, err := newGithubClient(context.TODO(), destination)
if err != nil {
return "", err
return "", "", err
}
dest := types.GithubDestination{}
@@ -510,14 +511,14 @@ func GetOrCreate(destination types.GenRepo, repo types.Repo) (string, error) {
if destination.Organization == "" {
user, _, err := client.Users.Get(context.TODO(), "")
if err != nil {
return "", err
return "", "", err
}
dest.User = user
login = *user.Login
} else {
organization, _, err := client.Organizations.Get(context.TODO(), destination.Organization)
if err != nil {
return "", err
return "", "", err
}
dest.Organization = organization
login = *organization.Login
@@ -526,12 +527,12 @@ func GetOrCreate(destination types.GenRepo, repo types.Repo) (string, error) {
r, _, err := client.Repositories.Get(context.TODO(), login, repo.Name)
if err != nil {
if !strings.Contains(err.Error(), "404 Not Found") {
return "", err
return "", "", err
}
if destination.Organization == "" {
r, _, err = client.Repositories.Create(context.TODO(), "", &github.Repository{Name: github.String(repo.Name), Private: github.Bool(destination.Visibility.Repositories == "private"), Visibility: github.String(destination.Visibility.Repositories), Owner: dest.User})
if err != nil {
return "", err
return "", "", err
}
} else {
if destination.Visibility.Repositories == "" {
@@ -539,12 +540,12 @@ func GetOrCreate(destination types.GenRepo, repo types.Repo) (string, error) {
}
r, _, err = client.Repositories.Create(context.TODO(), *dest.Organization.Login, &github.Repository{Name: github.String(repo.Name), Private: github.Bool(destination.Visibility.Repositories == "private"), Visibility: github.String(destination.Visibility.Repositories), Organization: dest.Organization})
if err != nil {
return "", err
return "", "", err
}
}
}
return *r.CloneURL, nil
return *r.CloneURL, token, nil
}
// GetIssues get issues
+146
View File
@@ -2,9 +2,18 @@ package github
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/cooperspencer/gickup/types"
)
@@ -82,3 +91,140 @@ func TestNewGithubClientAppAuthMissingKeyFile(t *testing.T) {
t.Fatal("expected error when App private key file does not exist")
}
}
//nolint:paralleltest // Replaces http.DefaultTransport and calls code with a package-level logger.
func TestGetOrCreateReturnsResolvedToken(t *testing.T) {
const wantToken = "installation-token-for-test"
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey() error = %v", err)
}
keypath := filepath.Join(t.TempDir(), "app.pem")
pemBytes := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
})
if err := os.WriteFile(keypath, pemBytes, 0o600); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}
tokenFile := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(tokenFile, []byte(wantToken+"\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("GITHUB_DESTINATION_TEST_TOKEN", wantToken)
for _, instance := range []struct {
name, url, apiURL, cloneURL string
}{
{"github.com", "", "https://api.github.com", "https://github.com/example/repo.git"},
{"enterprise-server", "https://github.example.com/", "https://github.example.com/api/v3", "https://github.example.com/example/repo.git"},
} {
for _, tc := range []struct {
name, auth, failAt string
create bool
}{
{name: "app-existing", auth: "app"},
{name: "app-create", auth: "app", create: true},
{name: "pat-existing", auth: "pat"},
{name: "pat-create", auth: "pat", create: true},
{name: "environment-token", auth: "environment"},
{name: "token-file", auth: "file"},
{name: "token-error", auth: "app", failAt: "/app/installations/4242/access_tokens"},
{name: "organization-error", auth: "app", failAt: "/orgs/example"},
{name: "repository-error", auth: "app", failAt: "/repos/example/repo"},
{name: "creation-error", auth: "app", create: true, failAt: "/orgs/example/repos"},
} {
t.Run(instance.name+"/"+tc.name, func(t *testing.T) {
destination := types.GenRepo{URL: instance.url, Organization: "example"}
switch tc.auth {
case "app":
destination.AppID = 99
destination.AppInstallationID = 4242
destination.AppPrivateKeyFile = keypath
destination.Token = "unused-configured-token"
case "pat":
destination.Token = wantToken
case "environment":
destination.Token = "GITHUB_DESTINATION_TEST_TOKEN"
case "file":
destination.TokenFile = tokenFile
}
created, failed := false, false
// These fixtures rely on clients using http.DefaultTransport to intercept github.com requests.
oldTransport := http.DefaultTransport
t.Cleanup(func() { http.DefaultTransport = oldTransport })
http.DefaultTransport = roundTripperFunc(func(r *http.Request) (*http.Response, error) {
w := httptest.NewRecorder()
w.Header().Set("Content-Type", "application/json")
if !strings.HasPrefix(r.URL.String(), instance.apiURL+"/") {
t.Errorf("unexpected API URL: %s", r.URL)
w.WriteHeader(http.StatusNotFound)
return w.Result(), nil
}
endpoint := strings.TrimPrefix(r.URL.String(), instance.apiURL)
if endpoint == "/app/installations/4242/access_tokens" {
if r.Method != http.MethodPost || !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
t.Error("installation token request must use POST and App JWT authentication")
}
} else if !strings.HasSuffix(r.Header.Get("Authorization"), " "+wantToken) {
t.Error("API request did not use the resolved token")
}
if endpoint == tc.failAt {
failed = true
w.WriteHeader(http.StatusForbidden)
return w.Result(), nil
}
switch r.Method + " " + endpoint {
case "POST /app/installations/4242/access_tokens":
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{
"token": wantToken, "expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
})
case "GET /orgs/example":
_, _ = w.WriteString(`{"login":"example"}`)
case "GET /repos/example/repo":
if tc.create {
w.WriteHeader(http.StatusNotFound)
_, _ = w.WriteString(`{"message":"Not Found"}`)
} else {
_ = json.NewEncoder(w).Encode(map[string]string{"clone_url": instance.cloneURL})
}
case "POST /orgs/example/repos":
created = true
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{"clone_url": instance.cloneURL})
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL)
w.WriteHeader(http.StatusNotFound)
}
return w.Result(), nil
})
cloneURL, token, err := GetOrCreate(destination, types.Repo{Name: "repo"})
if tc.failAt != "" {
if !failed || err == nil || cloneURL != "" || token != "" {
t.Fatalf("API error returned (%q, %q, %v), want empty URL and token with an error", cloneURL, token, err)
}
return
}
if err != nil {
t.Fatal(err)
}
if cloneURL != instance.cloneURL || token != wantToken {
t.Fatalf("GetOrCreate() = (%q, %q), want (%q, %q)", cloneURL, token, instance.cloneURL, wantToken)
}
if created != tc.create {
t.Fatalf("repository created = %v, want %v", created, tc.create)
}
})
}
}
}
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}
+2 -2
View File
@@ -659,9 +659,9 @@ func tempCloneBase(repo types.Repo, tempdir string, isBare bool) (*git.Repositor
return r, err
}
func CreateRemotePush(repo *git.Repository, destination types.GenRepo, url string, lfs bool) error {
// CreateRemotePush pushes to url using the caller's resolved token or SSH configuration.
func CreateRemotePush(repo *git.Repository, destination types.GenRepo, url, token string, lfs bool) error {
sub = logger.CreateSubLogger("stage", "tempclone", "url", url)
token := destination.GetToken()
var auth transport.AuthMethod
if destination.SSH {
if destination.SSHKey == "" {
+90
View File
@@ -0,0 +1,90 @@
package local
import (
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/cooperspencer/gickup/types"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/plumbing/transport"
)
//nolint:paralleltest // CreateRemotePush assigns the package-level logger.
func TestCreateRemotePushUsesProvidedToken(t *testing.T) {
const want = "resolved-token-for-test"
dir := t.TempDir()
repo, err := git.PlainInit(dir, false)
if err != nil {
t.Fatalf("git.PlainInit() error = %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("x\n"), 0o644); err != nil {
t.Fatalf("os.WriteFile() error = %v", err)
}
wt, err := repo.Worktree()
if err != nil {
t.Fatalf("Worktree() error = %v", err)
}
if _, err := wt.Add("README.md"); err != nil {
t.Fatalf("Add() error = %v", err)
}
if _, err := wt.Commit("seed", &git.CommitOptions{
Author: &object.Signature{Name: "t", Email: "t@example.com", When: time.Now()},
}); err != nil {
t.Fatalf("Commit() error = %v", err)
}
var passwordsMu sync.Mutex
var passwords []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, password, _ := r.BasicAuth()
passwordsMu.Lock()
passwords = append(passwords, password)
passwordsMu.Unlock()
w.Header().Set("WWW-Authenticate", `Basic realm="git"`)
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
for _, configuredToken := range []string{"", "unused-configured-token"} {
destination := types.GenRepo{Token: configuredToken}
err := CreateRemotePush(repo, destination, server.URL+"/org/repo.git", want, false)
if !errors.Is(err, transport.ErrAuthenticationRequired) {
t.Fatalf("push error = %v, want authentication required", err)
}
passwordsMu.Lock()
gotPasswords := passwords
passwords = nil
passwordsMu.Unlock()
if len(gotPasswords) == 0 {
t.Fatal("push did not reach the server")
}
for _, got := range gotPasswords {
if got != want {
t.Fatalf("push password = %q, want %q", got, want)
}
}
}
reopened, err := git.PlainOpen(dir)
if err != nil {
t.Fatal(err)
}
remotes, err := reopened.Remotes()
if err != nil {
t.Fatal(err)
}
for _, remote := range remotes {
urls := remote.Config().URLs
if len(urls) != 1 || urls[0] != server.URL+"/org/repo.git" {
t.Fatalf("persisted remote URLs = %v, want original credential-free URL", urls)
}
}
}
+7 -7
View File
@@ -568,7 +568,7 @@ func backup(repos []types.Repo, conf *types.Conf) {
continue
}
err = local.CreateRemotePush(temprepo, d, cloneurl, r.Origin.LFS)
err = local.CreateRemotePush(temprepo, d, cloneurl, d.GetToken(), r.Origin.LFS)
if err != nil {
if errors.Is(err, git.NoErrAlreadyUpToDate) {
log.Info().
@@ -650,7 +650,7 @@ func backup(repos []types.Repo, conf *types.Conf) {
continue
}
err = local.CreateRemotePush(temprepo, d, cloneurl, r.Origin.LFS)
err = local.CreateRemotePush(temprepo, d, cloneurl, d.GetToken(), r.Origin.LFS)
if err != nil {
if errors.Is(err, git.NoErrAlreadyUpToDate) {
log.Info().
@@ -736,7 +736,7 @@ func backup(repos []types.Repo, conf *types.Conf) {
continue
}
err = local.CreateRemotePush(temprepo, d, cloneurl, r.Origin.LFS)
err = local.CreateRemotePush(temprepo, d, cloneurl, d.GetToken(), r.Origin.LFS)
if err != nil {
if errors.Is(err, git.NoErrAlreadyUpToDate) {
log.Info().
@@ -808,7 +808,7 @@ func backup(repos []types.Repo, conf *types.Conf) {
}
}
cloneurl, err := github.GetOrCreate(d, r)
cloneurl, destinationtoken, err := github.GetOrCreate(d, r)
if err != nil {
log.Error().
Str("stage", "github").
@@ -818,7 +818,7 @@ func backup(repos []types.Repo, conf *types.Conf) {
continue
}
err = local.CreateRemotePush(temprepo, d, cloneurl, r.Origin.LFS)
err = local.CreateRemotePush(temprepo, d, cloneurl, destinationtoken, r.Origin.LFS)
if err != nil {
if errors.Is(err, git.NoErrAlreadyUpToDate) {
log.Info().
@@ -896,7 +896,7 @@ func backup(repos []types.Repo, conf *types.Conf) {
continue
}
err = local.CreateRemotePush(temprepo, d, cloneurl, r.Origin.LFS)
err = local.CreateRemotePush(temprepo, d, cloneurl, d.GetToken(), r.Origin.LFS)
if err != nil {
if errors.Is(err, git.NoErrAlreadyUpToDate) {
log.Info().
@@ -975,7 +975,7 @@ func backup(repos []types.Repo, conf *types.Conf) {
continue
}
err = local.CreateRemotePush(temprepo, d, cloneurl, r.Origin.LFS)
err = local.CreateRemotePush(temprepo, d, cloneurl, d.GetToken(), r.Origin.LFS)
if err != nil {
if errors.Is(err, git.NoErrAlreadyUpToDate) {
log.Info().
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/cooperspencer/gickup/types"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
)
//nolint:paralleltest // backup calls providers that assign package-level loggers.
func TestBackupGitHubAppPushCredential(t *testing.T) {
const wantToken = "installation-token-for-test"
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
keyFile := filepath.Join(t.TempDir(), "app.pem")
if err := os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key),
}), 0o600); err != nil {
t.Fatal(err)
}
sourceDir := t.TempDir()
source, err := git.PlainInit(sourceDir, false)
if err != nil {
t.Fatal(err)
}
worktree, err := source.Worktree()
if err != nil {
t.Fatal(err)
}
if _, err := worktree.Commit("seed", &git.CommitOptions{
AllowEmptyCommits: true,
Author: &object.Signature{Name: "test", Email: "test@example.com", When: time.Now()},
}); err != nil {
t.Fatal(err)
}
var passwordsMu sync.Mutex
var passwords []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method + " " + r.URL.Path {
case "POST /api/v3/app/installations/42/access_tokens":
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]string{
"token": wantToken, "expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339),
})
case "GET /api/v3/orgs/example":
_, _ = w.Write([]byte(`{"login":"example"}`))
case "GET /api/v3/repos/example/repo":
_ = json.NewEncoder(w).Encode(map[string]string{"clone_url": "http://" + r.Host + "/example/repo.git"})
case "GET /example/repo.git/info/refs":
_, password, _ := r.BasicAuth()
passwordsMu.Lock()
passwords = append(passwords, password)
passwordsMu.Unlock()
w.WriteHeader(http.StatusUnauthorized)
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
backup([]types.Repo{{Name: "repo", URL: sourceDir}}, &types.Conf{
Destination: types.Destination{Github: []types.GenRepo{{
URL: server.URL, Organization: "example",
AppID: 1, AppInstallationID: 42, AppPrivateKeyFile: keyFile,
}}},
})
passwordsMu.Lock()
gotPasswords := passwords
passwords = nil
passwordsMu.Unlock()
if len(gotPasswords) == 0 {
t.Fatal("backup did not attempt a push")
}
for _, got := range gotPasswords {
if got != wantToken {
t.Fatalf("backup push password = %q, want resolved installation token", got)
}
}
}