diff --git a/versioncheck/versioncheck.go b/versioncheck/versioncheck.go index a35d406..013295b 100644 --- a/versioncheck/versioncheck.go +++ b/versioncheck/versioncheck.go @@ -20,6 +20,10 @@ const ( DisableEnv = "POUTINE_DISABLE_VERSION_CHECK" // URLEnv overrides the compiled-in endpoint, primarily for staging. URLEnv = "POUTINE_VERSION_CHECK_URL" + // CIEnv signals an ephemeral CI run. When truthy, the check skips + // reading and writing the user-level state file and tags the report + // with ci=true. + CIEnv = "CI" ) const ( @@ -44,6 +48,7 @@ type options struct { Version string URL string Disabled bool + CI bool Client *http.Client Now func() time.Time SaveConfig func(*Config) error @@ -60,12 +65,23 @@ func Run(ctx context.Context, version string, disabled bool) *Result { return nil } - cfg, _ := LoadConfig() - if cfg == nil { - cfg = &Config{} + ci := isCIEnv(os.Getenv(CIEnv)) + var cfg *Config + saveConfig := SaveConfig + if ci { + cfg = &Config{ + InstanceID: uuid.NewString(), + StartCount: 1, + } + saveConfig = func(*Config) error { return nil } + } else { + cfg, _ = LoadConfig() + if cfg == nil { + cfg = &Config{} + } + recordStart(cfg, uuid.NewString) + _ = SaveConfig(cfg) } - recordStart(cfg, uuid.NewString) - _ = SaveConfig(cfg) timeoutCtx, cancel := context.WithTimeout(ctx, checkTimeout) defer cancel() @@ -74,9 +90,10 @@ func Run(ctx context.Context, version string, disabled bool) *Result { Config: cfg, Version: version, URL: VersionCheckURL, + CI: ci, Client: &http.Client{Timeout: checkTimeout}, Now: time.Now, - SaveConfig: SaveConfig, + SaveConfig: saveConfig, Env: os.Getenv, NewID: uuid.NewString, }) @@ -121,7 +138,7 @@ func run(ctx context.Context, opts options) (*Result, error) { } instanceID := ensureInstanceID(opts.Config, opts.NewID) startsSinceLastCheck := startsSinceLastReport(opts.Config) - requestURL := buildRequestURL(u, version, instanceID, opts.Config.StartCount, startsSinceLastCheck) + requestURL := buildRequestURL(u, version, instanceID, opts.Config.StartCount, startsSinceLastCheck, opts.CI) client := opts.Client if client == nil { @@ -173,6 +190,15 @@ func isDisabledByEnv(value string) bool { } } +func isCIEnv(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + func isDevVersion(version string) bool { version = strings.TrimSpace(version) return version == "" || @@ -230,7 +256,7 @@ func readResult(resp *http.Response) (*Result, error) { return &result, nil } -func buildRequestURL(u *url.URL, version, instanceID string, startCount, startsSinceLastCheck int) string { +func buildRequestURL(u *url.URL, version, instanceID string, startCount, startsSinceLastCheck int, ci bool) string { q := u.Query() q.Set("project", "poutine") q.Set("component", "cli") @@ -240,6 +266,9 @@ func buildRequestURL(u *url.URL, version, instanceID string, startCount, startsS q.Set("start_count", strconv.Itoa(startCount)) q.Set("starts_since_last_check", strconv.Itoa(startsSinceLastCheck)) } + if ci { + q.Set("ci", "true") + } u.RawQuery = q.Encode() return u.String() } diff --git a/versioncheck/versioncheck_test.go b/versioncheck/versioncheck_test.go index 6e4dcad..46a5c2e 100644 --- a/versioncheck/versioncheck_test.go +++ b/versioncheck/versioncheck_test.go @@ -308,6 +308,87 @@ func TestLoadConfig_RoundTrip(t *testing.T) { assert.True(t, cfg.LastVersionCheckAt.Equal(loaded.LastVersionCheckAt)) } +func TestRun_CIFlagSetInRequest(t *testing.T) { + now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC) + cfg := &Config{ + InstanceID: "2ed05245-10d7-4d21-a8e8-7c4e8a9851b4", + StartCount: 1, + } + var gotReq *http.Request + + result, err := run(context.Background(), options{ + Config: cfg, + Version: "v0.18.0", + URL: "https://updates.example/check", + CI: true, + Client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + gotReq = req + return &http.Response{ + StatusCode: http.StatusNoContent, + Body: io.NopCloser(strings.NewReader("")), + }, nil + })}, + Now: func() time.Time { return now }, + SaveConfig: func(*Config) error { return nil }, + Env: func(string) string { return "" }, + }) + + require.NoError(t, err) + assert.Nil(t, result) + require.NotNil(t, gotReq) + assert.Equal(t, "true", gotReq.URL.Query().Get("ci")) +} + +func TestRun_NonCIOmitsCIParam(t *testing.T) { + cfg := &Config{InstanceID: "2ed05245-10d7-4d21-a8e8-7c4e8a9851b4", StartCount: 1} + var gotReq *http.Request + + _, err := run(context.Background(), options{ + Config: cfg, + Version: "v0.18.0", + URL: "https://updates.example/check", + Client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + gotReq = req + return &http.Response{ + StatusCode: http.StatusNoContent, + Body: io.NopCloser(strings.NewReader("")), + }, nil + })}, + SaveConfig: func(*Config) error { return nil }, + Env: func(string) string { return "" }, + }) + + require.NoError(t, err) + require.NotNil(t, gotReq) + _, hasCI := gotReq.URL.Query()["ci"] + assert.False(t, hasCI, "ci param should be absent when not running in CI") +} + +func TestRun_CISkipsPersistentState(t *testing.T) { + dir := t.TempDir() + t.Setenv("POUTINE_CONFIG_DIR", dir) + t.Setenv(DisableEnv, "") + t.Setenv(CIEnv, "true") + t.Setenv(URLEnv, "https://override.example/check") + + // Stub the HTTP transport via the default client used by Run. Because Run + // constructs its own client, we can't intercept the request here; what we + // care about is that no state file is written on disk after the call. + _ = Run(context.Background(), "v0.18.0", false) + + _, err := os.Stat(filepath.Join(dir, "config.yaml")) + assert.True(t, os.IsNotExist(err), "no state file should be written in CI mode") +} + +func TestIsCIEnv(t *testing.T) { + for _, value := range []string{"1", "true", "TRUE", "yes", "on"} { + assert.True(t, isCIEnv(value), value) + } + for _, value := range []string{"", "0", "false", "no", "off", "anything"} { + assert.False(t, isCIEnv(value), value) + } +} + func TestRun_DisabledShortCircuitsBeforeAnyDiskWrite(t *testing.T) { dir := t.TempDir() t.Setenv("POUTINE_CONFIG_DIR", dir)