From 78f1ad537c034d31f43c29be0e76e7dc120b44df Mon Sep 17 00:00:00 2001 From: William Martin Date: Fri, 17 Apr 2026 16:56:28 +0200 Subject: [PATCH] Include CI context in telemetry --- internal/ci/ci.go | 19 +++++++++++++ internal/ci/ci_test.go | 56 ++++++++++++++++++++++++++++++++++++++ internal/ghcmd/cmd.go | 9 ++++-- internal/update/update.go | 13 ++------- pkg/cmd/copilot/copilot.go | 4 +-- 5 files changed, 86 insertions(+), 15 deletions(-) create mode 100644 internal/ci/ci.go create mode 100644 internal/ci/ci_test.go diff --git a/internal/ci/ci.go b/internal/ci/ci.go new file mode 100644 index 00000000000..6438127b093 --- /dev/null +++ b/internal/ci/ci.go @@ -0,0 +1,19 @@ +// Package ci provides helpers for detecting CI/CD execution environments. +package ci + +import "os" + +// IsCI determines if the current execution context is within a known CI/CD system. +// This is based on https://github.com/watson/ci-info/blob/HEAD/index.js. +func IsCI() bool { + return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari + os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity + os.Getenv("RUN_ID") != "" // TaskCluster, dsari +} + +// IsGitHubActions determines if the current execution context is within GitHub Actions. +// GitHub Actions sets the GITHUB_ACTIONS environment variable to "true" for all steps. +// See https://docs.github.com/en/actions/learn-github-actions/variables#default-environment-variables. +func IsGitHubActions() bool { + return os.Getenv("GITHUB_ACTIONS") == "true" +} diff --git a/internal/ci/ci_test.go b/internal/ci/ci_test.go new file mode 100644 index 00000000000..6b2a28b54af --- /dev/null +++ b/internal/ci/ci_test.go @@ -0,0 +1,56 @@ +package ci + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsCI(t *testing.T) { + tests := []struct { + name string + env map[string]string + want bool + }{ + {name: "no CI env vars", env: map[string]string{}, want: false}, + {name: "CI set", env: map[string]string{"CI": "true"}, want: true}, + {name: "BUILD_NUMBER set", env: map[string]string{"BUILD_NUMBER": "42"}, want: true}, + {name: "RUN_ID set", env: map[string]string{"RUN_ID": "abc"}, want: true}, + {name: "CI empty string", env: map[string]string{"CI": ""}, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("CI", "") + t.Setenv("BUILD_NUMBER", "") + t.Setenv("RUN_ID", "") + for k, v := range tt.env { + t.Setenv(k, v) + } + assert.Equal(t, tt.want, IsCI()) + }) + } +} + +func TestIsGitHubActions(t *testing.T) { + tests := []struct { + name string + value string + set bool + want bool + }{ + {name: "unset", set: false, want: false}, + {name: "true", value: "true", set: true, want: true}, + {name: "false", value: "false", set: true, want: false}, + {name: "empty", value: "", set: true, want: false}, + {name: "other value", value: "yes", set: true, want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("GITHUB_ACTIONS", "") + if tt.set { + t.Setenv("GITHUB_ACTIONS", tt.value) + } + assert.Equal(t, tt.want, IsGitHubActions()) + }) + } +} diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 7350437dfb4..34806f87449 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -19,6 +19,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/agents" "github.com/cli/cli/v2/internal/build" + "github.com/cli/cli/v2/internal/ci" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/config/migration" "github.com/cli/cli/v2/internal/gh" @@ -69,9 +70,11 @@ func Main() exitCode { ghExecutablePath := executablePath("gh") additionalCommonDimensions := ghtelemetry.Dimensions{ - "version": strings.TrimPrefix(buildVersion, "v"), - "is_tty": strconv.FormatBool(ioStreams.IsStdoutTTY()), - "agent": string(agents.Detect()), + "version": strings.TrimPrefix(buildVersion, "v"), + "is_tty": strconv.FormatBool(ioStreams.IsStdoutTTY()), + "agent": string(agents.Detect()), + "ci": strconv.FormatBool(ci.IsCI()), + "github_actions": strconv.FormatBool(ci.IsGitHubActions()), } var telemetryService ghtelemetry.Service diff --git a/internal/update/update.go b/internal/update/update.go index a4a15ea17cc..20cd09606c8 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/cli/cli/v2/internal/ci" "github.com/cli/cli/v2/pkg/extensions" "github.com/hashicorp/go-version" "github.com/mattn/go-isatty" @@ -42,7 +43,7 @@ func ShouldCheckForExtensionUpdate() bool { if os.Getenv("CODESPACES") != "" { return false } - return !IsCI() && IsTerminal(os.Stdout) && IsTerminal(os.Stderr) + return !ci.IsCI() && IsTerminal(os.Stdout) && IsTerminal(os.Stderr) } // CheckForExtensionUpdate checks whether an update exists for a specific extension based on extension type and recency of last check within past 24 hours. @@ -83,7 +84,7 @@ func ShouldCheckForUpdate() bool { if os.Getenv("CODESPACES") != "" { return false } - return !IsCI() && IsTerminal(os.Stdout) && IsTerminal(os.Stderr) + return !ci.IsCI() && IsTerminal(os.Stdout) && IsTerminal(os.Stderr) } // CheckForUpdate checks whether an update exists for the GitHub CLI based on recency of last check within past 24 hours. @@ -182,11 +183,3 @@ func versionGreaterThan(v, w string) bool { func IsTerminal(f *os.File) bool { return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) } - -// IsCI determines if the current execution context is within a known CI/CD system. -// This is based on https://github.com/watson/ci-info/blob/HEAD/index.js. -func IsCI() bool { - return os.Getenv("CI") != "" || // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari - os.Getenv("BUILD_NUMBER") != "" || // Jenkins, TeamCity - os.Getenv("RUN_ID") != "" // TaskCluster, dsari -} diff --git a/pkg/cmd/copilot/copilot.go b/pkg/cmd/copilot/copilot.go index 4ab840709f1..1f2b7779858 100644 --- a/pkg/cmd/copilot/copilot.go +++ b/pkg/cmd/copilot/copilot.go @@ -18,10 +18,10 @@ import ( "strings" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/ci" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/safepaths" - "github.com/cli/cli/v2/internal/update" ghzip "github.com/cli/cli/v2/internal/zip" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -150,7 +150,7 @@ func runCopilot(opts *CopilotOptions) error { fmt.Fprintf(opts.IO.ErrOut, "%s Copilot CLI was not installed", opts.IO.ColorScheme().WarningIcon()) return cmdutil.SilentError } - } else if !update.IsCI() { + } else if !ci.IsCI() { fmt.Fprintf(opts.IO.ErrOut, "%s Copilot CLI not installed", opts.IO.ColorScheme().WarningIcon()) return cmdutil.SilentError }