Repository: cli/cli
Pull request #9766 (2024-10-15 21:14:43)

Add acceptance tests for `workflow`, `run`, and `cache` commands
This adds a set of tests for the `gh workflow`, `gh run`, and `gh cache` commands:

```
❯ set -o pipefail && GH_ACCEPTANCE_HOST=github.com GH_ACCEPTANCE_ORG=gh-acceptance-testing go test -tags acceptance -json -run ^TestWorkflows$ github.com/cli/cli/v2/acceptance | tparse --all go test
┌───────────────────────────────────────────────────────────────────────────────────────────────┐
│  STATUS │ ELAPSED │                 TEST                  │             PACKAGE               │
│─────────┼─────────┼───────────────────────────────────────┼───────────────────────────────────│
│  PASS   │   46.22 │ TestWorkflows/run-rerun               │ github.com/cli/cli/v2/acceptance  │
│  PASS   │   38.29 │ TestWorkflows/run-view                │ github.com/cli/cli/v2/acceptance  │
│  PASS   │   34.86 │ TestWorkflows/cache-list-delete       │ github.com/cli/cli/v2/acceptance  │
│  PASS   │   32.85 │ TestWorkflows/run-delete              │ github.com/cli/cli/v2/acceptance  │
│  PASS   │   28.11 │ TestWorkflows/run-download            │ github.com/cli/cli/v2/acceptance  │
│  PASS   │   24.68 │ TestWorkflows/run-cancel              │ github.com/cli/cli/v2/acceptance  │
│  PASS   │   18.31 │ TestWorkflows/workflow-run            │ github.com/cli/cli/v2/acceptance  │
│  PASS   │    9.75 │ TestWorkflows/workflow-enable-disable │ github.com/cli/cli/v2/acceptance  │
│  PASS   │    7.35 │ TestWorkflows/workflow-view           │ github.com/cli/cli/v2/acceptance  │
│  PASS   │    6.25 │ TestWorkflows/workflow-list           │ github.com/cli/cli/v2/acceptance  │
│  PASS   │    0.00 │ TestWorkflows                         │ github.com/cli/cli/v2/acceptance  │
└───────────────────────────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────────────────────────┐
│  STATUS │ ELAPSED │             PACKAGE              │ COVER │ PASS │ FAIL │ SKIP  │
│─────────┼─────────┼──────────────────────────────────┼───────┼──────┼──────┼───────│
│  PASS   │ 46.85s  │ github.com/cli/cli/v2/acceptance │  --   │  11  │  0   │  0    │
└────────────────────────────────────────────────────────────────────────────────────┘
```

You can run these with:

```
GH_ACCEPTANCE_TOKEN=<token> GH_ACCEPTANCE_HOST=<host> GH_ACCEPTANCE_ORG=<org> go test -tags acceptance -json -run ^TestWorkflows$ github.com/cli/cli/v2/acceptance
```


## Notes

This PR implements a `sleep` command because we need to wait between operations for the actions backend to wake-up before the API will start returning data. I don't particularly like the approach, but to avoid spending more time on it I've left it as-is pending feedback.


Patch:
diff --git a/Makefile b/Makefile
index 32a06df2f..e68b68939 100644
--- a/Makefile
+++ b/Makefile
@@ -38,16 +38,11 @@ completions: bin/gh$(EXE)
 	bin/gh$(EXE) completion -s fish > ./share/fish/vendor_completions.d/gh.fish
 	bin/gh$(EXE) completion -s zsh > ./share/zsh/site-functions/_gh
 
-# just convenience tasks around `go test`
+# just a convenience task around `go test`
 .PHONY: test
 test:
 	go test ./...
 
-# For more information, see https://github.com/cli/cli/blob/trunk/acceptance/README.md
-.PHONY: acceptance
-acceptance:
-	go test -tags acceptance ./acceptance
-
 ## Site-related tasks are exclusively intended for use by the GitHub CLI team and for our release automation.
 
 site:
diff --git a/api/client.go b/api/client.go
index b30f5f164..e32856554 100644
--- a/api/client.go
+++ b/api/client.go
@@ -10,8 +10,8 @@ import (
 	"regexp"
 	"strings"
 
+	"github.com/cli/cli/v2/internal/ghinstance"
 	ghAPI "github.com/cli/go-gh/v2/pkg/api"
-	ghauth "github.com/cli/go-gh/v2/pkg/auth"
 )
 
 const (
@@ -249,7 +249,7 @@ func generateScopesSuggestion(statusCode int, endpointNeedsScopes, tokenHasScope
 		return fmt.Sprintf(
 			"This API operation needs the %[1]q scope. To request it, run:  gh auth refresh -h %[2]s -s %[1]s",
 			s,
-			ghauth.NormalizeHostname(hostname),
+			ghinstance.NormalizeHostname(hostname),
 		)
 	}
 
diff --git a/api/http_client.go b/api/http_client.go
index e9381fa85..f6e133f1f 100644
--- a/api/http_client.go
+++ b/api/http_client.go
@@ -7,9 +7,9 @@ import (
 	"strings"
 	"time"
 
+	"github.com/cli/cli/v2/internal/ghinstance"
 	"github.com/cli/cli/v2/utils"
 	ghAPI "github.com/cli/go-gh/v2/pkg/api"
-	ghauth "github.com/cli/go-gh/v2/pkg/auth"
 )
 
 type tokenGetter interface {
@@ -98,7 +98,7 @@ func AddAuthTokenHeader(rt http.RoundTripper, cfg tokenGetter) http.RoundTripper
 			// Only set header if an initial request or redirect request to the same host as the initial request.
 			// If the host has changed during a redirect do not add the authentication token header.
 			if !redirectHostnameChange {
-				hostname := ghauth.NormalizeHostname(getHost(req))
+				hostname := ghinstance.NormalizeHostname(getHost(req))
 				if token, _ := cfg.ActiveToken(hostname); token != "" {
 					req.Header.Set(authorization, fmt.Sprintf("token %s", token))
 				}
diff --git a/go.mod b/go.mod
index 874885da6..42e3c84e6 100644
--- a/go.mod
+++ b/go.mod
@@ -11,7 +11,7 @@ require (
 	github.com/cenkalti/backoff/v4 v4.3.0
 	github.com/charmbracelet/glamour v0.7.0
 	github.com/charmbracelet/lipgloss v0.10.1-0.20240413172830-d0be07ea6b9c
-	github.com/cli/go-gh/v2 v2.11.0
+	github.com/cli/go-gh/v2 v2.10.0
 	github.com/cli/oauth v1.1.1
 	github.com/cli/safeexec v1.0.1
 	github.com/cpuguy83/go-md2man/v2 v2.0.5
diff --git a/go.sum b/go.sum
index 28e339f9e..9a1fc23ee 100644
--- a/go.sum
+++ b/go.sum
@@ -95,8 +95,8 @@ github.com/charmbracelet/x/exp/term v0.0.0-20240425164147-ba2a9512b05f/go.mod h1
 github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q=
 github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
 github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
-github.com/cli/go-gh/v2 v2.11.0 h1:TERLYMMWderKBO3lBff/JIu2+eSly2oFRgN2WvO+3eA=
-github.com/cli/go-gh/v2 v2.11.0/go.mod h1:MeRoKzXff3ygHu7zP+NVTT+imcHW6p3tpuxHAzRM2xE=
+github.com/cli/go-gh/v2 v2.10.0 h1:GMflBKoErBXlLvN2euxzL+p7JaM8erlSmw0cT7uZr7M=
+github.com/cli/go-gh/v2 v2.10.0/go.mod h1:MeRoKzXff3ygHu7zP+NVTT+imcHW6p3tpuxHAzRM2xE=
 github.com/cli/oauth v1.1.1 h1:459gD3hSjlKX9B1uXBuiAMdpXBUQ9QGf/NDcCpoQxPs=
 github.com/cli/oauth v1.1.1/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4=
 github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
diff --git a/internal/authflow/flow.go b/internal/authflow/flow.go
index 1511f6e67..52b00284b 100644
--- a/internal/authflow/flow.go
+++ b/internal/authflow/flow.go
@@ -16,8 +16,6 @@ import (
 	"github.com/cli/cli/v2/utils"
 	"github.com/cli/oauth"
 	"github.com/henvic/httpretty"
-
-	ghauth "github.com/cli/go-gh/v2/pkg/auth"
 )
 
 var (
@@ -43,6 +41,13 @@ func AuthFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
 	minimumScopes := []string{"repo", "read:org", "gist"}
 	scopes := append(minimumScopes, additionalScopes...)
 
+	callbackURI := "http://127.0.0.1/callback"
+	if ghinstance.IsEnterprise(oauthHost) {
+		// the OAuth app on Enterprise hosts is still registered with a legacy callback URL
+		// see https://github.com/cli/cli/pull/222, https://github.com/cli/cli/pull/650
+		callbackURI = "http://localhost/"
+	}
+
 	host, err := oauth.NewGitHubHost(ghinstance.HostPrefix(oauthHost))
 	if err != nil {
 		return "", "", err
@@ -52,7 +57,7 @@ func AuthFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
 		Host:         host,
 		ClientID:     oauthClientID,
 		ClientSecret: oauthClientSecret,
-		CallbackURI:  getCallbackURI(oauthHost),
+		CallbackURI:  callbackURI,
 		Scopes:       scopes,
 		DisplayCode: func(code, verificationURL string) error {
 			fmt.Fprintf(w, "%s First copy your one-time code: %s\n", cs.Yellow("!"), cs.Bold(code))
@@ -105,16 +110,6 @@ func AuthFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
 	return token.Token, userLogin, nil
 }
 
-func getCallbackURI(oauthHost string) string {
-	callbackURI := "http://127.0.0.1/callback"
-	if ghauth.IsEnterprise(oauthHost) {
-		// the OAuth app on Enterprise hosts is still registered with a legacy callback URL
-		// see https://github.com/cli/cli/pull/222, https://github.com/cli/cli/pull/650
-		callbackURI = "http://localhost/"
-	}
-	return callbackURI
-}
-
 type cfg struct {
 	token string
 }
diff --git a/internal/config/config.go b/internal/config/config.go
index 476154d66..1b56d30b2 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -10,7 +10,7 @@ import (
 	"github.com/cli/cli/v2/internal/gh"
 	"github.com/cli/cli/v2/internal/keyring"
 	o "github.com/cli/cli/v2/pkg/option"
-	ghauth "github.com/cli/go-gh/v2/pkg/auth"
+	ghAuth "github.com/cli/go-gh/v2/pkg/auth"
 	ghConfig "github.com/cli/go-gh/v2/pkg/config"
 )
 
@@ -206,7 +206,7 @@ func (c *AuthConfig) ActiveToken(hostname string) (string, string) {
 	if c.tokenOverride != nil {
 		return c.tokenOverride(hostname)
 	}
-	token, source := ghauth.TokenFromEnvOrConfig(hostname)
+	token, source := ghAuth.TokenFromEnvOrConfig(hostname)
 	if token == "" {
 		var err error
 		token, err = c.TokenFromKeyring(hostname)
@@ -240,7 +240,7 @@ func (c *AuthConfig) HasEnvToken() bool {
 	// It has to use a hostname that is not going to be found in the hosts so that it
 	// can guarantee that tokens will only be returned from a set env var.
 	// Discussed here, but maybe worth revisiting: https://github.com/cli/cli/pull/7169#discussion_r1136979033
-	token, _ := ghauth.TokenFromEnvOrConfig(hostname)
+	token, _ := ghAuth.TokenFromEnvOrConfig(hostname)
 	return token != ""
 }
 
@@ -282,7 +282,7 @@ func (c *AuthConfig) Hosts() []string {
 	if c.hostsOverride != nil {
 		return c.hostsOverride()
 	}
-	return ghauth.KnownHosts()
+	return ghAuth.KnownHosts()
 }
 
 // SetHosts will override any hosts resolution and return the given
@@ -297,7 +297,7 @@ func (c *AuthConfig) DefaultHost() (string, string) {
 	if c.defaultHostOverride != nil {
 		return c.defaultHostOverride()
 	}
-	return ghauth.DefaultHost()
+	return ghAuth.DefaultHost()
 }
 
 // SetDefaultHost will override any host resolution and return the given
diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go
index a9bbe25f8..396d1eb5b 100644
--- a/internal/featuredetection/feature_detection.go
+++ b/internal/featuredetection/feature_detection.go
@@ -4,9 +4,8 @@ import (
 	"net/http"
 
 	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/ghinstance"
 	"golang.org/x/sync/errgroup"
-
-	ghauth "github.com/cli/go-gh/v2/pkg/auth"
 )
 
 type Detector interface {
@@ -63,7 +62,7 @@ func NewDetector(httpClient *http.Client, host string) Detector {
 }
 
 func (d *detector) IssueFeatures() (IssueFeatures, error) {
-	if !ghauth.IsEnterprise(d.host) {
+	if !ghinstance.IsEnterprise(d.host) {
 		return allIssueFeatures, nil
 	}
 
@@ -164,7 +163,7 @@ func (d *detector) PullRequestFeatures() (PullRequestFeatures, error) {
 }
 
 func (d *detector) RepositoryFeatures() (RepositoryFeatures, error) {
-	if !ghauth.IsEnterprise(d.host) {
+	if !ghinstance.IsEnterprise(d.host) {
 		return allRepositoryFeatures, nil
 	}
 
diff --git a/internal/ghinstance/host.go b/internal/ghinstance/host.go
index be839d62e..f80bfef57 100644
--- a/internal/ghinstance/host.go
+++ b/internal/ghinstance/host.go
@@ -4,8 +4,6 @@ import (
 	"errors"
 	"fmt"
 	"strings"
-
-	ghauth "github.com/cli/go-gh/v2/pkg/auth"
 )
 
 // DefaultHostname is the domain name of the default GitHub instance.
@@ -22,10 +20,22 @@ func Default() string {
 	return defaultHostname
 }
 
+// IsEnterprise reports whether a non-normalized host name looks like a GHE instance.
+func IsEnterprise(h string) bool {
+	normalizedHostName := NormalizeHostname(h)
+	return normalizedHostName != defaultHostname && normalizedHostName != localhost
+}
+
+// IsTenancy reports whether a non-normalized host name looks like a tenancy instance.
+func IsTenancy(h string) bool {
+	normalizedHostName := NormalizeHostname(h)
+	return strings.HasSuffix(normalizedHostName, "."+tenancyHost)
+}
+
 // TenantName extracts the tenant name from tenancy host name and
 // reports whether it found the tenant name.
 func TenantName(h string) (string, bool) {
-	normalizedHostName := ghauth.NormalizeHostname(h)
+	normalizedHostName := NormalizeHostname(h)
 	return cutSuffix(normalizedHostName, "."+tenancyHost)
 }
 
@@ -33,6 +43,22 @@ func isGarage(h string) bool {
 	return strings.EqualFold(h, "garage.github.com")
 }
 
+// NormalizeHostname returns the canonical host name of a GitHub instance.
+func NormalizeHostname(h string) string {
+	hostname := strings.ToLower(h)
+	if strings.HasSuffix(hostname, "."+defaultHostname) {
+		return defaultHostname
+	}
+	if strings.HasSuffix(hostname, "."+localhost) {
+		return localhost
+	}
+	if before, found := cutSuffix(hostname, "."+tenancyHost); found {
+		idx := strings.LastIndex(before, ".")
+		return fmt.Sprintf("%s.%s", before[idx+1:], tenancyHost)
+	}
+	return hostname
+}
+
 func HostnameValidator(hostname string) error {
 	if len(strings.TrimSpace(hostname)) < 1 {
 		return errors.New("a value is required")
@@ -47,7 +73,14 @@ func GraphQLEndpoint(hostname string) string {
 	if isGarage(hostname) {
 		return fmt.Sprintf("https://%s/api/graphql", hostname)
 	}
-	if ghauth.IsEnterprise(hostname) {
+	// Once we change Tenancy to no longer be treated as Enterprise, this
+	// conditional can be removed as the flow will fall through to the bottom.
+	// However, we can't do that until we've investigated all places in which
+	// Tenancy is currently treated as Enterprise.
+	if IsTenancy(hostname) {
+		return fmt.Sprintf("https://api.%s/graphql", hostname)
+	}
+	if IsEnterprise(hostname) {
 		return fmt.Sprintf("https://%s/api/graphql", hostname)
 	}
 	if strings.EqualFold(hostname, localhost) {
@@ -60,7 +93,14 @@ func RESTPrefix(hostname string) string {
 	if isGarage(hostname) {
 		return fmt.Sprintf("https://%s/api/v3/", hostname)
 	}
-	if ghauth.IsEnterprise(hostname) {
+	// Once we change Tenancy to no longer be treated as Enterprise, this
+	// conditional can be removed as the flow will fall through to the bottom.
+	// However, we can't do that until we've investigated all places in which
+	// Tenancy is currently treated as Enterprise.
+	if IsTenancy(hostname) {
+		return fmt.Sprintf("https://api.%s/", hostname)
+	}
+	if IsEnterprise(hostname) {
 		return fmt.Sprintf("https://%s/api/v3/", hostname)
 	}
 	if strings.EqualFold(hostname, localhost) {
@@ -81,7 +121,7 @@ func GistHost(hostname string) string {
 	if isGarage(hostname) {
 		return fmt.Sprintf("%s/gist/", hostname)
 	}
-	if ghauth.IsEnterprise(hostname) {
+	if IsEnterprise(hostname) {
 		return fmt.Sprintf("%s/gist/", hostname)
 	}
 	if strings.EqualFold(hostname, localhost) {
diff --git a/internal/ghrepo/repo.go b/internal/ghrepo/repo.go
index a31d354b5..4f0328e99 100644
--- a/internal/ghrepo/repo.go
+++ b/internal/ghrepo/repo.go
@@ -6,7 +6,7 @@ import (
 	"strings"
 
 	"github.com/cli/cli/v2/internal/ghinstance"
-	ghauth "github.com/cli/go-gh/v2/pkg/auth"
+	ghAuth "github.com/cli/go-gh/v2/pkg/auth"
 	"github.com/cli/go-gh/v2/pkg/repository"
 )
 
@@ -37,7 +37,7 @@ func FullName(r Interface) string {
 }
 
 func defaultHost() string {
-	host, _ := ghauth.DefaultHost()
+	host, _ := ghAuth.DefaultHost()
 	return host
 }
 
diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go
index 653c9e6c0..1480a29a0 100644
--- a/pkg/cmd/auth/login/login.go
+++ b/pkg/cmd/auth/login/login.go
@@ -15,7 +15,7 @@ import (
 	"github.com/cli/cli/v2/pkg/cmd/auth/shared/gitcredentials"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
-	ghauth "github.com/cli/go-gh/v2/pkg/auth"
+	ghAuth "github.com/cli/go-gh/v2/pkg/auth"
 	"github.com/spf13/cobra"
 )
 
@@ -123,7 +123,7 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm
 			}
 
 			if opts.Hostname == "" && (!opts.Interactive || opts.Web) {
-				opts.Hostname, _ = ghauth.DefaultHost()
+				opts.Hostname, _ = ghAuth.DefaultHost()
 			}
 
 			opts.MainExecutable = f.Executable()
diff --git a/pkg/cmd/gist/clone/clone.go b/pkg/cmd/gist/clone/clone.go
index c7db74521..6fa6f226a 100644
--- a/pkg/cmd/gist/clone/clone.go
+++ b/pkg/cmd/gist/clone/clone.go
@@ -8,12 +8,11 @@ import (
 	"github.com/MakeNowJust/heredoc"
 	"github.com/cli/cli/v2/git"
 	"github.com/cli/cli/v2/internal/gh"
+	"github.com/cli/cli/v2/internal/ghinstance"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
 	"github.com/spf13/cobra"
 	"github.com/spf13/pflag"
-
-	ghauth "github.com/cli/go-gh/v2/pkg/auth"
 )
 
 type CloneOptions struct {
@@ -94,7 +93,7 @@ func cloneRun(opts *CloneOptions) error {
 }
 
 func formatRemoteURL(hostname string, gistID string, protocol string) string {
-	if ghauth.IsEnterprise(hostname) {
+	if ghinstance.IsEnterprise(hostname) {
 		if protocol == "ssh" {
 			return fmt.Sprintf("git@%s:gist/%s.git", hostname, gistID)
 		}
diff --git a/pkg/cmd/pr/status/http.go b/pkg/cmd/pr/status/http.go
index b3fbbac26..1eb12ed74 100644
--- a/pkg/cmd/pr/status/http.go
+++ b/pkg/cmd/pr/status/http.go
@@ -6,10 +6,9 @@ import (
 	"strings"
 
 	"github.com/cli/cli/v2/api"
+	"github.com/cli/cli/v2/internal/ghinstance"
 	"github.com/cli/cli/v2/internal/ghrepo"
 	"github.com/cli/cli/v2/pkg/set"
-
-	ghauth "github.com/cli/go-gh/v2/pkg/auth"
 )
 
 type requestOptions struct {
@@ -121,10 +120,13 @@ func pullRequestStatus(httpClient *http.Client, repo ghrepo.Interface, options r
 		}
 	`
 
-	currentUsername, err := getCurrentUsername(options.Username, repo.RepoHost(), apiClient)
-
-	if err != nil {
-		return nil, err
+	currentUsername := options.Username
+	if currentUsername == "@me" && ghinstance.IsEnterprise(repo.RepoHost()) {
+		var err error
+		currentUsername, err = api.CurrentLoginName(apiClient, repo.RepoHost())
+		if err != nil {
+			return nil, err
+		}
 	}
 
 	viewerQuery := fmt.Sprintf("repo:%s state:open is:pr author:%s", ghrepo.FullName(repo), currentUsername)
@@ -186,17 +188,6 @@ func pullRequestStatus(httpClient *http.Client, repo ghrepo.Interface, options r
 	return &payload, nil
 }
 
-func getCurrentUsername(username string, hostname string, apiClient *api.Client) (string, error) {
-	if username == "@me" && ghauth.IsEnterprise(hostname) {
-		var err error
-		username, err = api.CurrentLoginName(apiClient, hostname)
-		if err != nil {
-			return "", err
-		}
-	}
-	return username, nil
-}
-
 func pullRequestFragment(conflictStatus bool, statusCheckRollupWithCountByState bool) (string, error) {
 	fields := []string{
 		"number", "title", "state", "url", "isDraft", "isCrossRepository",
diff --git a/pkg/cmd/repo/delete/delete.go b/pkg/cmd/repo/delete/delete.go
index 722d748b9..1e96e404d 100644
--- a/pkg/cmd/repo/delete/delete.go
+++ b/pkg/cmd/repo/delete/delete.go
@@ -10,7 +10,7 @@ import (
 	"github.com/cli/cli/v2/internal/ghrepo"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
-	ghauth "github.com/cli/go-gh/v2/pkg/auth"
+	ghAuth "github.com/cli/go-gh/v2/pkg/auth"
 	"github.com/spf13/cobra"
 )
 
@@ -85,7 +85,7 @@ func deleteRun(opts *DeleteOptions) error {
 	} else {
 		repoSelector := opts.RepoArg
 		if !strings.Contains(repoSelector, "/") {
-			defaultHost, _ := ghauth.DefaultHost()
+			defaultHost, _ := ghAuth.DefaultHost()
 			currentUser, err := api.CurrentLoginName(apiClient, defaultHost)
 			if err != nil {
 				return err
diff --git a/pkg/cmd/ruleset/list/list.go b/pkg/cmd/ruleset/list/list.go
index 2b945c906..7cc533e13 100644
--- a/pkg/cmd/ruleset/list/list.go
+++ b/pkg/cmd/ruleset/list/list.go
@@ -15,7 +15,7 @@ import (
 	"github.com/cli/cli/v2/pkg/cmd/ruleset/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
-	ghauth "github.com/cli/go-gh/v2/pkg/auth"
+	ghAuth "github.com/cli/go-gh/v2/pkg/auth"
 	"github.com/spf13/cobra"
 )
 
@@ -109,7 +109,7 @@ func listRun(opts *ListOptions) error {
 		}
 	}
 
-	hostname, _ := ghauth.DefaultHost()
+	hostname, _ := ghAuth.DefaultHost()
 
 	if opts.WebMode {
 		var rulesetURL string
diff --git a/pkg/cmd/ruleset/view/view.go b/pkg/cmd/ruleset/view/view.go
index cefd926ab..d272d273e 100644
--- a/pkg/cmd/ruleset/view/view.go
+++ b/pkg/cmd/ruleset/view/view.go
@@ -15,7 +15,7 @@ import (
 	"github.com/cli/cli/v2/pkg/cmd/ruleset/shared"
 	"github.com/cli/cli/v2/pkg/cmdutil"
 	"github.com/cli/cli/v2/pkg/iostreams"
-	ghauth "github.com/cli/go-gh/v2/pkg/auth"
+	ghAuth "github.com/cli/go-gh/v2/pkg/auth"
 	"github.com/spf13/cobra"
 )
 
@@ -124,7 +124,7 @@ func viewRun(opts *ViewOptions) error {
 		}
 	}
 
-	hostname, _ := ghauth.DefaultHost()
+	hostname, _ := ghAuth.DefaultHost()
 	cs := opts.IO.ColorScheme()
 
 	if opts.InteractiveMode {


Test patch:
diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go
index 5b4fe7b58..e390f2247 100644
--- a/acceptance/acceptance_test.go
+++ b/acceptance/acceptance_test.go
@@ -6,8 +6,10 @@ import (
 	"fmt"
 	"os"
 	"path"
+	"strconv"
 	"strings"
 	"testing"
+	"time"
 
 	"math/rand"
 
@@ -28,19 +30,21 @@ func TestMain(m *testing.M) {
 func TestPullRequests(t *testing.T) {
 	var tsEnv testScriptEnv
 	if err := tsEnv.fromEnv(); err != nil {
-		t.Fatal(err)
+		fmt.Fprintln(os.Stderr, err)
+		os.Exit(1)
 	}
 
 	testscript.Run(t, testScriptParamsFor(tsEnv, "pr"))
 }
 
-func TestIssues(t *testing.T) {
+func TestWorkflows(t *testing.T) {
 	var tsEnv testScriptEnv
 	if err := tsEnv.fromEnv(); err != nil {
-		t.Fatal(err)
+		fmt.Fprintln(os.Stderr, err)
+		os.Exit(1)
 	}
 
-	testscript.Run(t, testScriptParamsFor(tsEnv, "pr"))
+	testscript.Run(t, testScriptParamsFor(tsEnv, "workflow"))
 }
 
 func testScriptParamsFor(tsEnv testScriptEnv, command string) testscript.Params {
@@ -127,6 +131,23 @@ func sharedCmds(tsEnv testScriptEnv) map[string]func(ts *testscript.TestScript,
 
 			ts.Setenv(args[0], strings.TrimRight(ts.ReadFile("stdout"), "\n"))
 		},
+		"sleep": func(ts *testscript.TestScript, neg bool, args []string) {
+			if neg {
+				ts.Fatalf("unsupported: ! sleep")
+			}
+			if len(args) != 1 {
+				ts.Fatalf("usage: sleep seconds")
+			}
+
+			// sleep for the given number of seconds
+			seconds, err := strconv.Atoi(args[0])
+			if err != nil {
+				ts.Fatalf("invalid number of seconds: %v", err)
+			}
+
+			d := time.Duration(seconds) * time.Second
+			time.Sleep(d)
+		},
 	}
 }
 
@@ -155,7 +176,7 @@ type missingEnvError struct {
 }
 
 func (e missingEnvError) Error() string {
-	return fmt.Sprintf("environment variable(s) %s must be set and non-empty", strings.Join(e.missingEnvs, ", "))
+	return fmt.Sprintf("environment variables %s must be set and non-empty", strings.Join(e.missingEnvs, ", "))
 }
 
 type testScriptEnv struct {
diff --git a/acceptance/testdata/issue/issue-comment.txtar b/acceptance/testdata/issue/issue-comment.txtar
deleted file mode 100644
index f47bf619c..000000000
--- a/acceptance/testdata/issue/issue-comment.txtar
+++ /dev/null
@@ -1,20 +0,0 @@
-# Create a repository with a file so it has a default branch
-exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
-
-# Defer repo cleanup
-defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
-
-# Clone the repo
-exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
-
-# Create an issue in the repo
-cd $SCRIPT_NAME-$RANDOM_STRING
-exec gh issue create --title 'Feature Request' --body 'Feature Body'
-stdout2env ISSUE_URL
-
-# Comment on the issue
-exec gh issue comment $ISSUE_URL --body 'Looks like a great feature!'
-
-# View the issue
-exec gh issue view $ISSUE_URL --comments
-stdout 'Looks like a great feature!'
diff --git a/acceptance/testdata/issue/issue-create-basic.txtar b/acceptance/testdata/issue/issue-create-basic.txtar
deleted file mode 100644
index ddba28eec..000000000
--- a/acceptance/testdata/issue/issue-create-basic.txtar
+++ /dev/null
@@ -1,17 +0,0 @@
-# Create a repository with a file so it has a default branch
-exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
-
-# Defer repo cleanup
-defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
-
-# Clone the repo
-exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
-
-# Create an issue in the repo
-cd $SCRIPT_NAME-$RANDOM_STRING
-exec gh issue create --title 'Feature Request' --body 'Feature Body'
-stdout2env ISSUE_URL
-
-# Check the issue was created
-exec gh issue view $ISSUE_URL
-stdout 'title:\tFeature Request$'
diff --git a/acceptance/testdata/issue/issue-create-with-metadata.txtar b/acceptance/testdata/issue/issue-create-with-metadata.txtar
deleted file mode 100644
index 8187f96cf..000000000
--- a/acceptance/testdata/issue/issue-create-with-metadata.txtar
+++ /dev/null
@@ -1,19 +0,0 @@
-# Create a repository with a file so it has a default branch
-exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
-
-# Defer repo cleanup
-defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
-
-# Clone the repo
-exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
-
-# Create an issue in the repo
-cd $SCRIPT_NAME-$RANDOM_STRING
-exec gh issue create --title 'Feature Request' --body 'Feature Body' --assignee '@me' --label 'bug'
-stdout2env ISSUE_URL
-
-# Check the issue was create
-exec gh issue view $ISSUE_URL
-stdout 'title:\tFeature Request$'
-stdout 'assignees:\t.+$'
-stdout 'labels:\tbug$'
diff --git a/acceptance/testdata/issue/issue-list.txtar b/acceptance/testdata/issue/issue-list.txtar
deleted file mode 100644
index 5a810f5e1..000000000
--- a/acceptance/testdata/issue/issue-list.txtar
+++ /dev/null
@@ -1,16 +0,0 @@
-# Create a repository with a file so it has a default branch
-exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
-
-# Defer repo cleanup
-defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
-
-# Clone the repo
-exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
-
-# Create an issue in the repo
-cd $SCRIPT_NAME-$RANDOM_STRING
-exec gh issue create --title 'Feature Request' --body 'Feature Body'
-
-# Check the issue is included in the list output
-exec gh issue list
-stdout 'OPEN\tFeature Request'
diff --git a/acceptance/testdata/issue/issue-view.txtar b/acceptance/testdata/issue/issue-view.txtar
deleted file mode 100644
index ddba28eec..000000000
--- a/acceptance/testdata/issue/issue-view.txtar
+++ /dev/null
@@ -1,17 +0,0 @@
-# Create a repository with a file so it has a default branch
-exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
-
-# Defer repo cleanup
-defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
-
-# Clone the repo
-exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
-
-# Create an issue in the repo
-cd $SCRIPT_NAME-$RANDOM_STRING
-exec gh issue create --title 'Feature Request' --body 'Feature Body'
-stdout2env ISSUE_URL
-
-# Check the issue was created
-exec gh issue view $ISSUE_URL
-stdout 'title:\tFeature Request$'
diff --git a/acceptance/testdata/workflow/cache-list-delete.txtar b/acceptance/testdata/workflow/cache-list-delete.txtar
new file mode 100644
index 000000000..6a99f4bc2
--- /dev/null
+++ b/acceptance/testdata/workflow/cache-list-delete.txtar
@@ -0,0 +1,69 @@
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Clone the repo
+exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# commit the workflow file
+cd $SCRIPT_NAME-$RANDOM_STRING
+mkdir .github/workflows
+mv ../workflow.yml .github/workflows/workflow.yml
+exec git add .github/workflows/workflow.yml
+exec git commit -m 'Create workflow file'
+exec git push -u origin main
+
+# Sleep because it takes a second for the workflow to register
+sleep 1
+
+# Check the workflow is indeed created
+exec gh workflow list
+stdout 'Test Workflow Name'
+
+# Run the workflow
+exec gh workflow run 'Test Workflow Name'
+
+# It takes some time for a workflow run to register
+sleep 10
+
+# Get the run ID we want to watch
+exec gh run list --json databaseId --jq '.[0].databaseId'
+stdout2env RUN_ID
+
+# Wait for workflow to complete
+exec gh run watch $RUN_ID --exit-status
+
+# List the cache
+exec gh cache list
+stdout 'Linux-values'
+
+# Delete the cache
+exec gh cache delete 'Linux-values'
+
+-- workflow.yml --
+name: Test Workflow Name
+
+on: workflow_dispatch
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v4
+
+    - name: Cache values
+      id: cache-values
+      uses: actions/cache@v4
+      with:
+        path: values.txt
+        key: ${{ runner.os }}-values
+
+    - name: Generate values file
+      if: steps.cache-values.outputs.cache-hit != 'true'
+      run: echo "values" > values.txt
diff --git a/acceptance/testdata/workflow/run-cancel.txtar b/acceptance/testdata/workflow/run-cancel.txtar
new file mode 100644
index 000000000..08d8d519a
--- /dev/null
+++ b/acceptance/testdata/workflow/run-cancel.txtar
@@ -0,0 +1,73 @@
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Clone the repo
+exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# commit the workflow file
+cd $SCRIPT_NAME-$RANDOM_STRING
+mkdir .github/workflows
+mv ../workflow.yml .github/workflows/workflow.yml
+exec git add .github/workflows/workflow.yml
+exec git commit -m 'Create workflow file'
+exec git push -u origin main
+
+# Sleep because it takes a second for the workflow to register
+sleep 1
+
+# Check the workflow is indeed created
+exec gh workflow list
+stdout 'Test Workflow Name'
+
+# Run the workflow
+exec gh workflow run 'Test Workflow Name'
+
+# It takes some time for a workflow run to register
+sleep 10
+
+# Get the run ID we want to cancel
+exec gh run list --json databaseId --jq '.[0].databaseId'
+stdout2env RUN_ID
+
+# cancel the workflow run
+exec gh run cancel $RUN_ID
+stdout '✓ Request to cancel workflow [0-9]+ submitted.'
+
+# Wait for workflow to complete
+exec gh run watch $RUN_ID
+
+# Check the workflow run is cancelled
+exec gh run list --json conclusion --jq '.[0].conclusion'
+stdout 'cancelled'
+
+-- workflow.yml --
+# This is a basic workflow to help you get started with Actions
+
+name: Test Workflow Name
+
+# Controls when the workflow will run
+on:
+  # Allows you to run this workflow manually from the Actions tab
+  workflow_dispatch:
+
+# A workflow run is made up of one or more jobs that can run sequentially or in parallel
+jobs:
+  # This workflow contains a single job called "build"
+  build:
+    # The type of runner that the job will run on
+    runs-on: ubuntu-latest
+
+    # Steps represent a sequence of tasks that will be executed as part of the job
+    steps:
+      # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
+      - uses: actions/checkout@v4
+
+      # Runs a single command using the runners shell
+      - name: Run a one-line script
+        run: sleep 30
diff --git a/acceptance/testdata/workflow/run-delete.txtar b/acceptance/testdata/workflow/run-delete.txtar
new file mode 100644
index 000000000..72d098740
--- /dev/null
+++ b/acceptance/testdata/workflow/run-delete.txtar
@@ -0,0 +1,74 @@
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Clone the repo
+exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# commit the workflow file
+cd $SCRIPT_NAME-$RANDOM_STRING
+mkdir .github/workflows
+mv ../workflow.yml .github/workflows/workflow.yml
+exec git add .github/workflows/workflow.yml
+exec git commit -m 'Create workflow file'
+exec git push -u origin main
+
+# Sleep because it takes a second for the workflow to register
+sleep 1
+
+# Check the workflow is indeed created
+exec gh workflow list
+stdout 'Test Workflow Name'
+
+# Run the workflow
+exec gh workflow run 'Test Workflow Name'
+
+# It takes some time for a workflow run to register
+sleep 10
+
+# Get the run ID we want to watch & delete
+exec gh run list --json databaseId --jq '.[0].databaseId'
+stdout2env RUN_ID
+
+# Wait for workflow to complete
+exec gh run watch $RUN_ID --exit-status
+
+# Delete the workflow run
+exec gh run delete $RUN_ID
+stdout '✓ Request to delete workflow submitted.'
+
+# It takes some time for a workflow run to be deleted
+sleep 5
+
+# Check the workflow run is cancelled, which is implied by an empty list
+exec gh run list
+stdout ''
+
+-- workflow.yml --
+# This is a basic workflow to help you get started with Actions
+
+name: Test Workflow Name
+
+# Controls when the workflow will run
+on:
+  # Allows you to run this workflow manually from the Actions tab
+  workflow_dispatch:
+
+# A workflow run is made up of one or more jobs that can run sequentially or in parallel
+jobs:
+  # This workflow contains a single job called "build"
+  build:
+    # The type of runner that the job will run on
+    runs-on: ubuntu-latest
+
+    # Steps represent a sequence of tasks that will be executed as part of the job
+    steps:
+
+      # Runs a single command using the runners shell
+      - name: Run a one-line script
+        run: echo Hello, world!
diff --git a/acceptance/testdata/workflow/run-download.txtar b/acceptance/testdata/workflow/run-download.txtar
new file mode 100644
index 000000000..653fdbef5
--- /dev/null
+++ b/acceptance/testdata/workflow/run-download.txtar
@@ -0,0 +1,70 @@
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Clone the repo
+exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# commit the workflow file
+cd $SCRIPT_NAME-$RANDOM_STRING
+mkdir .github/workflows
+mv ../workflow.yml .github/workflows/workflow.yml
+exec git add .github/workflows/workflow.yml
+exec git commit -m 'Create workflow file'
+exec git push -u origin main
+
+# Sleep because it takes a second for the workflow to register
+sleep 1
+
+# Check the workflow is indeed created
+exec gh workflow list
+stdout 'Test Workflow Name'
+
+# Run the workflow
+exec gh workflow run 'Test Workflow Name'
+
+# It takes some time for a workflow run to register
+sleep 10
+
+# Get the run ID we want to watch
+exec gh run list --json databaseId --jq '.[0].databaseId'
+stdout2env RUN_ID
+
+# Wait for workflow to complete
+exec gh run watch $RUN_ID --exit-status
+
+# Download the artifact
+exec gh run download $RUN_ID
+
+# Check if we downloaded the artifact
+exists ./my-artifact/world.txt
+
+-- workflow.yml --
+# This is a basic workflow to help you get started with Actions
+
+name: Test Workflow Name
+
+# Controls when the workflow will run
+on:
+  # Allows you to run this workflow manually from the Actions tab
+  workflow_dispatch:
+
+# A workflow run is made up of one or more jobs that can run sequentially or in parallel
+jobs:
+  # This workflow contains a single job called "build"
+  build:
+    # The type of runner that the job will run on
+    runs-on: ubuntu-latest
+
+    # Steps represent a sequence of tasks that will be executed as part of the job
+    steps:
+      - run: echo hello > world.txt
+      - uses: actions/upload-artifact@v4
+        with:
+          name: my-artifact
+          path: world.txt
diff --git a/acceptance/testdata/workflow/run-rerun.txtar b/acceptance/testdata/workflow/run-rerun.txtar
new file mode 100644
index 000000000..446aabbc4
--- /dev/null
+++ b/acceptance/testdata/workflow/run-rerun.txtar
@@ -0,0 +1,72 @@
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Clone the repo
+exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# commit the workflow file
+cd $SCRIPT_NAME-$RANDOM_STRING
+mkdir .github/workflows
+mv ../workflow.yml .github/workflows/workflow.yml
+exec git add .github/workflows/workflow.yml
+exec git commit -m 'Create workflow file'
+exec git push -u origin main
+
+# Sleep because it takes a second for the workflow to register
+sleep 1
+
+# Check the workflow is indeed created
+exec gh workflow list
+stdout 'Test Workflow Name'
+
+# Run the workflow
+exec gh workflow run 'Test Workflow Name'
+
+# It takes some time for a workflow run to register
+sleep 10
+
+# Get the run ID we want to rerun
+exec gh run list --json databaseId --jq '.[0].databaseId'
+stdout2env RUN_ID
+
+# Wait for workflow to complete
+exec gh run watch $RUN_ID --exit-status
+
+# Rerun the workflow run
+exec gh run rerun $RUN_ID
+
+# It takes some time for a workflow run to register
+sleep 10
+
+# Wait for workflow to complete
+exec gh run watch $RUN_ID --exit-status
+
+-- workflow.yml --
+# This is a basic workflow to help you get started with Actions
+
+name: Test Workflow Name
+
+# Controls when the workflow will run
+on:
+  # Allows you to run this workflow manually from the Actions tab
+  workflow_dispatch:
+
+# A workflow run is made up of one or more jobs that can run sequentially or in parallel
+jobs:
+  # This workflow contains a single job called "build"
+  build:
+    # The type of runner that the job will run on
+    runs-on: ubuntu-latest
+
+    # Steps represent a sequence of tasks that will be executed as part of the job
+    steps:
+
+      # Runs a single command using the runners shell
+      - name: Run a one-line script
+        run: echo Hello, world!
diff --git a/acceptance/testdata/workflow/run-view.txtar b/acceptance/testdata/workflow/run-view.txtar
new file mode 100644
index 000000000..25f12c3a5
--- /dev/null
+++ b/acceptance/testdata/workflow/run-view.txtar
@@ -0,0 +1,66 @@
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Clone the repo
+exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# commit the workflow file
+cd $SCRIPT_NAME-$RANDOM_STRING
+mkdir .github/workflows
+mv ../workflow.yml .github/workflows/workflow.yml
+exec git add .github/workflows/workflow.yml
+exec git commit -m 'Create workflow file'
+exec git push -u origin main
+
+# Sleep because it takes a second for the workflow to register
+sleep 1
+
+# Check the workflow is indeed created
+exec gh workflow list
+stdout 'Test Workflow Name'
+
+# Run the workflow
+exec gh workflow run 'Test Workflow Name'
+
+# It takes some time for a workflow run to register
+sleep 10
+
+# Get the run ID we want to view
+exec gh run list --json databaseId --jq '.[0].databaseId'
+stdout2env RUN_ID
+
+# Wait for workflow to complete
+exec gh run watch $RUN_ID --exit-status
+
+# View the workflow run
+exec gh run view $RUN_ID
+
+-- workflow.yml --
+# This is a basic workflow to help you get started with Actions
+
+name: Test Workflow Name
+
+# Controls when the workflow will run
+on:
+  # Allows you to run this workflow manually from the Actions tab
+  workflow_dispatch:
+
+# A workflow run is made up of one or more jobs that can run sequentially or in parallel
+jobs:
+  # This workflow contains a single job called "build"
+  build:
+    # The type of runner that the job will run on
+    runs-on: ubuntu-latest
+
+    # Steps represent a sequence of tasks that will be executed as part of the job
+    steps:
+
+      # Runs a single command using the runners shell
+      - name: Run a one-line script
+        run: echo Hello, world!
diff --git a/acceptance/testdata/workflow/workflow-enable-disable.txtar b/acceptance/testdata/workflow/workflow-enable-disable.txtar
new file mode 100644
index 000000000..f0b58116f
--- /dev/null
+++ b/acceptance/testdata/workflow/workflow-enable-disable.txtar
@@ -0,0 +1,66 @@
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Clone the repo
+exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# commit the workflow file
+cd $SCRIPT_NAME-$RANDOM_STRING
+mkdir .github/workflows
+mv ../workflow.yml .github/workflows/workflow.yml
+exec git add .github/workflows/workflow.yml
+exec git commit -m 'Create workflow file'
+exec git push -u origin main
+
+# Sleep because it takes a second for the workflow to register
+sleep 1
+
+# Check the workflow is indeed created
+exec gh workflow list
+stdout 'Test Workflow Name'
+
+# disable the workflow
+exec gh workflow disable 'Test Workflow Name'
+
+# Check that the listing shows it is disabled
+exec gh workflow list --all
+stdout 'Test\s+Workflow\s+Name\s+disabled_manually\s+\d+'
+
+# enable the workflow
+exec gh workflow enable 'Test Workflow Name'
+
+# Check the workflow is indeed enabled
+exec gh workflow list
+stdout 'Test\s+Workflow\s+Name\s+active\s+\d+'
+
+-- workflow.yml --
+# This is a basic workflow to help you get started with Actions
+
+name: Test Workflow Name
+
+# Controls when the workflow will run
+on:
+  # Allows you to run this workflow manually from the Actions tab
+  workflow_dispatch:
+
+# A workflow run is made up of one or more jobs that can run sequentially or in parallel
+jobs:
+  # This workflow contains a single job called "build"
+  build:
+    # The type of runner that the job will run on
+    runs-on: ubuntu-latest
+
+    # Steps represent a sequence of tasks that will be executed as part of the job
+    steps:
+      # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
+      - uses: actions/checkout@v4
+
+      # Runs a single command using the runners shell
+      - name: Run a one-line script
+        run: echo Hello, world!
diff --git a/acceptance/testdata/workflow/workflow-list.txtar b/acceptance/testdata/workflow/workflow-list.txtar
new file mode 100644
index 000000000..ad0d87c88
--- /dev/null
+++ b/acceptance/testdata/workflow/workflow-list.txtar
@@ -0,0 +1,52 @@
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Clone the repo
+exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# commit the workflow file
+cd $SCRIPT_NAME-$RANDOM_STRING
+mkdir .github/workflows
+mv ../workflow.yml .github/workflows/workflow.yml
+exec git add .github/workflows/workflow.yml
+exec git commit -m 'Create workflow file'
+exec git push -u origin main
+
+# Sleep because it takes a second for the workflow to register
+sleep 1
+
+# Check the workflow is indeed created
+exec gh workflow list
+stdout 'Test Workflow Name'
+
+-- workflow.yml --
+# This is a basic workflow to help you get started with Actions
+
+name: Test Workflow Name
+
+# Controls when the workflow will run
+on:
+  # Allows you to run this workflow manually from the Actions tab
+  workflow_dispatch:
+
+# A workflow run is made up of one or more jobs that can run sequentially or in parallel
+jobs:
+  # This workflow contains a single job called "build"
+  build:
+    # The type of runner that the job will run on
+    runs-on: ubuntu-latest
+
+    # Steps represent a sequence of tasks that will be executed as part of the job
+    steps:
+      # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
+      - uses: actions/checkout@v4
+
+      # Runs a single command using the runners shell
+      - name: Run a one-line script
+        run: echo Hello, world!
diff --git a/acceptance/testdata/workflow/workflow-run.txtar b/acceptance/testdata/workflow/workflow-run.txtar
new file mode 100644
index 000000000..010189c01
--- /dev/null
+++ b/acceptance/testdata/workflow/workflow-run.txtar
@@ -0,0 +1,62 @@
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Clone the repo
+exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# commit the workflow file
+cd $SCRIPT_NAME-$RANDOM_STRING
+mkdir .github/workflows
+mv ../workflow.yml .github/workflows/workflow.yml
+exec git add .github/workflows/workflow.yml
+exec git commit -m 'Create workflow file'
+exec git push -u origin main
+
+# Sleep because it takes a second for the workflow to register
+sleep 1
+
+# Check the workflow is indeed created
+exec gh workflow list
+stdout 'Test Workflow Name'
+
+# Run the workflow
+exec gh workflow run 'Test Workflow Name'
+
+# It takes some time for a workflow run to register
+sleep 10
+
+# Check the workflow run exists
+exec gh run list
+stdout 'Test Workflow Name' 
+
+-- workflow.yml --
+# This is a basic workflow to help you get started with Actions
+
+name: Test Workflow Name
+
+# Controls when the workflow will run
+on:
+  # Allows you to run this workflow manually from the Actions tab
+  workflow_dispatch:
+
+# A workflow run is made up of one or more jobs that can run sequentially or in parallel
+jobs:
+  # This workflow contains a single job called "build"
+  build:
+    # The type of runner that the job will run on
+    runs-on: ubuntu-latest
+
+    # Steps represent a sequence of tasks that will be executed as part of the job
+    steps:
+      # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
+      - uses: actions/checkout@v4
+
+      # Runs a single command using the runners shell
+      - name: Run a one-line script
+        run: echo Hello, world!
diff --git a/acceptance/testdata/workflow/workflow-view.txtar b/acceptance/testdata/workflow/workflow-view.txtar
new file mode 100644
index 000000000..d3bc3d252
--- /dev/null
+++ b/acceptance/testdata/workflow/workflow-view.txtar
@@ -0,0 +1,52 @@
+# Use gh as a credential helper
+exec gh auth setup-git
+
+# Create a repository with a file so it has a default branch
+exec gh repo create $ORG/$SCRIPT_NAME-$RANDOM_STRING --add-readme --private
+
+# Defer repo cleanup
+defer gh repo delete --yes $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# Clone the repo
+exec gh repo clone $ORG/$SCRIPT_NAME-$RANDOM_STRING
+
+# commit the workflow file
+cd $SCRIPT_NAME-$RANDOM_STRING
+mkdir .github/workflows
+mv ../workflow.yml .github/workflows/workflow.yml
+exec git add .github/workflows/workflow.yml
+exec git commit -m 'Create workflow file'
+exec git push -u origin main
+
+# Sleep because it takes a second for the workflow to register
+sleep 1
+
+# Check the workflow is indeed created
+exec gh workflow view 'Test Workflow Name'
+stdout 'Test Workflow Name - workflow.yml'
+
+-- workflow.yml --
+# This is a basic workflow to help you get started with Actions
+
+name: Test Workflow Name
+
+# Controls when the workflow will run
+on:
+  # Allows you to run this workflow manually from the Actions tab
+  workflow_dispatch:
+
+# A workflow run is made up of one or more jobs that can run sequentially or in parallel
+jobs:
+  # This workflow contains a single job called "build"
+  build:
+    # The type of runner that the job will run on
+    runs-on: ubuntu-latest
+
+    # Steps represent a sequence of tasks that will be executed as part of the job
+    steps:
+      # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
+      - uses: actions/checkout@v4
+
+      # Runs a single command using the runners shell
+      - name: Run a one-line script
+        run: echo Hello, world!
diff --git a/internal/authflow/flow_test.go b/internal/authflow/flow_test.go
deleted file mode 100644
index b7ba1f64a..000000000
--- a/internal/authflow/flow_test.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package authflow
-
-import (
-	"testing"
-
-	"github.com/stretchr/testify/assert"
-)
-
-func Test_getCallbackURI(t *testing.T) {
-	tests := []struct {
-		name      string
-		oauthHost string
-		want      string
-	}{
-		{
-			name:      "dotcom",
-			oauthHost: "github.com",
-			want:      "http://127.0.0.1/callback",
-		},
-		{
-			name:      "ghes",
-			oauthHost: "my.server.com",
-			want:      "http://localhost/",
-		},
-		{
-			name:      "ghec data residency (ghe.com)",
-			oauthHost: "stampname.ghe.com",
-			want:      "http://127.0.0.1/callback",
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			assert.Equal(t, tt.want, getCallbackURI(tt.oauthHost))
-		})
-	}
-}
diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go
index 8af091c3f..2a32e9a2d 100644
--- a/internal/featuredetection/feature_detection_test.go
+++ b/internal/featuredetection/feature_detection_test.go
@@ -25,14 +25,6 @@ func TestIssueFeatures(t *testing.T) {
 			},
 			wantErr: false,
 		},
-		{
-			name:     "ghec data residency (ghe.com)",
-			hostname: "stampname.ghe.com",
-			wantFeatures: IssueFeatures{
-				StateReason: true,
-			},
-			wantErr: false,
-		},
 		{
 			name:     "GHE empty response",
 			hostname: "git.my.org",
@@ -279,16 +271,6 @@ func TestRepositoryFeatures(t *testing.T) {
 			},
 			wantErr: false,
 		},
-		{
-			name:     "ghec data residency (ghe.com)",
-			hostname: "stampname.ghe.com",
-			wantFeatures: RepositoryFeatures{
-				PullRequestTemplateQuery: true,
-				VisibilityField:          true,
-				AutoMerge:                true,
-			},
-			wantErr: false,
-		},
 		{
 			name:     "GHE empty response",
 			hostname: "git.my.org",
diff --git a/internal/ghinstance/host_test.go b/internal/ghinstance/host_test.go
index 1b7e0146d..e9fb97008 100644
--- a/internal/ghinstance/host_test.go
+++ b/internal/ghinstance/host_test.go
@@ -6,6 +6,88 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
+func TestIsEnterprise(t *testing.T) {
+	tests := []struct {
+		host string
+		want bool
+	}{
+		{
+			host: "github.com",
+			want: false,
+		},
+		{
+			host: "api.github.com",
+			want: false,
+		},
+		{
+			host: "github.localhost",
+			want: false,
+		},
+		{
+			host: "api.github.localhost",
+			want: false,
+		},
+		{
+			host: "garage.github.com",
+			want: false,
+		},
+		{
+			host: "ghe.io",
+			want: true,
+		},
+		{
+			host: "example.com",
+			want: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.host, func(t *testing.T) {
+			if got := IsEnterprise(tt.host); got != tt.want {
+				t.Errorf("IsEnterprise() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
+func TestIsTenancy(t *testing.T) {
+	tests := []struct {
+		host string
+		want bool
+	}{
+		{
+			host: "github.com",
+			want: false,
+		},
+		{
+			host: "github.localhost",
+			want: false,
+		},
+		{
+			host: "garage.github.com",
+			want: false,
+		},
+		{
+			host: "ghe.com",
+			want: false,
+		},
+		{
+			host: "tenant.ghe.com",
+			want: true,
+		},
+		{
+			host: "api.tenant.ghe.com",
+			want: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.host, func(t *testing.T) {
+			if got := IsTenancy(tt.host); got != tt.want {
+				t.Errorf("IsTenancy() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
 func TestTenantName(t *testing.T) {
 	tests := []struct {
 		host       string
@@ -48,6 +130,69 @@ func TestTenantName(t *testing.T) {
 	}
 }
 
+func TestNormalizeHostname(t *testing.T) {
+	tests := []struct {
+		host string
+		want string
+	}{
+		{
+			host: "GitHub.com",
+			want: "github.com",
+		},
+		{
+			host: "api.github.com",
+			want: "github.com",
+		},
+		{
+			host: "ssh.github.com",
+			want: "github.com",
+		},
+		{
+			host: "upload.github.com",
+			want: "github.com",
+		},
+		{
+			host: "GitHub.localhost",
+			want: "github.localhost",
+		},
+		{
+			host: "api.github.localhost",
+			want: "github.localhost",
+		},
+		{
+			host: "garage.github.com",
+			want: "github.com",
+		},
+		{
+			host: "GHE.IO",
+			want: "ghe.io",
+		},
+		{
+			host: "git.my.org",
+			want: "git.my.org",
+		},
+		{
+			host: "ghe.com",
+			want: "ghe.com",
+		},
+		{
+			host: "tenant.ghe.com",
+			want: "tenant.ghe.com",
+		},
+		{
+			host: "api.tenant.ghe.com",
+			want: "tenant.ghe.com",
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.host, func(t *testing.T) {
+			if got := NormalizeHostname(tt.host); got != tt.want {
+				t.Errorf("NormalizeHostname() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
+
 func TestHostnameValidator(t *testing.T) {
 	tests := []struct {
 		name     string
diff --git a/pkg/cmd/attestation/auth/host.go b/pkg/cmd/attestation/auth/host.go
index d72f82fa5..a40df8335 100644
--- a/pkg/cmd/attestation/auth/host.go
+++ b/pkg/cmd/attestation/auth/host.go
@@ -3,13 +3,15 @@ package auth
 import (
 	"errors"
 
-	ghauth "github.com/cli/go-gh/v2/pkg/auth"
+	"github.com/cli/cli/v2/internal/ghinstance"
 )
 
 var ErrUnsupportedHost = errors.New("An unsupported host was detected. Note that gh attestation does not currently support GHES")
 
 func IsHostSupported(host string) error {
-	if ghauth.IsEnterprise(host) {
+	// Note that this check is slightly redundant as Tenancy should not be considered Enterprise
+	// but the ghinstance package has not been updated to reflect this yet.
+	if ghinstance.IsEnterprise(host) && !ghinstance.IsTenancy(host) {
 		return ErrUnsupportedHost
 	}
 	return nil
diff --git a/pkg/cmd/attestation/inspect/inspect.go b/pkg/cmd/attestation/inspect/inspect.go
index c548b2ec7..867ff8c1b 100644
--- a/pkg/cmd/attestation/inspect/inspect.go
+++ b/pkg/cmd/attestation/inspect/inspect.go
@@ -85,7 +85,7 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
 				Logger: opts.Logger,
 			}
 			// Prepare for tenancy if detected
-			if ghauth.IsTenancy(opts.Hostname) {
+			if ghinstance.IsTenancy(opts.Hostname) {
 				hc, err := f.HttpClient()
 				if err != nil {
 					return err
diff --git a/pkg/cmd/attestation/trustedroot/trustedroot.go b/pkg/cmd/attestation/trustedroot/trustedroot.go
index c092b81ec..5473adc0c 100644
--- a/pkg/cmd/attestation/trustedroot/trustedroot.go
+++ b/pkg/cmd/attestation/trustedroot/trustedroot.go
@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"os"
 
+	"github.com/cli/cli/v2/internal/ghinstance"
 	"github.com/cli/cli/v2/pkg/cmd/attestation/api"
 	"github.com/cli/cli/v2/pkg/cmd/attestation/auth"
 	"github.com/cli/cli/v2/pkg/cmd/attestation/io"
@@ -67,7 +68,7 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com
 				return err
 			}
 
-			if ghauth.IsTenancy(opts.Hostname) {
+			if ghinstance.IsTenancy(opts.Hostname) {
 				c, err := f.Config()
 				if err != nil {
 					return err
diff --git a/pkg/cmd/attestation/verify/verify.go b/pkg/cmd/attestation/verify/verify.go
index d14081dd8..17b8a84d1 100644
--- a/pkg/cmd/attestation/verify/verify.go
+++ b/pkg/cmd/attestation/verify/verify.go
@@ -138,7 +138,7 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
 			}
 
 			// Prepare for tenancy if detected
-			if ghauth.IsTenancy(opts.Hostname) {
+			if ghinstance.IsTenancy(opts.Hostname) {
 				td, err := opts.APIClient.GetTrustDomain()
 				if err != nil {
 					return fmt.Errorf("error getting trust domain, make sure you are authenticated against the host: %w", err)
diff --git a/pkg/cmd/pr/status/http_test.go b/pkg/cmd/pr/status/http_test.go
deleted file mode 100644
index eeb91e909..000000000
--- a/pkg/cmd/pr/status/http_test.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package status
-
-import (
-	"net/http"
-	"testing"
-
-	"github.com/cli/cli/v2/api"
-	"github.com/cli/cli/v2/pkg/httpmock"
-	"github.com/stretchr/testify/assert"
-)
-
-func Test_getCurrentUsername(t *testing.T) {
-	tests := []struct {
-		name            string
-		username        string
-		hostname        string
-		serverUsername  string
-		currentUsername string
-	}{
-		{
-			name:            "dotcom",
-			username:        "@me",
-			hostname:        "github.com",
-			currentUsername: "@me",
-		},
-		{
-			name:            "ghec data residency (ghe.com)",
-			username:        "@me",
-			hostname:        "stampname.ghe.com",
-			currentUsername: "@me",
-		},
-		{
-			name:            "ghes",
-			username:        "@me",
-			hostname:        "my.server.com",
-			serverUsername:  "@serverUserName",
-			currentUsername: "@serverUserName",
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			clientStub := &http.Client{}
-			if tt.serverUsername != "" {
-				reg := &httpmock.Registry{}
-				defer reg.Verify(t)
-
-				reg.Register(
-					httpmock.GraphQL(`query UserCurrent\b`),
-					httpmock.StringResponse(`{"data":{"viewer":{"login":"`+tt.serverUsername+`"}}}`),
-				)
-
-				clientStub.Transport = reg
-			}
-
-			apiClientStub := api.NewClientFromHTTP(clientStub)
-
-			currentUsername, err := getCurrentUsername(tt.username, tt.hostname, apiClientStub)
-			assert.NoError(t, err)
-			assert.Equal(t, tt.currentUsername, currentUsername)
-		})
-	}
-}