From 88b1299dd0e32708f74a33cbd4df2389b5e0bc8c Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 23 Aug 2026 15:51:06 -0400 Subject: [PATCH 1/2] feat(librarystore): auto-create library repo via go-github (#459) Publish assumed the target repository already existed and errored out otherwise. Repository creation is a GitHub platform operation git cannot perform, so it now runs a "create if missing" step over the go-github API before the existing git clone/commit/tag/push path. Co-Authored-By: Claude Opus 4.8 --- pkg/librarystore/github.go | 68 ++++++++++++++++++ pkg/librarystore/github_test.go | 118 ++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) diff --git a/pkg/librarystore/github.go b/pkg/librarystore/github.go index 3fa8c1fc..c6cd98b7 100644 --- a/pkg/librarystore/github.go +++ b/pkg/librarystore/github.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io/fs" + "net/http" "os" "os/exec" "path/filepath" @@ -14,6 +15,7 @@ import ( "strings" "github.com/Masterminds/semver" + "github.com/google/go-github/v89/github" ) // gitDir is the directory git owns in a working tree. It is never part of @@ -33,6 +35,12 @@ type GitHubStore struct { // commitIdentity is the author used for release commits. commitName string commitEmail string + // ensureRepository creates the export's GitHub repository when it is absent, + // and is a no-op when it already exists. It runs before the clone in Publish: + // repository creation is a platform (API) operation git cannot perform. Tests + // point remoteFor at a local bare repository that already exists and disable + // this so publish never contacts the GitHub API. + ensureRepository func(ctx context.Context, language Language, name string) error } // NewGitHubStore returns a store publishing to repositories under owner. @@ -41,6 +49,7 @@ func NewGitHubStore(owner string) *GitHubStore { s.remoteFor = func(language Language, name string) string { return fmt.Sprintf("https://github.com/%s/%s.git", s.Owner, repositoryName(language, name)) } + s.ensureRepository = s.createLibraryRepository return s } @@ -125,6 +134,10 @@ func (s *GitHubStore) Publish(ctx context.Context, artifactDir string, c Coordin return Published{}, err } + if err = s.ensureRepository(ctx, c.Language, c.Name); err != nil { + return Published{}, err + } + work, err := os.MkdirTemp("", "codefly-library-publish-*") if err != nil { return Published{}, err @@ -205,6 +218,61 @@ func validateGoModulePath(artifactDir, importPath string) error { return nil } +// createLibraryRepository creates the export's repository under the store owner +// when a credential is available, and is a no-op when the repository already +// exists. Without a token it does nothing: the subsequent clone fails with the +// "create the library repository first" message, preserving today's behavior for +// operators who provision repositories out of band. +func (s *GitHubStore) createLibraryRepository(ctx context.Context, language Language, name string) error { + token := githubToken() + if token == "" { + return nil + } + client, err := github.NewClient(github.WithAuthToken(token)) + if err != nil { + return err + } + return ensureRepositoryExists(ctx, client, s.Owner, repositoryName(language, name)) +} + +// ensureRepositoryExists creates owner/repo when GitHub reports it absent, and +// returns nil when it already exists. A "not found" means create it; any other +// failure — permission, rate limit, network — is surfaced rather than mistaken +// for a missing repository. The repository is created private: publishing an +// automated release must not make an organization's history public as a side +// effect; widening visibility is a deliberate, reversible follow-up. +func ensureRepositoryExists(ctx context.Context, client *github.Client, owner, repo string) error { + if _, resp, err := client.Repositories.Get(ctx, owner, repo); err == nil { + return nil + } else if resp == nil || resp.StatusCode != http.StatusNotFound { + return fmt.Errorf("librarystore: check repository %s/%s: %w", owner, repo, err) + } + if _, _, err := client.Repositories.Create(ctx, owner, &github.Repository{ + Name: github.Ptr(repo), + Private: github.Ptr(true), + }); err != nil { + return fmt.Errorf("librarystore: create repository %s/%s (a token with repository-creation scope is required): %w", owner, repo, err) + } + return nil +} + +// githubToken resolves a GitHub token from GITHUB_TOKEN/GH_TOKEN, falling back +// to the `gh` CLI's stored credential so local publishing works without an +// exported token. An empty result leaves repository creation to the operator. +func githubToken() string { + if t := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")); t != "" { + return t + } + if t := strings.TrimSpace(os.Getenv("GH_TOKEN")); t != "" { + return t + } + out, err := exec.Command("gh", "auth", "token").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + func parseGoModulePath(data []byte) (string, error) { for _, line := range strings.Split(string(data), "\n") { if index := strings.Index(line, "//"); index >= 0 { diff --git a/pkg/librarystore/github_test.go b/pkg/librarystore/github_test.go index ae1f129f..169f4bc6 100644 --- a/pkg/librarystore/github_test.go +++ b/pkg/librarystore/github_test.go @@ -2,12 +2,15 @@ package librarystore import ( "context" + "net/http" + "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" + "github.com/google/go-github/v89/github" "github.com/stretchr/testify/require" ) @@ -29,6 +32,9 @@ func goModule(t *testing.T, modulePath, body string) string { func storeTo(remote string) *GitHubStore { s := NewGitHubStore("codefly-dev") s.remoteFor = func(Language, string) string { return remote } + // The local bare remote already exists, so creation is a no-op; disabling it + // also keeps publish tests from ever contacting the real GitHub API. + s.ensureRepository = func(context.Context, Language, string) error { return nil } return s } @@ -68,6 +74,118 @@ func TestGitHubStorePublishResolveGoLibrary(t *testing.T) { require.Contains(t, resolved.InstallHint, "@v1.2.0") } +func TestGitHubStorePublishCreatesMissingRepositoryBeforeCloning(t *testing.T) { + ctx := context.Background() + remote := filepath.Join(t.TempDir(), "remote.git") + store := NewGitHubStore("codefly-dev") + store.remoteFor = func(Language, string) string { return remote } + + // The remote does not exist yet: publish must create it (here, the local bare + // repository the clone will target) before the git clone/commit/tag/push runs. + var created bool + store.ensureRepository = func(context.Context, Language, string) error { + created = true + return exec.Command("git", "init", "--quiet", "--bare", remote).Run() + } + + published, err := store.Publish(ctx, goModule(t, goModulePath(remote), "package authkit\n"), + Coordinates{Language: LanguageGo, Name: "authkit", Version: "1.0.0"}) + require.NoError(t, err) + require.True(t, created, "ensureRepository must run before the clone") + require.Equal(t, "1.0.0", published.Version) + + // The git path is unchanged: the release is resolvable from the created repo. + versions, err := store.List(ctx, LanguageGo, "authkit") + require.NoError(t, err) + require.Equal(t, []string{"1.0.0"}, versions) +} + +func TestGitHubStorePublishAbortsWhenRepositoryCreationFails(t *testing.T) { + ctx := context.Background() + // An unreachable remote proves the creation failure aborts before any git + // operation: a clone attempt would surface a network error, not this one. + store := storeTo("https://192.0.2.1/unreachable/authkit-go.git") + store.ensureRepository = func(context.Context, Language, string) error { + return errNoRepoScope + } + + _, err := store.Publish(ctx, goModule(t, goModulePath("https://192.0.2.1/unreachable/authkit-go.git"), "package authkit\n"), + Coordinates{Language: LanguageGo, Name: "authkit", Version: "1.0.0"}) + require.ErrorIs(t, err, errNoRepoScope) +} + +var errNoRepoScope = &repoScopeError{} + +type repoScopeError struct{} + +func (*repoScopeError) Error() string { return "no repo-creation scope" } + +func TestEnsureRepositoryExists(t *testing.T) { + ctx := context.Background() + + newClient := func(t *testing.T, handler http.HandlerFunc) *github.Client { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + client, err := github.NewClient(github.WithEnterpriseURLs(server.URL, server.URL)) + require.NoError(t, err) + return client + } + + t.Run("existing repository is a no-op", func(t *testing.T) { + var created bool + client := newClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + created = true + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"name":"authkit-go"}`)) + }) + require.NoError(t, ensureRepositoryExists(ctx, client, "codefly-dev", "authkit-go")) + require.False(t, created, "an existing repository must not be re-created") + }) + + t.Run("absent repository is created", func(t *testing.T) { + var createPath string + client := newClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusNotFound) + return + } + createPath = r.URL.Path + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"name":"authkit-go"}`)) + }) + require.NoError(t, ensureRepositoryExists(ctx, client, "codefly-dev", "authkit-go")) + require.Equal(t, "/api/v3/orgs/codefly-dev/repos", createPath, "the repository is created org-owned") + }) + + t.Run("a non-404 lookup error is surfaced, not treated as absent", func(t *testing.T) { + var created bool + client := newClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + created = true + } + w.WriteHeader(http.StatusInternalServerError) + }) + err := ensureRepositoryExists(ctx, client, "codefly-dev", "authkit-go") + require.ErrorContains(t, err, "check repository") + require.False(t, created, "a lookup failure must not trigger a blind create") + }) + + t.Run("a token without creation scope yields an actionable error", func(t *testing.T) { + client := newClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusForbidden) + }) + err := ensureRepositoryExists(ctx, client, "codefly-dev", "authkit-go") + require.ErrorContains(t, err, "repository-creation scope") + }) +} + func TestGitHubStorePublishedVersionsAreImmutableButIdenticalContentReleases(t *testing.T) { ctx := context.Background() remote := bareRepo(t) From 7c25e89c4203006375c0946386bc17d105640865 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 23 Aug 2026 17:39:08 -0400 Subject: [PATCH 2/2] fix(librarystore): make auto-created library repos usable and publishable (#459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the initial auto-create change surfaced defects where the "safe-looking" defaults quietly broke the feature. Fixes, highest severity first: - Split auth: the API token created the repo but never reached git, so a host authenticated only by GITHUB_TOKEN created a repo it then could not clone/push. The resolved credential now also authenticates github.com HTTPS git ops, injected via GIT_CONFIG_* env (scoped to github.com, kept out of argv) so create and content-push share one credential. - Visibility: repos were created private, which makes the `go get github.com/owner/name-go` install hint Publish returns fail for consumers — contradicting the store's own contract. Repos are now created public. - Default branch: created empty, so the published default branch was whatever the publishing host's init.defaultBranch happened to be. AutoInit makes it deterministic and host-independent. - Idempotency: a create losing the lookup/create race (422) is now the idempotent success the contract promises, not a misleading "needs scope" error. - Owner type: Create routed everything through the org endpoint, 404ing for a user owner the struct documents as supported; owner type now selects the org vs authenticated-user endpoint. - Testability: the token source is injectable, so the no-token degrade path and the real ensureRepository wiring are covered without shelling out to `gh`. Co-Authored-By: Claude Opus 4.8 --- pkg/librarystore/github.go | 90 ++++++++++++++++++++++---- pkg/librarystore/github_test.go | 109 +++++++++++++++++++++++++++++--- 2 files changed, 178 insertions(+), 21 deletions(-) diff --git a/pkg/librarystore/github.go b/pkg/librarystore/github.go index c6cd98b7..7cb673f4 100644 --- a/pkg/librarystore/github.go +++ b/pkg/librarystore/github.go @@ -3,6 +3,7 @@ package librarystore import ( "context" "crypto/sha256" + "encoding/base64" "encoding/hex" "errors" "fmt" @@ -13,6 +14,7 @@ import ( "path/filepath" "sort" "strings" + "sync" "github.com/Masterminds/semver" "github.com/google/go-github/v89/github" @@ -41,6 +43,13 @@ type GitHubStore struct { // point remoteFor at a local bare repository that already exists and disable // this so publish never contacts the GitHub API. ensureRepository func(ctx context.Context, language Language, name string) error + // tokenSource resolves the GitHub credential used both to create the repository + // (via the API) and to authenticate git's HTTPS clone/push to github.com, so + // the two never diverge. It is injectable so tests can exercise the no-token + // path without shelling out to `gh`. + tokenSource func() string + tokenOnce sync.Once + cachedToken string } // NewGitHubStore returns a store publishing to repositories under owner. @@ -49,10 +58,19 @@ func NewGitHubStore(owner string) *GitHubStore { s.remoteFor = func(language Language, name string) string { return fmt.Sprintf("https://github.com/%s/%s.git", s.Owner, repositoryName(language, name)) } + s.tokenSource = githubToken s.ensureRepository = s.createLibraryRepository return s } +// authToken resolves the GitHub credential once and caches it: publish runs the +// token source (which may shell out to `gh`) for repository creation and then for +// every git invocation, and re-resolving each time would be both slow and racy. +func (s *GitHubStore) authToken() string { + s.tokenOnce.Do(func() { s.cachedToken = s.tokenSource() }) + return s.cachedToken +} + // repositoryName is the per-export repository name, e.g. "authkit-go". func repositoryName(language Language, name string) string { return fmt.Sprintf("%s-%s", name, language) @@ -224,7 +242,7 @@ func validateGoModulePath(artifactDir, importPath string) error { // "create the library repository first" message, preserving today's behavior for // operators who provision repositories out of band. func (s *GitHubStore) createLibraryRepository(ctx context.Context, language Language, name string) error { - token := githubToken() + token := s.authToken() if token == "" { return nil } @@ -238,24 +256,58 @@ func (s *GitHubStore) createLibraryRepository(ctx context.Context, language Lang // ensureRepositoryExists creates owner/repo when GitHub reports it absent, and // returns nil when it already exists. A "not found" means create it; any other // failure — permission, rate limit, network — is surfaced rather than mistaken -// for a missing repository. The repository is created private: publishing an -// automated release must not make an organization's history public as a side -// effect; widening visibility is a deliberate, reversible follow-up. +// for a missing repository. +// +// The repository is created public: the store's contract is that a consumer +// resolves the export with its native tool (`go get github.com/owner/name-go`) +// without codefly or credentials, and the install hint Publish returns says +// exactly that — a private repository would make that command fail for anyone +// outside the organization. It is created org-owned when the owner is an +// organization and under the authenticated user otherwise, so the same code path +// serves the "organization or user" owner the store documents. +// +// A create that fails with 422 means the repository was created concurrently (or +// out of band) between the lookup and the create; that is the idempotent success +// this function promises, not an error. func ensureRepositoryExists(ctx context.Context, client *github.Client, owner, repo string) error { if _, resp, err := client.Repositories.Get(ctx, owner, repo); err == nil { return nil } else if resp == nil || resp.StatusCode != http.StatusNotFound { return fmt.Errorf("librarystore: check repository %s/%s: %w", owner, repo, err) } - if _, _, err := client.Repositories.Create(ctx, owner, &github.Repository{ - Name: github.Ptr(repo), - Private: github.Ptr(true), + org, err := createOwner(ctx, client, owner) + if err != nil { + return err + } + if _, resp, err := client.Repositories.Create(ctx, org, &github.Repository{ + Name: github.Ptr(repo), + Private: github.Ptr(false), + AutoInit: github.Ptr(true), }); err != nil { + if resp != nil && resp.StatusCode == http.StatusUnprocessableEntity { + return nil + } return fmt.Errorf("librarystore: create repository %s/%s (a token with repository-creation scope is required): %w", owner, repo, err) } return nil } +// createOwner maps the store owner to the org argument Repositories.Create wants: +// the owner login for an organization, or "" for a user account (which targets +// the authenticated user's `POST /user/repos`, the only user-repo creation the +// GitHub API allows). Passing an organization's login to the user endpoint — or +// vice versa — is a 404, so the owner's type must decide the route. +func createOwner(ctx context.Context, client *github.Client, owner string) (string, error) { + user, _, err := client.Users.Get(ctx, owner) + if err != nil { + return "", fmt.Errorf("librarystore: resolve owner %s: %w", owner, err) + } + if user.GetType() == "Organization" { + return owner, nil + } + return "", nil +} + // githubToken resolves a GitHub token from GITHUB_TOKEN/GH_TOKEN, falling back // to the `gh` CLI's stored credential so local publishing works without an // exported token. An empty result leaves repository creation to the operator. @@ -463,7 +515,7 @@ func (s *GitHubStore) defaultBranch(ctx context.Context, work string) (string, e func (s *GitHubStore) git(ctx context.Context, dir string, args ...string) error { //nolint:gosec // git is invoked with internal subcommands and store-controlled arguments, never a shell. command := exec.CommandContext(ctx, "git", gitArgs(dir, args)...) - command.Env = gitEnv() + command.Env = s.gitEnv() if output, err := command.CombinedOutput(); err != nil { return fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output))) } @@ -473,7 +525,7 @@ func (s *GitHubStore) git(ctx context.Context, dir string, args ...string) error func (s *GitHubStore) output(ctx context.Context, dir string, args ...string) (string, error) { //nolint:gosec // git is invoked with internal subcommands and store-controlled arguments, never a shell. command := exec.CommandContext(ctx, "git", gitArgs(dir, args)...) - command.Env = gitEnv() + command.Env = s.gitEnv() out, err := command.Output() if err != nil { var exitErr *exec.ExitError @@ -490,8 +542,24 @@ func (s *GitHubStore) output(ctx context.Context, dir string, args ...string) (s // Ambient configuration is otherwise preserved so a user's configured push // credentials still work; signing is disabled per-invocation on the commands // that create objects, not by discarding global config wholesale. -func gitEnv() []string { - return append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GCM_INTERACTIVE=Never") +// +// When a token is available it also authenticates HTTPS operations against +// github.com with that same credential, so a host that has a token but no git +// credential helper can still clone and push a repository it just created over +// the API. The header is passed through GIT_CONFIG_* — not argv — so the token +// is never exposed to `ps`, and it is scoped to github.com so git never sends it +// to another host (e.g. after a redirect). +func (s *GitHubStore) gitEnv() []string { + env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GCM_INTERACTIVE=Never") + if token := s.authToken(); token != "" { + header := "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte("x-access-token:"+token)) + env = append(env, + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=http.https://github.com/.extraHeader", + "GIT_CONFIG_VALUE_0="+header, + ) + } + return env } func gitArgs(dir string, args []string) []string { diff --git a/pkg/librarystore/github_test.go b/pkg/librarystore/github_test.go index 169f4bc6..1c60e06b 100644 --- a/pkg/librarystore/github_test.go +++ b/pkg/librarystore/github_test.go @@ -2,6 +2,8 @@ package librarystore import ( "context" + "encoding/base64" + "encoding/json" "net/http" "net/http/httptest" "os" @@ -35,6 +37,9 @@ func storeTo(remote string) *GitHubStore { // The local bare remote already exists, so creation is a no-op; disabling it // also keeps publish tests from ever contacting the real GitHub API. s.ensureRepository = func(context.Context, Language, string) error { return nil } + // No ambient token: keep git operations off the github.com auth path so tests + // never depend on the host's `gh`/GITHUB_TOKEN state. + s.tokenSource = func() string { return "" } return s } @@ -82,6 +87,7 @@ func TestGitHubStorePublishCreatesMissingRepositoryBeforeCloning(t *testing.T) { // The remote does not exist yet: publish must create it (here, the local bare // repository the clone will target) before the git clone/commit/tag/push runs. + store.tokenSource = func() string { return "" } var created bool store.ensureRepository = func(context.Context, Language, string) error { created = true @@ -145,19 +151,65 @@ func TestEnsureRepositoryExists(t *testing.T) { require.False(t, created, "an existing repository must not be re-created") }) - t.Run("absent repository is created", func(t *testing.T) { + t.Run("an absent org repository is created public, org-owned, initialized", func(t *testing.T) { var createPath string + var body struct { + Private *bool `json:"private"` + AutoInit *bool `json:"auto_init"` + } client := newClient(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { + switch { + case r.URL.Path == "/api/v3/users/codefly-dev": + _, _ = w.Write([]byte(`{"type":"Organization"}`)) + case r.URL.Path == "/api/v3/repos/codefly-dev/authkit-go": w.WriteHeader(http.StatusNotFound) - return + case r.Method == http.MethodPost: + createPath = r.URL.Path + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"name":"authkit-go"}`)) } - createPath = r.URL.Path - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(`{"name":"authkit-go"}`)) }) require.NoError(t, ensureRepositoryExists(ctx, client, "codefly-dev", "authkit-go")) - require.Equal(t, "/api/v3/orgs/codefly-dev/repos", createPath, "the repository is created org-owned") + require.Equal(t, "/api/v3/orgs/codefly-dev/repos", createPath, "an organization owner uses the org endpoint") + require.NotNil(t, body.Private) + require.False(t, *body.Private, "the published repository must be public so `go get` resolves it") + require.NotNil(t, body.AutoInit) + require.True(t, *body.AutoInit, "auto-init gives a deterministic default branch instead of the host's git config") + }) + + t.Run("a user owner is created under the authenticated user", func(t *testing.T) { + var createPath string + client := newClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v3/users/alice": + _, _ = w.Write([]byte(`{"type":"User"}`)) + case r.URL.Path == "/api/v3/repos/alice/authkit-go": + w.WriteHeader(http.StatusNotFound) + case r.Method == http.MethodPost: + createPath = r.URL.Path + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"name":"authkit-go"}`)) + } + }) + require.NoError(t, ensureRepositoryExists(ctx, client, "alice", "authkit-go")) + require.Equal(t, "/api/v3/user/repos", createPath, "a user owner uses the authenticated-user endpoint") + }) + + t.Run("a concurrent create (422) is an idempotent success", func(t *testing.T) { + client := newClient(t, func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v3/users/codefly-dev": + _, _ = w.Write([]byte(`{"type":"Organization"}`)) + case r.URL.Path == "/api/v3/repos/codefly-dev/authkit-go": + w.WriteHeader(http.StatusNotFound) + case r.Method == http.MethodPost: + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message":"Repository creation failed.","errors":[{"resource":"Repository","code":"custom","message":"name already exists on this account"}]}`)) + } + }) + require.NoError(t, ensureRepositoryExists(ctx, client, "codefly-dev", "authkit-go"), + "a repository that appeared between the lookup and the create is not an error") }) t.Run("a non-404 lookup error is surfaced, not treated as absent", func(t *testing.T) { @@ -175,17 +227,54 @@ func TestEnsureRepositoryExists(t *testing.T) { t.Run("a token without creation scope yields an actionable error", func(t *testing.T) { client := newClient(t, func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { + switch { + case r.URL.Path == "/api/v3/users/codefly-dev": + _, _ = w.Write([]byte(`{"type":"Organization"}`)) + case r.URL.Path == "/api/v3/repos/codefly-dev/authkit-go": w.WriteHeader(http.StatusNotFound) - return + case r.Method == http.MethodPost: + w.WriteHeader(http.StatusForbidden) } - w.WriteHeader(http.StatusForbidden) }) err := ensureRepositoryExists(ctx, client, "codefly-dev", "authkit-go") require.ErrorContains(t, err, "repository-creation scope") }) } +func TestGitEnvInjectsScopedTokenHeaderForGitHub(t *testing.T) { + store := NewGitHubStore("codefly-dev") + store.tokenSource = func() string { return "s3cr3t" } + + env := store.gitEnv() + want := "GIT_CONFIG_VALUE_0=Authorization: Basic " + + base64.StdEncoding.EncodeToString([]byte("x-access-token:s3cr3t")) + require.Contains(t, env, "GIT_CONFIG_KEY_0=http.https://github.com/.extraHeader", + "the credential must be scoped to github.com so it is never sent elsewhere") + require.Contains(t, env, want, "the resolved token must authenticate git clone/push, not only the API") + + // Without a token, git keeps today's ambient-credential behavior: no header. + store2 := NewGitHubStore("codefly-dev") + store2.tokenSource = func() string { return "" } + for _, e := range store2.gitEnv() { + require.NotContains(t, e, "extraHeader") + } +} + +func TestGitHubStorePublishWithoutTokenFallsBackToCloneError(t *testing.T) { + ctx := context.Background() + // Exercise the real wiring: NewGitHubStore's default ensureRepository, driven + // by a token source that yields nothing. Creation must degrade to a no-op so + // the missing remote surfaces today's "create the library repository first". + remote := filepath.Join(t.TempDir(), "missing.git") + store := NewGitHubStore("codefly-dev") + store.remoteFor = func(Language, string) string { return remote } + store.tokenSource = func() string { return "" } + + _, err := store.Publish(ctx, goModule(t, goModulePath(remote), "package authkit\n"), + Coordinates{Language: LanguageGo, Name: "authkit", Version: "1.0.0"}) + require.ErrorContains(t, err, "create the library repository first") +} + func TestGitHubStorePublishedVersionsAreImmutableButIdenticalContentReleases(t *testing.T) { ctx := context.Background() remote := bareRepo(t)