feat(core): read the environment through one funnel, with a hermetic test overlay - #1838
Conversation
|
This pull request is part of a Mergify stack:
|
Merge Protections🟢 All 6 merge protections satisfied — ready to merge. Show 6 satisfied protections🟢 🤖 Continuous Integration
🟢 👀 Review Requirements
🟢 Enforce conventional commitMake sure that we follow https://www.conventionalcommits.org/en/v1.0.0/
🟢 🔎 Reviews
🟢 📕 PR description
🟢 🚦 Auto-queueWhen all merge protections are satisfied, this pull request will be queued automatically. |
`Theme::detect()` read `NO_COLOR`, `FORCE_COLOR` and `CLICOLOR_FORCE`
on every call, and it is called from production paths in
`mergify-freeze`, `mergify-queue`, `mergify-events` and
`mergify-stack`. So any test in any of those crates read the process
environment, on whatever thread it happened to run on, just by
rendering a themed line. That is the read half of the data race this
stack is about: the tests in `mergify-ci`, `mergify-core`,
`mergify-cli` and `mergify-stack` mutate the process environment
while these read it.
Those three reads could never change the answer inside a test binary
anyway. `resolve_enabled` returns `false` for a `None` choice
whatever they say, and only `mergify-cli`'s entry point ever records
a choice, which the doc comment on `set_color_choice` already said. A
test harness read three environment variables to compute a value it
had already decided.
`mergify-tui` now takes the resolved choice and never reads the
environment. `--color always|never` still wins, `NO_COLOR` still
beats `FORCE_COLOR` / `CLICOLOR_FORCE`, and `auto` with neither still
defers to the TTY. The precedence moves to `resolve_color_choice` in
the binary, next to the clap enum it maps from, which is also where
the workspace's environment funnel lives. `mergify-tui` cannot use
that funnel: it is `mergify-core`, and this crate deliberately stays
dependency-light.
Two behaviour fixes come with it, both in the same three variables:
Exported-but-empty no longer counts as set. A workflow writing
`NO_COLOR: ${{ inputs.no_color }}` with no input exports the empty
string, and every `mergify` in that job lost color with no way back
but `--color always`. <https://no-color.org> says "present and not an
empty string"; so does every other variable this CLI reads.
The log stream obeys the same decision. `init_tracing` was choosing
ANSI from `stderr().is_terminal()` alone, so `NO_COLOR=1` and
`--color never` gave plain stdout and a `-vv` stream still full of
escape sequences, which is the one output people redirect to a file.
The remaining visible difference is *when* the variables are read:
once at startup instead of at every `Theme::detect()`. Nothing
mutates the environment mid-run, so the answer is the same either
way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: Ib099b7044861960ca2c9e85f443072914b333c20
46785e0 to
347397a
Compare
Revision history
|
There was a problem hiding this comment.
🟡 Changes recommended
Critical compilation and async overlay correctness issues remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Centralizes environment access behind a thread-local overlay and makes GitHub CLI token resolution injectable for hermetic tests.
Changes:
- Adds synchronous and asynchronous environment overlays.
- Migrates authentication tests away from
temp_env. - Removes obsolete
temp-envdependencies.
File summaries
| File | Description |
|---|---|
crates/mergify-core/src/env.rs |
Environment funnel, overlays, and tests |
crates/mergify-core/src/auth.rs |
Injectable GitHub CLI token resolution |
crates/mergify-core/Cargo.toml |
Removes temp-env |
crates/mergify-config/Cargo.toml |
Removes temp-env |
Cargo.lock |
Updates dependency entries |
Review details
Suppressed comments (3)
crates/mergify-core/src/env.rs:403
- This restoration check has the same non-hermetic assumption as the earlier test:
PATHmay legitimately be unset, so the test reports a failure even though the overlay was restored correctly. Compare the value captured before the panic with the value after unwinding rather than requiringPATHto exist.
assert!(
var("PATH").is_some(),
"the overlay outlived the panic that unwound through it"
);
crates/mergify-core/src/env.rs:113
- This claim is not enforced in the current workspace: there is no
clippy.toml,[workspace.lints.clippy]does not enableclippy::disallowed_methods, and directstd::env::var*calls still exist in command crates. Without adding the lint/config (and migrating those callers), future code can bypass this funnel, so either add the enforcement in this change or make the comment describe a future step.
// workspace; `clippy.toml` disallows `std::env::var*`
// everywhere else so every other caller lands here.
#[allow(clippy::disallowed_methods)]
std::env::var_os(name)
crates/mergify-core/src/env.rs:104
- On Windows, environment variable names are case-insensitive, but this replacement map uses exact
OsStringkeys. For example,with_var("Path", Some(...), || var("PATH"))returnsNoneeven though the realstd::env::var_os("PATH")would find the value, so the overlay does not preserve the platform's environment semantics. Normalize keys/lookups (or perform a Windows case-insensitive lookup) and add a regression test.
map.get(OsStr::new(name)).cloned()
- Files reviewed: 4/5 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…test overlay `mergify_core::env` becomes the one place this workspace reads the process environment, and `env::testing::with_vars` the one way a test changes what it says. Nothing mutates the process environment any more. `temp_env` did, and that is undefined behaviour in a test binary. `setenv` became `unsafe` in Rust 1.80 because on Unix it can reallocate `environ` while another thread sits inside `getenv`: a use-after-free, not a race whose worst case is a wrong assertion. libtest runs its tests on many threads at once. The concurrent reader was never only ours, which is what makes serialising our own reads a non-answer. `std::env::temp_dir` is a `getenv`, so every `tempfile::tempdir()` is one, and there are 94 of those across the four crates that called `temp_env`. So is `Command::spawn` snapshotting `environ` for the child, and the CLI's tests spawn `git` constantly. `temp_env`'s lock serialises against other `temp_env` calls and against nothing else. So the environment is now read, never written. `var`, `var_os`, `var_non_empty` and `var_os_non_empty` consult a thread-local overlay first and the process environment otherwise; `env::testing::with_vars` installs one for the duration of a closure or a future. No global state changes, so tests that use it can run concurrently with anything. The overlay is a *replacement*, not a patch: while it is installed, a name the test did not list reads as unset. That is deliberate and it is worth more than the UB fix. It is what makes a test that reads `GITHUB_ACTIONS` behave the same on a laptop and on a GitHub Actions runner, which is the problem `mergify-ci`'s hand-maintained `CI_ENV_VARS` scrub list existed to paper over, with a comment admitting new detector inputs had to be added to it or their tests went flaky on CI. It is compiled unconditionally rather than behind `cfg(test)`. `cfg(test)` provably cannot work here: it is false whenever the crate is compiled as a dependency, so every consumer crate's tests would read the real environment anyway. `mergify_tui::theme` carries a comment about having been bitten by exactly that. Three things the module says about itself, because each one is a way to misuse it. It is the one place *this workspace* reads the environment, not the one place the process does: `tracing-subscriber` reads `RUST_LOG`, `dirs` reads `HOME`, `reqwest` reads the proxy variables, and no overlay reaches inside a dependency. The guard is `!Send`, so spawning a guarded future is a compile error, but work the body itself hands to another thread is not covered. And two overlay scopes must nest rather than overlap. `with_no_vars` installs a genuinely empty map rather than layering an empty list onto the enclosing overlay, which would have made it a no-op exactly where its name promises the most. Both accessors use `try_with`, since the overlay owns a `HashMap` and therefore registers a thread-local destructor: a `Drop` impl reading a variable while its thread tears down would otherwise panic where `std::env::var_os` could not, and a panic in a `Drop` during unwinding aborts. The `gh auth token` leg of both token resolvers becomes a parameter. Their tests used to point `PATH` at a nonexistent directory, and two of them wrote a shell script named `gh` into a temp dir and put *that* on `PATH`, which needs the environment to really change, for a grandchild process. A closure says the same thing, including the case worth pinning (`gh` echoing back the `mut_` value it was handed), without a shell script, a temp directory or a `cfg(unix)` gate. Nothing else moves yet: `mergify-ci`, `mergify-stack`, `mergify-cli` and `mergify-auth` still call `temp_env`, and the lint that bans it lands at the top of the stack. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Change-Id: Ib777d5196b912ba69690a03554372442cdbc04be
347397a to
6f8c35c
Compare
|
The three suppressed comments in the Copilot review body, which have no thread to reply on:
The reasoning is that last clause. A |
Merge Queue Status
This pull request spent 7 minutes 56 seconds in the queue, including 7 minutes 26 seconds running CI. Required conditions to merge
|
mergify_core::envbecomes the one place this workspace reads theprocess environment, and
env::testing::with_varsthe one way a testchanges what it says. Nothing mutates the process environment any
more.
temp_envdid, and that is undefined behaviour in a test binary.setenvbecameunsafein Rust 1.80 because on Unix it canreallocate
environwhile another thread sits insidegetenv: ause-after-free, not a race whose worst case is a wrong assertion.
libtest runs its tests on many threads at once.
The concurrent reader was never only ours, which is what makes
serialising our own reads a non-answer.
std::env::temp_diris agetenv, so everytempfile::tempdir()is one, and there are 94 ofthose across the four crates that called
temp_env. So isCommand::spawnsnapshottingenvironfor the child, and the CLI'stests spawn
gitconstantly.temp_env's lock serialises againstother
temp_envcalls and against nothing else.So the environment is now read, never written.
var,var_os,var_non_emptyandvar_os_non_emptyconsult a thread-local overlayfirst and the process environment otherwise;
env::testing::with_varsinstalls one for the duration of a closure or a future. No global
state changes, so tests that use it can run concurrently with
anything.
The overlay is a replacement, not a patch: while it is installed, a
name the test did not list reads as unset. That is deliberate and it
is worth more than the UB fix. It is what makes a test that reads
GITHUB_ACTIONSbehave the same on a laptop and on a GitHub Actionsrunner, which is the problem
mergify-ci's hand-maintainedCI_ENV_VARSscrub list existed to paper over, with a commentadmitting new detector inputs had to be added to it or their tests
went flaky on CI.
It is compiled unconditionally rather than behind
cfg(test).cfg(test)provably cannot work here: it is false whenever the crateis compiled as a dependency, so every consumer crate's tests would
read the real environment anyway.
mergify_tui::themecarries acomment about having been bitten by exactly that.
Three things the module says about itself, because each one is a way
to misuse it. It is the one place this workspace reads the
environment, not the one place the process does:
tracing-subscriberreads
RUST_LOG,dirsreadsHOME,reqwestreads the proxyvariables, and no overlay reaches inside a dependency. The guard is
!Send, so spawning a guarded future is a compile error, but workthe body itself hands to another thread is not covered. And two
overlay scopes must nest rather than overlap.
with_no_varsinstalls a genuinely empty map rather than layering anempty list onto the enclosing overlay, which would have made it a
no-op exactly where its name promises the most.
Both accessors use
try_with, since the overlay owns aHashMapandtherefore registers a thread-local destructor: a
Dropimpl readinga variable while its thread tears down would otherwise panic where
std::env::var_oscould not, and a panic in aDropduringunwinding aborts.
The
gh auth tokenleg of both token resolvers becomes a parameter.Their tests used to point
PATHat a nonexistent directory, and twoof them wrote a shell script named
ghinto a temp dir and putthat on
PATH, which needs the environment to really change, for agrandchild process. A closure says the same thing, including the case
worth pinning (
ghechoing back themut_value it was handed),without a shell script, a temp directory or a
cfg(unix)gate.Nothing else moves yet:
mergify-ci,mergify-stack,mergify-cliand
mergify-authstill calltemp_env, and the lint that bans itlands at the top of the stack.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com