Skip to main content
ansicode

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

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 exists
GitHub Actions

GitHub 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

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

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

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 through pipes

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/null
View raw logs locally

Viewing 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.log

See 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.