Skip to content

Why literal secrets are refused in CI

A literal credential — the secret written directly into a config file — resolves normally on a developer's machine. When the CI environment variable is exactly true, Resolver::resolve refuses it and returns LiteralRefusedInCi instead.

What the refusal is actually protecting against

The risk is not that a literal is a weak way to store a secret. It is that CI is a place where config files get read out loud.

A pipeline clones the repository, and anything committed in a config file is on the runner. Jobs print their configuration when debugging. Failed builds attach artefacts. Logs are retained, often more widely readable than the repository itself, and frequently forwarded somewhere else again. A literal secret that is merely untidy on a laptop becomes a secret with a long and poorly-tracked distribution list in CI.

The refusal turns that from something that happens quietly into a build failure with a message naming the problem. A failed pipeline is cheap. A key in a log aggregator is not.

It also fails at the right moment. The alternative is discovering months later that a key has been sitting in build logs since it was added, by which point the only remedy is rotating it and hoping.

Why the check is one exact string

Detection is a single comparison against the literal value true. Not a prefix match on CI_*, not a list of vendor-specific variables, not a case-insensitive or truthy-value check.

Broader detection sounds strictly better and is not. Developer shells export all sorts of things: CI_SERVER for an unrelated tool, CI=1 left over from a script, vendor variables that persist in a terminal long after the thing that set them. Every one of those becomes a machine where literals mysteriously stop working, with an error about CI on a laptop that is not CI. That is a bad failure — it is confusing, it looks like a bug in the tool, and the natural workaround is for someone to go looking for how to turn the safety check off.

A narrow check has a narrow failure mode. CI=true is the convention GitHub Actions, GitLab CI, CircleCI and Buildkite already follow, so the common platforms are covered without guessing, and anywhere else can opt in with one line.

The cost is stated plainly: a platform that sets CI=1 gets no protection, and nothing warns you. That is the deliberate trade — a check that misses quietly rather than one that fires wrongly.

Why it is policy rather than a security boundary

The check is trivially defeated. Anyone can set CI=false, and the crate's own error message suggests exactly that for local use.

This is not a flaw, because the refusal is not defending against an attacker. Someone who controls the environment already controls the process, and no environment-variable check can stop them. What it defends against is nobody in particular — the ordinary path where a literal gets committed because it was convenient, and then runs in CI because nothing objected.

For that, a check that can be deliberately overridden is enough. It makes the safe path the default and the unsafe path something a person had to choose, which is all a policy control is ever doing.

Tools wanting stricter enforcement can set CI=true themselves at startup in whatever conditions they consider unsafe, which turns the same mechanism into a harder rule without the crate having to guess what those conditions are.

Why refusal rather than a warning

A warning would be ignored. It would appear in the logs of a passing build, next to everything else in the logs of a passing build, and the secret would still have been read and used.

Refusal also arrives at a point where fixing it is cheap. The pipeline that fails is the one that introduced the literal, and the fix — move it to a CI secret and reference it with env — is the thing that should have happened anyway.

The consequence is worth knowing: resolution stops there rather than continuing to the fallback_env leg, so a reference carrying both a literal and a fallback fails in CI even when the fallback variable is set. That behaviour is described in what this crate does not do.