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.