From 2b93d011054fe40b1c964337392e98a6f89afc26 Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Mon, 16 Feb 2026 16:26:24 -0800 Subject: [PATCH] feat: add FORCE_COLOR environment variable support Add support for the FORCE_COLOR environment variable (https://force-color.org). When FORCE_COLOR is set to any value other than "0", colors are enabled regardless of terminal detection. NO_COLOR takes precedence over FORCE_COLOR. This is useful in CI environments like GitHub Actions and GitLab CI that support ANSI colors but are not detected as TTYs. Fixes #155 --- README.md | 10 +++++- color.go | 16 ++++++++- color_test.go | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d135bfe0..66d6c527 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,12 @@ There might be a case where you want to explicitly disable/enable color output. The `color` package also disables color output if the [`NO_COLOR`](https://no-color.org) environment variable is set to a non-empty string. +It also supports the [`FORCE_COLOR`](https://force-color.org) environment variable. When +`FORCE_COLOR` is set (to any value other than `"0"`), colors will be enabled regardless of +terminal detection. This is useful in CI environments like GitHub Actions and GitLab CI that +support ANSI colors but are not detected as TTYs. `NO_COLOR` takes precedence over +`FORCE_COLOR`. + `Color` has support to disable/enable colors programmatically both globally and for single color definitions. For example suppose you have a CLI app and a `-no-color` bool flag. You can easily disable the color output with: @@ -176,7 +182,9 @@ c.Println("This prints again cyan...") ## GitHub Actions -To output color in GitHub Actions (or other CI systems that support ANSI colors), make sure to set `color.NoColor = false` so that it bypasses the check for non-tty output streams. +To output color in GitHub Actions (or other CI systems that support ANSI colors), you can +set the `FORCE_COLOR=1` environment variable. Alternatively, set `color.NoColor = false` +programmatically to bypass the check for non-tty output streams. ## Credits diff --git a/color.go b/color.go index d3906bfb..32528c1e 100644 --- a/color.go +++ b/color.go @@ -19,7 +19,11 @@ var ( // set (regardless of its value). This is a global option and affects all // colors. For more control over each color block use the methods // DisableColor() individually. - NoColor = noColorIsSet() || os.Getenv("TERM") == "dumb" || !stdoutIsTerminal() + // + // If the FORCE_COLOR environment variable is set (to any value other than + // "0"), colors will be enabled regardless of terminal detection. NO_COLOR + // takes precedence over FORCE_COLOR. + NoColor = noColorIsSet() || (!forceColorIsSet() && (os.Getenv("TERM") == "dumb" || !stdoutIsTerminal())) // Output defines the standard output of the print functions. By default, // stdOut() is used. @@ -67,6 +71,13 @@ func stdErr() io.Writer { return colorable.NewColorableStderr() } +// forceColorIsSet returns true if the environment variable FORCE_COLOR is set +// to a value other than "0". See https://force-color.org for more details. +func forceColorIsSet() bool { + val, ok := os.LookupEnv("FORCE_COLOR") + return ok && val != "0" +} + // Color defines a custom color object which is defined by SGR parameters. type Color struct { params []Attribute @@ -178,6 +189,9 @@ func New(value ...Attribute) *Color { if noColorIsSet() { c.noColor = boolPtr(true) } + if forceColorIsSet() && !noColorIsSet() { + c.noColor = boolPtr(false) + } c.Add(value...) return c diff --git a/color_test.go b/color_test.go index e22af6b3..42e8488b 100644 --- a/color_test.go +++ b/color_test.go @@ -662,3 +662,94 @@ func TestRGB(t *testing.T) { }) } } + +func Test_forceColorIsSet(t *testing.T) { + t.Run("default", func(t *testing.T) { + if forceColorIsSet() { + t.Errorf("forceColorIsSet() = true, want false") + } + }) + + tests := []struct { + name string + value string + want bool + }{ + { + name: "FORCE_COLOR=1", + value: "1", + want: true, + }, + { + name: "FORCE_COLOR=0", + value: "0", + want: false, + }, + { + name: "FORCE_COLOR=true", + value: "true", + want: true, + }, + { + name: "FORCE_COLOR=", + value: "", + want: true, + }, + { + name: "FORCE_COLOR=whatever", + value: "whatever", + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("FORCE_COLOR", tt.value) + if got := forceColorIsSet(); got != tt.want { + t.Errorf("forceColorIsSet() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestForceColor(t *testing.T) { + rb := new(bytes.Buffer) + Output = rb + + t.Setenv("FORCE_COLOR", "1") + + NoColor = false + + testColors := []struct { + text string + code Attribute + }{ + {text: "black", code: FgBlack}, + {text: "red", code: FgRed}, + {text: "green", code: FgGreen}, + } + + for _, c := range testColors { + p := New(c.code) + p.Print(c.text) + + line, _ := rb.ReadString('\n') + scannedLine := fmt.Sprintf("%q", line) + colored := fmt.Sprintf("\x1b[%dm%s\x1b[0m", c.code, c.text) + escapedForm := fmt.Sprintf("%q", colored) + + if scannedLine != escapedForm { + t.Errorf("Expecting %s, got '%s'\n", escapedForm, scannedLine) + } + } +} + +func TestForceColorOverriddenByNoColor(t *testing.T) { + // NO_COLOR should take precedence over FORCE_COLOR + t.Setenv("FORCE_COLOR", "1") + t.Setenv("NO_COLOR", "1") + + c := New(FgRed) + if !c.isNoColorSet() { + t.Error("Expected NO_COLOR to take precedence over FORCE_COLOR") + } +}