From f59f502c73eef2391b29cd4bff153af5bc596d06 Mon Sep 17 00:00:00 2001 From: Luke McKechnie Date: Thu, 9 Apr 2026 14:30:09 -0500 Subject: [PATCH 1/5] auth: add per-owner account mapping to fix multi-account token selection When multiple GitHub accounts are configured for the same host, gh always uses the globally active account's token regardless of which org or user owns the current repo. This causes commands to silently authenticate as the wrong user. This change adds an owner-to-user mapping stored in hosts.yml, and automatically selects the correct token based on the repo owner resolved from git remotes at HTTP client construction time. A new command 'gh auth set-user --owner ' manages the mappings. Fixes #12885 --- internal/config/auth_config_test.go | 82 +++++++++++ internal/config/config.go | 36 ++++- internal/gh/gh.go | 12 ++ pkg/cmd/auth/auth.go | 2 + pkg/cmd/auth/setuser/setuser.go | 105 ++++++++++++++ pkg/cmd/auth/setuser/setuser_test.go | 200 +++++++++++++++++++++++++++ pkg/cmd/factory/default.go | 19 ++- 7 files changed, 450 insertions(+), 6 deletions(-) create mode 100644 pkg/cmd/auth/setuser/setuser.go create mode 100644 pkg/cmd/auth/setuser/setuser_test.go diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index ad5e3732a26..f840c2d4257 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -666,6 +666,88 @@ func TestTokenForUserNotFoundErrors(t *testing.T) { require.EqualError(t, err, "no token found for 'test-user-1'") } +func TestSetOwnerUserAndUserForOwner(t *testing.T) { + // Given two users logged in to a host + authCfg := newTestAuthConfig(t) + _, err := authCfg.Login("github.com", "personal-user", "personal-token", "", false) + require.NoError(t, err) + _, err = authCfg.Login("github.com", "work-user", "work-token", "", false) + require.NoError(t, err) + + // When we set an owner mapping + err = authCfg.SetOwnerUser("github.com", "work-org", "work-user") + require.NoError(t, err) + + // Then UserForOwner returns the mapped user + user, err := authCfg.UserForOwner("github.com", "work-org") + require.NoError(t, err) + require.Equal(t, "work-user", user) +} + +func TestUserForOwnerNotFound(t *testing.T) { + // Given no owner mappings + authCfg := newTestAuthConfig(t) + + // When we look up an owner with no mapping + _, err := authCfg.UserForOwner("github.com", "unknown-org") + + // Then it returns an error + require.Error(t, err) +} + +func TestActiveTokenUsesOwnerMappingWhenRepoOwnerSet(t *testing.T) { + // Given two users logged in insecurely, with an owner mapping + authCfg := newTestAuthConfig(t) + _, err := authCfg.Login("github.com", "personal-user", "personal-token", "", false) + require.NoError(t, err) + _, err = authCfg.Login("github.com", "work-user", "work-token", "", false) + require.NoError(t, err) + require.NoError(t, authCfg.SetOwnerUser("github.com", "work-org", "work-user")) + + // When we set the repo owner to the mapped org and get the active token + authCfg.SetRepoOwner("work-org") + token, source := authCfg.ActiveToken("github.com") + + // Then the work user's token is returned + require.Equal(t, "work-token", token) + require.Equal(t, oauthTokenKey, source) +} + +func TestActiveTokenFallsBackToActiveUserWhenNoOwnerMapping(t *testing.T) { + // Given two users logged in, work-user is globally active, no owner mapping + authCfg := newTestAuthConfig(t) + _, err := authCfg.Login("github.com", "personal-user", "personal-token", "", false) + require.NoError(t, err) + _, err = authCfg.Login("github.com", "work-user", "work-token", "", false) + require.NoError(t, err) + + // When we set the repo owner to an unmapped org + authCfg.SetRepoOwner("unknown-org") + token, source := authCfg.ActiveToken("github.com") + + // Then the globally active user's token is returned + require.Equal(t, "work-token", token) + require.Equal(t, oauthTokenKey, source) +} + +func TestActiveTokenUsesOwnerMappingFromKeyring(t *testing.T) { + // Given two users logged in securely, with an owner mapping + authCfg := newTestAuthConfig(t) + _, err := authCfg.Login("github.com", "personal-user", "personal-token", "", true) + require.NoError(t, err) + _, err = authCfg.Login("github.com", "work-user", "work-token", "", true) + require.NoError(t, err) + require.NoError(t, authCfg.SetOwnerUser("github.com", "work-org", "work-user")) + + // When we set the repo owner to the mapped org and get the active token + authCfg.SetRepoOwner("work-org") + token, source := authCfg.ActiveToken("github.com") + + // Then the work user's token is returned from the keyring + require.Equal(t, "work-token", token) + require.Equal(t, "keyring", source) +} + func requireKeyWithValue(t *testing.T, cfg *ghConfig.Config, keys []string, value string) { t.Helper() diff --git a/internal/config/config.go b/internal/config/config.go index dadfa284b30..7b38d3530ac 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -34,6 +34,7 @@ const ( telemetryKey = "telemetry" userKey = "user" usersKey = "users" + ownersKey = "owners" versionKey = "version" ) @@ -229,11 +230,16 @@ type AuthConfig struct { defaultHostOverride func() (string, string) hostsOverride func() []string tokenOverride func(string) (string, string) + repoOwner string } // ActiveToken will retrieve the active auth token for the given hostname, // searching environment variables, plain text config, and // lastly encrypted storage. +// +// If a repo owner has been set via SetRepoOwner and a mapping exists for that +// owner, the token for the mapped user will be used instead of the globally +// active user. func (c *AuthConfig) ActiveToken(hostname string) (string, string) { if c.tokenOverride != nil { return c.tokenOverride(hostname) @@ -242,7 +248,15 @@ func (c *AuthConfig) ActiveToken(hostname string) (string, string) { if token == "" { var user string var err error - if user, err = c.ActiveUser(hostname); err == nil { + // If a repo owner is set and maps to a specific user, use that user's token. + if c.repoOwner != "" { + user, err = c.UserForOwner(hostname, c.repoOwner) + } + // Fall back to the globally active user if no owner mapping exists. + if err != nil || user == "" { + user, err = c.ActiveUser(hostname) + } + if err == nil { token, err = c.TokenFromKeyringForUser(hostname, user) } if err != nil { @@ -259,6 +273,13 @@ func (c *AuthConfig) ActiveToken(hostname string) (string, string) { return token, source } +// SetRepoOwner sets the GitHub owner (user or org) of the current repo context. +// When set, ActiveToken will prefer the token for the user mapped to this owner +// over the globally active user. +func (c *AuthConfig) SetRepoOwner(owner string) { + c.repoOwner = owner +} + // HasActiveToken returns true when a token for the hostname is present. func (c *AuthConfig) HasActiveToken(hostname string) bool { token, _ := c.ActiveToken(hostname) @@ -320,6 +341,19 @@ func (c *AuthConfig) ActiveUser(hostname string) (string, error) { return c.cfg.Get([]string{hostsKey, hostname, userKey}) } +// UserForOwner retrieves the gh username mapped to the given GitHub owner (user or org) +// for the given hostname. Returns an error if no mapping exists. +func (c *AuthConfig) UserForOwner(hostname, owner string) (string, error) { + return c.cfg.Get([]string{hostsKey, hostname, ownersKey, owner}) +} + +// SetOwnerUser stores a mapping from a GitHub owner (user or org) to a gh username +// for the given hostname, persisting it to the config file. +func (c *AuthConfig) SetOwnerUser(hostname, owner, username string) error { + c.cfg.Set([]string{hostsKey, hostname, ownersKey, owner}, username) + return ghConfig.Write(c.cfg) +} + func (c *AuthConfig) Hosts() []string { if c.hostsOverride != nil { return c.hostsOverride() diff --git a/internal/gh/gh.go b/internal/gh/gh.go index 759a931f2b7..3473cd4cfaf 100644 --- a/internal/gh/gh.go +++ b/internal/gh/gh.go @@ -128,6 +128,18 @@ type AuthConfig interface { // This will not be accurate if the oauth token is set from an environment variable. ActiveUser(hostname string) (username string, err error) + // UserForOwner retrieves the gh username mapped to the given GitHub owner (user or org) + // for the given hostname. Returns an error if no mapping exists. + UserForOwner(hostname, owner string) (username string, err error) + + // SetOwnerUser stores a mapping from a GitHub owner (user or org) to a gh username + // for the given hostname, persisting it to the config file. + SetOwnerUser(hostname, owner, username string) error + + // SetRepoOwner sets the GitHub owner (user or org) of the current repo context + // so that ActiveToken prefers the mapped user's token over the globally active user. + SetRepoOwner(owner string) + // Hosts retrieves a list of known hosts. Hosts() []string diff --git a/pkg/cmd/auth/auth.go b/pkg/cmd/auth/auth.go index e8154f42495..e6026bd0e6e 100644 --- a/pkg/cmd/auth/auth.go +++ b/pkg/cmd/auth/auth.go @@ -6,6 +6,7 @@ import ( authLogoutCmd "github.com/cli/cli/v2/pkg/cmd/auth/logout" authRefreshCmd "github.com/cli/cli/v2/pkg/cmd/auth/refresh" authSetupGitCmd "github.com/cli/cli/v2/pkg/cmd/auth/setupgit" + authSetUserCmd "github.com/cli/cli/v2/pkg/cmd/auth/setuser" authStatusCmd "github.com/cli/cli/v2/pkg/cmd/auth/status" authSwitchCmd "github.com/cli/cli/v2/pkg/cmd/auth/switch" authTokenCmd "github.com/cli/cli/v2/pkg/cmd/auth/token" @@ -30,6 +31,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(authSetupGitCmd.NewCmdSetupGit(f, nil)) cmd.AddCommand(authTokenCmd.NewCmdToken(f, nil)) cmd.AddCommand(authSwitchCmd.NewCmdSwitch(f, nil)) + cmd.AddCommand(authSetUserCmd.NewCmdSetUser(f, nil)) cmdutil.DisableTelemetryForSubcommands(cmd) diff --git a/pkg/cmd/auth/setuser/setuser.go b/pkg/cmd/auth/setuser/setuser.go new file mode 100644 index 00000000000..601e82cbe53 --- /dev/null +++ b/pkg/cmd/auth/setuser/setuser.go @@ -0,0 +1,105 @@ +package authsetuser + +import ( + "fmt" + "slices" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + ghauth "github.com/cli/go-gh/v2/pkg/auth" + "github.com/spf13/cobra" +) + +// SetUserOptions holds the options for the set-user command. +type SetUserOptions struct { + IO *iostreams.IOStreams + Config func() (gh.Config, error) + Hostname string + Owner string + Username string +} + +// NewCmdSetUser creates the `gh auth set-user` command. +func NewCmdSetUser(f *cmdutil.Factory, runF func(*SetUserOptions) error) *cobra.Command { + opts := SetUserOptions{ + IO: f.IOStreams, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "set-user ", + Args: cobra.ExactArgs(1), + Short: "Map a GitHub owner to an authenticated user", + Long: heredoc.Docf(` + Map a GitHub owner (user or org) to an authenticated gh account. + + When gh runs a command inside a repository whose owner matches the + given %[1]s--owner%[1]s value, it will automatically use the token for + %[1]s%[1]s instead of the globally active account. + + This allows working with repositories owned by different GitHub accounts + without running %[1]sgh auth switch%[1]s between them. + + Run %[1]sgh auth status%[1]s to see available authenticated accounts. + `, "`"), + Example: heredoc.Doc(` + # Use your work account for all repos owned by your work org + $ gh auth set-user --owner xgdevops lukemckechnie + + # Use your personal account for your own repos + $ gh auth set-user --owner galamdring galamdring + + # Set a mapping for a specific GitHub Enterprise host + $ gh auth set-user --owner myorg --hostname enterprise.internal myuser + `), + RunE: func(c *cobra.Command, args []string) error { + opts.Username = args[0] + if runF != nil { + return runF(&opts) + } + return setUserRun(&opts) + }, + } + + cmd.Flags().StringVarP(&opts.Owner, "owner", "o", "", "The GitHub owner (user or org) to map (required)") + cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance (default: github.com)") + _ = cmd.MarkFlagRequired("owner") + + return cmd +} + +func setUserRun(opts *SetUserOptions) error { + cfg, err := opts.Config() + if err != nil { + return err + } + authCfg := cfg.Authentication() + + hostname := opts.Hostname + if hostname == "" { + hostname, _ = authCfg.DefaultHost() + } + hostname = ghauth.NormalizeHostname(hostname) + + // Validate that the hostname is known. + if !slices.Contains(authCfg.Hosts(), hostname) { + return fmt.Errorf("not logged in to %s", hostname) + } + + // Validate that the username is a known authenticated user on this host. + if !slices.Contains(authCfg.UsersForHost(hostname), opts.Username) { + return fmt.Errorf("not logged in to %s as %s", hostname, opts.Username) + } + + if err := authCfg.SetOwnerUser(hostname, opts.Owner, opts.Username); err != nil { + return fmt.Errorf("failed to save owner mapping: %w", err) + } + + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.ErrOut, "%s Mapped owner %s to user %s on %s\n", + cs.SuccessIcon(), cs.Bold(opts.Owner), cs.Bold(opts.Username), hostname) + + return nil +} diff --git a/pkg/cmd/auth/setuser/setuser_test.go b/pkg/cmd/auth/setuser/setuser_test.go new file mode 100644 index 00000000000..49e0330ed54 --- /dev/null +++ b/pkg/cmd/auth/setuser/setuser_test.go @@ -0,0 +1,200 @@ +package authsetuser + +import ( + "bytes" + "testing" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCmdSetUser(t *testing.T) { + tests := []struct { + name string + input string + wantErr string + wantOpts SetUserOptions + }{ + { + name: "owner and username parsed", + input: "--owner work-org work-user", + wantOpts: SetUserOptions{ + Owner: "work-org", + Username: "work-user", + }, + }, + { + name: "hostname flag parsed", + input: "--owner work-org --hostname ghe.io work-user", + wantOpts: SetUserOptions{ + Owner: "work-org", + Username: "work-user", + Hostname: "ghe.io", + }, + }, + { + name: "errors when owner flag is missing", + input: "work-user", + wantErr: `required flag(s) "owner" not set`, + }, + { + name: "errors when username arg is missing", + input: "--owner work-org", + wantErr: "accepts 1 arg(s), received 0", + }, + { + name: "errors when too many args", + input: "--owner work-org user1 user2", + wantErr: "accepts 1 arg(s), received 2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{} + + argv, err := shlex.Split(tt.input) + require.NoError(t, err) + + var gotOpts *SetUserOptions + cmd := NewCmdSetUser(f, func(opts *SetUserOptions) error { + gotOpts = opts + return nil + }) + // Override help so -h can be used for hostname + cmd.Flags().BoolP("help", "x", false, "") + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + + if tt.wantErr != "" { + require.ErrorContains(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantOpts.Owner, gotOpts.Owner) + assert.Equal(t, tt.wantOpts.Username, gotOpts.Username) + assert.Equal(t, tt.wantOpts.Hostname, gotOpts.Hostname) + }) + } +} + +func TestSetUserRun(t *testing.T) { + tests := []struct { + name string + owner string + username string + hostname string + setupConfig func(*config.AuthConfig) + wantErr string + wantMapping string + }{ + { + name: "maps owner to authenticated user", + owner: "work-org", + username: "work-user", + setupConfig: func(authCfg *config.AuthConfig) { + _, err := authCfg.Login("github.com", "work-user", "work-token", "", false) + require.NoError(t, err) + }, + wantMapping: "work-user", + }, + { + name: "maps personal owner to personal user", + owner: "personal-user", + username: "personal-user", + setupConfig: func(authCfg *config.AuthConfig) { + _, err := authCfg.Login("github.com", "personal-user", "personal-token", "", false) + require.NoError(t, err) + }, + wantMapping: "personal-user", + }, + { + name: "errors when not logged in to host", + owner: "work-org", + username: "work-user", + setupConfig: func(authCfg *config.AuthConfig) { + // no users logged in + }, + wantErr: "not logged in to github.com", + }, + { + name: "errors when username not an authenticated user on host", + owner: "work-org", + username: "unknown-user", + setupConfig: func(authCfg *config.AuthConfig) { + _, err := authCfg.Login("github.com", "work-user", "work-token", "", false) + require.NoError(t, err) + }, + wantErr: "not logged in to github.com as unknown-user", + }, + { + name: "uses explicit hostname flag", + owner: "work-org", + username: "work-user", + hostname: "ghe.io", + setupConfig: func(authCfg *config.AuthConfig) { + _, err := authCfg.Login("ghe.io", "work-user", "work-token", "", false) + require.NoError(t, err) + }, + wantMapping: "work-user", + }, + { + name: "errors when hostname not known", + owner: "work-org", + username: "work-user", + hostname: "unknown.internal", + setupConfig: func(authCfg *config.AuthConfig) { + _, err := authCfg.Login("github.com", "work-user", "work-token", "", false) + require.NoError(t, err) + }, + wantErr: "not logged in to unknown.internal", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, _ := config.NewIsolatedTestConfig(t) + authCfg := cfg.Authentication().(*config.AuthConfig) + tt.setupConfig(authCfg) + + ios, _, _, _ := iostreams.Test() + + opts := &SetUserOptions{ + IO: ios, + Config: func() (gh.Config, error) { + return cfg, nil + }, + Owner: tt.owner, + Username: tt.username, + Hostname: tt.hostname, + } + + err := setUserRun(opts) + + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } + + require.NoError(t, err) + + hostname := tt.hostname + if hostname == "" { + hostname = "github.com" + } + user, err := authCfg.UserForOwner(hostname, tt.owner) + require.NoError(t, err) + assert.Equal(t, tt.wantMapping, user) + }) + } +} diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index cc10075f203..4c6327f304d 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -32,11 +32,11 @@ func New(appVersion string, invokingAgent string, cfgFunc func() (gh.Config, err } f.IOStreams = ios - f.HttpClient = HttpClientFunc(cfgFunc, ios, appVersion, invokingAgent, telemetryDisabler) - f.PlainHttpClient = plainHttpClientFunc(ios, appVersion, invokingAgent, telemetryDisabler) - f.ExternalHttpClient = externalHttpClientFunc(ios, appVersion) f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable f.Remotes = remotesFunc(f) // Depends on Config, and GitClient + f.HttpClient = HttpClientFunc(cfgFunc, ios, appVersion, invokingAgent, telemetryDisabler, f.Remotes) + f.PlainHttpClient = plainHttpClientFunc(ios, appVersion, invokingAgent, telemetryDisabler) + f.ExternalHttpClient = externalHttpClientFunc(ios, appVersion) f.BaseRepo = BaseRepoFunc(f.Remotes) f.Prompter = newPrompter(f) // Depends on Config and IOStreams f.Browser = newBrowser(f) // Depends on Config, and IOStreams @@ -185,14 +185,23 @@ func remotesFunc(f *cmdutil.Factory) func() (ghContext.Remotes, error) { return rr.Resolver() } -func HttpClientFunc(cfgFunc func() (gh.Config, error), ios *iostreams.IOStreams, appVersion string, invokingAgent string, telemetryDisabler ghtelemetry.Disabler) func() (*http.Client, error) { +func HttpClientFunc(cfgFunc func() (gh.Config, error), ios *iostreams.IOStreams, appVersion string, invokingAgent string, telemetryDisabler ghtelemetry.Disabler, remotesFunc func() (ghContext.Remotes, error)) func() (*http.Client, error) { return func() (*http.Client, error) { cfg, err := cfgFunc() if err != nil { return nil, err } + authCfg := cfg.Authentication() + + // If we can resolve the current repo's owner, set it on the auth config so + // that ActiveToken will prefer the token mapped to that owner over the + // globally active user. + if remotes, err := remotesFunc(); err == nil && len(remotes) > 0 { + authCfg.SetRepoOwner(remotes[0].RepoOwner()) + } + opts := api.HTTPClientOptions{ - Config: cfg.Authentication(), + Config: authCfg, Log: ios.ErrOut, LogColorize: ios.ColorEnabled(), AppVersion: appVersion, From 0a59c95e8f65940ca6e7dd712ea95a5c317353e2 Mon Sep 17 00:00:00 2001 From: Luke McKechnie Date: Tue, 28 Apr 2026 17:59:50 -0500 Subject: [PATCH 2/5] Add rebase-and-release workflow for fork maintenance --- .github/workflows/rebase-and-release.yml | 67 ++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/workflows/rebase-and-release.yml diff --git a/.github/workflows/rebase-and-release.yml b/.github/workflows/rebase-and-release.yml new file mode 100644 index 00000000000..0d25603eb6c --- /dev/null +++ b/.github/workflows/rebase-and-release.yml @@ -0,0 +1,67 @@ +name: Rebase on upstream and release + +on: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: write + +jobs: + rebase-and-release: + runs-on: ubuntu-latest + steps: + - name: Checkout fork + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.PAT_TOKEN }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Add upstream and rebase + run: | + git remote add upstream https://github.com/cli/cli.git + git fetch upstream trunk --tags + git rebase upstream/trunk + + - name: Push rebased branch + run: | + git remote set-url origin https://x-access-token:${{ secrets.PAT_TOKEN }}@github.com/galamdring/github-cli.git + git push --force-with-lease origin HEAD + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build multi-platform + run: | + GH_VERSION=$(git describe --tags 2>/dev/null || echo "v0.0.0-unknown") + LDFLAGS="-X github.com/cli/cli/v2/internal/build.Version=$GH_VERSION -X github.com/cli/cli/v2/internal/build.Date=$(date +%Y-%m-%d)" + GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "$LDFLAGS" -o bin/gh-linux-amd64 ./cmd/gh + GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "$LDFLAGS" -o bin/gh-darwin-arm64 ./cmd/gh + GOOS=darwin GOARCH=amd64 go build -trimpath -ldflags "$LDFLAGS" -o bin/gh-darwin-amd64 ./cmd/gh + GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "$LDFLAGS" -o bin/gh-windows-amd64.exe ./cmd/gh + + - name: Determine version tag + id: version + run: | + TAG=$(git describe --tags 2>/dev/null || echo "v0.0.0-unknown") + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.tag }} + name: ${{ steps.version.outputs.tag }} + files: | + bin/gh-linux-amd64 + bin/gh-darwin-arm64 + bin/gh-darwin-amd64 + bin/gh-windows-amd64.exe + generate_release_notes: true From 8c2bac7f5cc6374dc2adae8461b385b4a89f57f6 Mon Sep 17 00:00:00 2001 From: Luke McKechnie Date: Thu, 13 Aug 2026 17:49:36 -0500 Subject: [PATCH 3/5] fix: update test call sites for upstream signature changes NewIsolatedTestConfig now takes a cfgString param, and HttpClientFunc now takes a remotesFunc param (added in the per-owner account mapping commit). Fork-added tests were never updated for these, so they failed to compile once rebased onto the current upstream/trunk. --- pkg/cmd/auth/setuser/setuser_test.go | 2 +- pkg/cmd/factory/default_test.go | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/auth/setuser/setuser_test.go b/pkg/cmd/auth/setuser/setuser_test.go index 49e0330ed54..88dd6a1a3c6 100644 --- a/pkg/cmd/auth/setuser/setuser_test.go +++ b/pkg/cmd/auth/setuser/setuser_test.go @@ -163,7 +163,7 @@ func TestSetUserRun(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cfg, _ := config.NewIsolatedTestConfig(t) + cfg, _ := config.NewIsolatedTestConfig(t, "") authCfg := cfg.Authentication().(*config.AuthConfig) tt.setupConfig(authCfg) diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index c41b77506d4..75c98b9f698 100644 --- a/pkg/cmd/factory/default_test.go +++ b/pkg/cmd/factory/default_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "testing" + ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" @@ -353,7 +354,8 @@ func TestSSOURL(t *testing.T) { t.Run(tt.name, func(t *testing.T) { cfg := config.NewMockConfig() ios, _, _, stderr := iostreams.Test() - client, err := HttpClientFunc(func() (gh.Config, error) { return cfg, nil }, ios, "v1.2.3", "", &telemetry.NoOpService{})() + remotesFunc := func() (ghContext.Remotes, error) { return nil, nil } + client, err := HttpClientFunc(func() (gh.Config, error) { return cfg, nil }, ios, "v1.2.3", "", &telemetry.NoOpService{}, remotesFunc)() require.NoError(t, err) req, err := http.NewRequest("GET", ts.URL, nil) if tt.sso != "" { From 9f41e88fe95f61b653566361c9f34ac0e148d6c0 Mon Sep 17 00:00:00 2001 From: Luke McKechnie Date: Thu, 13 Aug 2026 17:46:53 -0500 Subject: [PATCH 4/5] ci: surface rebase-and-release conflicts instead of failing silently The scheduled rebase onto upstream/trunk has been failing every week for the last 2.5 months on the same unresolved conflict, with nothing but a red check nobody was watching. Abort cleanly on conflict and file (or update) a tracking issue summarizing what needs manual resolution. --- .github/workflows/rebase-and-release.yml | 42 +++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rebase-and-release.yml b/.github/workflows/rebase-and-release.yml index 0d25603eb6c..913e7ec66ca 100644 --- a/.github/workflows/rebase-and-release.yml +++ b/.github/workflows/rebase-and-release.yml @@ -7,6 +7,7 @@ on: permissions: contents: write + issues: write jobs: rebase-and-release: @@ -24,10 +25,49 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" - name: Add upstream and rebase + id: rebase run: | git remote add upstream https://github.com/cli/cli.git git fetch upstream trunk --tags - git rebase upstream/trunk + + if git rebase upstream/trunk 2>rebase_error.log; then + exit 0 + fi + + echo "conflict=true" >> "$GITHUB_OUTPUT" + cat rebase_error.log + + { + echo "The scheduled rebase of \`$(git branch --show-current)\` onto \`upstream/trunk\` failed and needs manual resolution." + echo + echo "**Conflicting files:**" + echo '```' + git diff --name-only --diff-filter=U + echo '```' + echo + echo "**Git output:**" + echo '```' + tail -n 60 rebase_error.log + echo '```' + echo + echo "To resolve: fetch \`upstream/trunk\`, run \`git rebase upstream/trunk\`, fix the conflicts above, then force-push." + } > rebase_conflict_body.md + + git rebase --abort + exit 1 + + - name: Report rebase conflict + if: failure() && steps.rebase.outputs.conflict == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TITLE="Automated rebase onto upstream/trunk is failing" + EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --search "\"$TITLE\" in:title" --state open --json number --jq '.[0].number // empty') + if [ -n "$EXISTING" ]; then + gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file rebase_conflict_body.md + else + gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file rebase_conflict_body.md + fi - name: Push rebased branch run: | From 1aedfd89e69dd249c23e72aaa67a11598e39d533 Mon Sep 17 00:00:00 2001 From: cli automation Date: Sat, 15 Aug 2026 03:32:57 +0000 Subject: [PATCH 5/5] Bump Go to 1.26.6 --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 9b841ae5995..addf5664768 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/cli/cli/v2 go 1.26.0 -toolchain go1.26.5 +toolchain go1.26.6 require ( charm.land/bubbles/v2 v2.1.1