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
107 changes: 107 additions & 0 deletions .github/workflows/rebase-and-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
name: Rebase on upstream and release

on:
schedule:
- cron: '0 6 * * 1'
workflow_dispatch:

permissions:
contents: write
issues: 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
id: rebase
run: |
git remote add upstream https://github.com/cli/cli.git
git fetch upstream trunk --tags

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: |
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions internal/config/auth_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
36 changes: 35 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const (
telemetryKey = "telemetry"
userKey = "user"
usersKey = "users"
ownersKey = "owners"
versionKey = "version"
)

Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down
12 changes: 12 additions & 0 deletions internal/gh/gh.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions pkg/cmd/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)

Expand Down
Loading