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.