ANSI escape codes in CI logs — GitHub Actions, GitLab, Jenkins, Buildkite
CI runners pipe your build output into a log file, not a TTY — so most colour-aware tools strip SGR by default. The web UIs of every major CI launched after 2018 render ANSI just fine; the only thing missing is convincing tools to emit the bytes. This page covers the eight pieces of that contract — why CI strips colour, how each major provider (GitHub Actions, GitLab, Jenkins, Buildkite, CircleCI, Azure) parses or annotates the log stream, how to force colour through pipes with FORCE_COLOR / --color=always / a PTY wrapper, and how to view a downloaded raw log with the coloured rendering intact.
Why CI strips colour by default — isatty(stdout) == 0
Inside a CI job the process's stdout is wired to a log file or socket, not a terminal. `isatty(STDOUT_FILENO)` returns 0, and most well-behaved tools take this as a cue to drop SGR bytes — there's nothing to render them. The override knobs are: FORCE_COLOR (Node ecosystem: chalk, jest, mocha, npm, pnpm, vite, next; respected by many non-Node tools too — see /accessibility), `--color=always` (git, grep, ripgrep, fd, eza, jest, pytest, cargo), and on POSIX the `script` / `unbuffer` PTY-wrapper trick. The de-facto marker that lets a tool know it's in CI at all is `CI=true` — set by every major provider; chalk reads it and enables basic 16-colour output even when isatty fails. NO_COLOR (no-color.org) still wins over all of them.
Most CIs render SGR in their web UI now — the bytes survive the pipe; the override just keeps tools from self-stripping.
# Universal CI overrides — works across providers
CI=true # set automatically by every major provider
FORCE_COLOR=3 npm test # 3 = truecolor (chalk depth)
git --color=always log # explicit flag on tools that have one
script -qec 'pytest' /dev/null # wrap in a PTY when no flag existsGitHub Actions — TERM=dumb, but the web UI renders SGR
The runner sets `TERM=dumb`, which trips conservative tools into stripping colour. The web UI, however, has rendered SGR escapes since 2018 — so the moment you force-emit them (`FORCE_COLOR=3` for Node tools, `--color=always` for git/grep/ripgrep), the live log goes coloured. The annotation channel (`::error::`, `::warning::`, `::notice::`, `::group::name` / `::endgroup::`) is separate from SGR — it produces structured PR annotations + collapsible log sections. Raw log download (gear icon → Download log archive, or `GET /repos/{owner}/{repo}/actions/runs/{run_id}/logs` via API) preserves every byte the runner wrote, ANSI included — so feeding the archive into `less -R` or `ansi2html` retroactively reproduces the coloured view.
::group:: produces a collapsible UI section; FORCE_COLOR=3 makes chalk-based output stay coloured.
# .github/workflows/test.yml — keep colour through chalk-using tools
- name: Run tests
env:
FORCE_COLOR: 3
run: |
echo "::group::Unit tests"
npm test --color=always
echo "::endgroup::"
echo "::notice file=README.md,line=1::done"GitLab CI — SGR renders, section markers use \r\e[0K to hide themselves
GitLab's job log viewer has rendered SGR since 8.5 (2016). The provider-specific structuring channel is the collapsible section marker: `section_start:<unix_ts>:<name>\r\x1b[0K<header text>` and `section_end:<unix_ts>:<name>\r\x1b[0K`. The `\r\x1b[0K` is a carriage return followed by CSI 0 K (erase from cursor to end of line) — when the GitLab parser DOESN'T consume the marker (older versions, or local cat-the-log review), the trailing `\r` + erase-line silently overwrites the marker text on the same row, so the literal `section_start:…` string never appears visually. The raw trace endpoint (`GET /projects/:id/jobs/:job_id/trace` via API, or 'Show complete raw' in the UI) preserves every ANSI byte plus the markers, so you can pipe it into `ansi2html` or `less -R` for off-platform review.
Trailing \r\e[0K hides the literal marker text — only the rendered header survives in the GitLab UI.
# .gitlab-ci.yml — emit a collapsible section
script:
- echo -e "section_start:`date +%s`:tests\r\e[0KUnit tests"
- FORCE_COLOR=3 npm test --color=always
- echo -e "section_end:`date +%s`:tests\r\e[0K"Jenkins — AnsiColor plugin required; raw 'Console Output' still has the bytes
Out of the box Jenkins's Console Output page renders escape bytes literally (`^[[31m` text instead of red). The fix is the AnsiColor plugin: install it, then in a Declarative Pipeline use `options { ansiColor('xterm') }` at the top level or wrap an individual `stage` with `wrap([$class: 'AnsiColorBuildWrapper', colorMapName: 'xterm']) { … }` (Scripted Pipeline). For classic Freestyle jobs there's a per-job 'Color ANSI Console Output' checkbox once the plugin is installed. The plugin only changes the rendered styled view — the raw 'Console Output (text)' link still serves the un-rendered bytes, which is what you want for `wget`-then-`less -R` archaeology. Choose the colorMapName carefully: `xterm` (256-color) is the safe default; `vga` and `css` exist for legacy themes.
Top-level `options { ansiColor('xterm') }` enables the rendered view for every stage in the pipeline.
// Jenkinsfile — Declarative Pipeline
pipeline {
agent any
options { ansiColor('xterm') }
stages {
stage('test') {
steps {
sh 'FORCE_COLOR=3 npm test --color=always'
}
}
}
}Buildkite, CircleCI, Azure Pipelines — modern CIs render SGR out of the box
Buildkite renders SGR by default in its log viewer and supports `--*[group]name` style headers via Markdown (it parses log lines that begin with `~~~` / `+++` / `***` / `---` as collapsible markers — older Buildkite-specific markers like `--- :package:` are still respected). CircleCI 2.0+ renders ANSI in the web UI; the runner sets `TERM=xterm-256color` so tools that gate on TERM also stay coloured. Azure Pipelines mirrors GitHub Actions's logging-command syntax with a `##[…]` prefix instead of `::…::` — `##[group]name` / `##[endgroup]`, `##[error]message`, `##[warning]message`, `##[debug]message`. Travis CI (legacy but still seen) sets `TERM=dumb` in some default images; combined with `CI=true` chalk still emits basic colour. Bitbucket Pipelines has rendered SGR in the log UI since 2018. The pattern: any CI launched after 2018 expects ANSI; only Jenkins remains plugin-gated.
Azure's ##[…] prefix is GitHub's ::…:: with two characters changed — easy to dual-target both CIs.
# Azure Pipelines — group + annotation
steps:
- script: |
echo "##[group]Unit tests"
FORCE_COLOR=3 npm test --color=always
echo "##[endgroup]"
echo "##[warning]flaky test skipped"Force colour when isatty() is false — flags, env vars, PTY wrappers
Three layers in increasing brute-force: (1) **explicit CLI flag** — `--color=always` works for git, grep (GNU and BSD), ripgrep, fd, eza, jq, ls (with `--color=always` on GNU coreutils), jest, pytest's `--color=yes`, cargo's `--color=always`. Cheapest and most predictable. (2) **env-var coercion** — `FORCE_COLOR=3` (Node truecolor), `FORCE_COLOR=2` (256), `FORCE_COLOR=1` (basic 16); honoured by every chalk-using tool plus a growing list of Rust tools. `CLICOLOR_FORCE=1` is the BSD-family analogue (git, BSD ls, eza). (3) **PTY wrapper** — when a tool has neither a flag nor an env-var hook, run it under a pseudo-terminal so `isatty(stdout)` returns true: `script -qec 'cmd' /dev/null` (util-linux), `script -q /dev/null cmd` (BSD `script`), or `unbuffer cmd` (expect package). Use sparingly: PTYs slow down throughput slightly and can confuse line-buffered tools that batch-flush on close.
Try a flag first, env var second, PTY wrap last — each layer adds invasiveness.
# Three escalating workarounds, in order
git --color=always log | tee log.txt # 1. flag
FORCE_COLOR=3 vitest run | tee out.log # 2. env var
script -qec 'apt-get install -y curl' /dev/null # 3. PTY wrap
# Combine for tools that need both
FORCE_COLOR=3 script -qec 'pytest' /dev/nullViewing downloaded CI logs locally — cat -v, less -R, ansi2html
Once you've downloaded the raw CI log (GitHub: gear → Download log archive; GitLab: 'Show complete raw'; Jenkins: 'Console Output (text)'), four tools cover almost every workflow: `cat -v log.txt` shows control bytes as the literal caret form (`^[[31mERROR^[[0m`) — best for parser debugging or grepping for stray escapes. `less -R log.txt` renders SGR colour while passing other control codes through as text — safe for arbitrary log files. `less +F -R log.txt` adds tail-follow. `ansi2html < log.txt > log.html` (pip install ansi2html) emits a self-contained HTML page with inline CSS — best for sharing a coloured log via email or attachment. `aha < log.txt > log.html` is the apt-installable alternative. `ansifilter log.txt` strips ANSI entirely (or converts to HTML/LaTeX/RTF). When you only need ANSI removed, /strip on this site has the regex inline.
less -R is the daily driver — cat -v for surgical debugging, ansi2html for sharing.
# 1. Inspect raw bytes (debug a stray escape)
cat -v build.log | head
# 2. Render in-terminal
less -R build.log
# 3. Convert to shareable HTML
ansi2html < build.log > build.html
aha --black < build.log > build.html # alternative
# 4. Strip entirely
ansifilter build.log > plain.logSee also
/accessibility goes deeper on the env-var contract (NO_COLOR, CLICOLOR, FORCE_COLOR semantics across non-CI environments); /strip has the regex for removing ANSI bytes when shipping logs to a non-rendering aggregator (Datadog / CloudWatch / Splunk); /pitfalls covers the symptom-level versions of colour-bleed in long-running CI jobs.