Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
### Breaking Changes

- The `ios_build_preflight` and `android_build_preflight` actions have been removed. [#773]
- `EnvManager` now populates the process `ENV` from the loaded `.env` file by default [#723].

### New Features

Expand Down
10 changes: 10 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@

- Remove calls to `ios_build_preflight` and `android_build_preflight` actions from your Fastfiles as they've been removed. They also applied legacy `.configure` secrets and ran dependency/tool checks, so you need to preserve any behavior your project still needs explicitly.

### `EnvManager` populates the process `ENV` by default

`EnvManager` now layers the values from the loaded `.env` file into the process `ENV` when you call `set_up`/`new`, so fastlane actions that resolve their `default_value:` via `ENV.fetch(...)` can see them. Pre-existing `ENV` entries always win (no-override). `EnvManager.reset!` rolls back the keys the default instance added; for instances you created with `new`, call `restore_env!`.

This reverses the parse-only behavior introduced in `14.4.0` (#578), which kept `ENV` pristine and exposed values only through the `EnvManager` instance. If you upgraded to a `14.4.x`+ release and rely on `ENV` staying untouched, pass `mutate_env: false` to restore that behavior:

```ruby
EnvManager.set_up(env_file_name: '.myapp-env', mutate_env: false)
```

## From 13.x to 14.0.0

### Metadata Source Actions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,22 @@ class << self
end

# Set up by loading the .env file with the given name.
#
# When `mutate_env` is true (the default), values from the .env file are
# layered into the process `ENV` using no-override semantics: keys already
# set in `ENV` (e.g. by CI) win. This lets fastlane actions that look up
# values via `ENV.fetch(...)` for their `default_value:` find them without
# the caller having to thread them through explicitly.
#
# Pass `mutate_env: false` to keep `ENV` pristine — values are still
# accessible via `get_required_env!`, but only through this instance.
# Useful for tests that want isolation, or for callers that prefer to
# control `ENV` themselves.
def initialize(
env_file_name:,
env_file_folder: File.join(Dir.home, '.a8c-apps'),
example_env_file_path: 'fastlane/example.env',
mutate_env: true,
print_error_lambda: ->(message) { FastlaneCore::UI.user_error!(message) },
print_warning_lambda: ->(message) { FastlaneCore::UI.important(message) }
)
Expand All @@ -31,10 +43,30 @@ def initialize(
@print_warning_lambda.call("Warning: env file not found at #{@env_path}. Environment variables may not be loaded.")
end

# Parse rather than load so we don't mutate the global ENV. Each instance
# gets its own view, and values from the process environment (e.g. set by
# CI) still take precedence — see `env_value`.
@loaded_env = File.exist?(@env_path) ? Dotenv.parse(@env_path) : {}
unless mutate_env
@loaded_env = File.exist?(@env_path) ? Dotenv.parse(@env_path) : {}
@mutations = {}
return
end

# `load` rather than `parse` so interpolated values (`B=${A}`) resolve
# against the process `ENV`; `parse` resolves them against the file's own values.
env_before = ENV.to_h
@loaded_env = File.exist?(@env_path) ? Dotenv.load(@env_path) : {}
# Records the value this instance wrote for each key it added. On restore,
# we delete only if `ENV[key]` still matches — if a later caller overwrote
# it, their value is left alone.
@mutations = ENV.to_h.reject { |key, _| env_before.key?(key) }
end

# Remove from `ENV` any keys this instance added via `mutate_env: true`,
# but only if the value is still the one we wrote. Keys that a later
# caller has overwritten are left untouched.
# Idempotent — calling more than once is safe. Used by `reset!` and
# available for callers that want to roll back manually.
def restore_env!
@mutations.each { |key, value| ENV.delete(key) if ENV[key] == value }
@mutations = {}
end

# Use this instead of getting values from `ENV` directly. It will throw an error if the requested value is missing or empty.
Expand Down Expand Up @@ -135,8 +167,10 @@ def self.require_env_vars!(*keys)
default!.require_env_vars!(*keys)
end

# Clears the default instance, useful for test teardown.
# Clears the default instance, useful for test teardown. Also rolls back
# any `ENV` mutations the default instance performed via `mutate_env`.
def self.reset!
@default&.restore_env!
@default = nil
end

Expand Down
200 changes: 198 additions & 2 deletions spec/env_manager_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@
expect(manager.env_example_path).to eq('fastlane/example.env')
end

it 'loads values from the .env file without mutating ENV' do
it 'loads values from the .env file into ENV by default' do
ENV.delete('TEST_INIT_VAR')

with_tmp_file(named: 'test.env', content: "TEST_INIT_VAR=loaded\n") do |path|
Expand All @@ -72,22 +72,40 @@
print_error_lambda: print_error_lambda
)

expect(manager.get_required_env!('TEST_INIT_VAR')).to eq('loaded')
expect(ENV.fetch('TEST_INIT_VAR', nil)).to eq('loaded')
end
end

it 'leaves ENV untouched when mutate_env is false' do
ENV.delete('TEST_INIT_VAR')

with_tmp_file(named: 'test.env', content: "TEST_INIT_VAR=loaded\n") do |path|
manager = described_class.new(
env_file_name: File.basename(path),
env_file_folder: File.dirname(path),
mutate_env: false,
print_error_lambda: print_error_lambda
)

expect(manager.get_required_env!('TEST_INIT_VAR')).to eq('loaded')
expect(ENV.fetch('TEST_INIT_VAR', nil)).to be_nil
end
end

it 'keeps multiple instances isolated from each other' do
it 'keeps multiple instances isolated when mutate_env is false' do
with_tmp_file(named: 'a.env', content: "SHARED_KEY=from_a\n") do |path_a|
with_tmp_file(named: 'b.env', content: "SHARED_KEY=from_b\n") do |path_b|
manager_a = described_class.new(
env_file_name: File.basename(path_a),
env_file_folder: File.dirname(path_a),
mutate_env: false,
print_error_lambda: print_error_lambda
)
manager_b = described_class.new(
env_file_name: File.basename(path_b),
env_file_folder: File.dirname(path_b),
mutate_env: false,
print_error_lambda: print_error_lambda
)

Expand All @@ -108,6 +126,69 @@
)

expect(manager.get_required_env!('PRECEDENCE_KEY')).to eq('from_env')
# No-override: the pre-existing ENV value is left in place.
expect(ENV.fetch('PRECEDENCE_KEY')).to eq('from_env')
end
end

it 'lets the first instance win when multiple mutate_env instances share a key' do
ENV.delete('FIRST_WINS_KEY')

with_tmp_file(named: 'a.env', content: "FIRST_WINS_KEY=from_a\n") do |path_a|
with_tmp_file(named: 'b.env', content: "FIRST_WINS_KEY=from_b\n") do |path_b|
described_class.new(
env_file_name: File.basename(path_a),
env_file_folder: File.dirname(path_a),
print_error_lambda: print_error_lambda
)
described_class.new(
env_file_name: File.basename(path_b),
env_file_folder: File.dirname(path_b),
print_error_lambda: print_error_lambda
)

# The second instance's value never lands in ENV because the first
# already populated the key.
expect(ENV.fetch('FIRST_WINS_KEY')).to eq('from_a')
end
end
end

it 'resolves interpolated values against the process ENV' do
ENV['INTERP_BASE'] = 'from_env'
ENV.delete('INTERP_DERIVED')

with_tmp_file(named: 'i.env', content: "INTERP_BASE=from_file\nINTERP_DERIVED=${INTERP_BASE}\n") do |path|
manager = described_class.new(
env_file_name: File.basename(path),
env_file_folder: File.dirname(path),
print_error_lambda: print_error_lambda
)

expect(manager.get_required_env!('INTERP_DERIVED')).to eq('from_env')
expect(ENV.fetch('INTERP_DERIVED')).to eq('from_env')

manager.restore_env!

expect(ENV.fetch('INTERP_DERIVED', nil)).to be_nil
expect(ENV.fetch('INTERP_BASE')).to eq('from_env')
end
end

it 'resolves interpolated values against the file when mutate_env is false' do
ENV['INTERP_BASE'] = 'from_env'
ENV.delete('INTERP_DERIVED')

with_tmp_file(named: 'i.env', content: "INTERP_BASE=from_file\nINTERP_DERIVED=${INTERP_BASE}\n") do |path|
manager = described_class.new(
env_file_name: File.basename(path),
env_file_folder: File.dirname(path),
mutate_env: false,
print_error_lambda: print_error_lambda
)

expect(manager.get_required_env!('INTERP_DERIVED')).to eq('from_file')
expect(ENV.fetch('INTERP_DERIVED', nil)).to be_nil
end
end

Expand Down Expand Up @@ -329,6 +410,85 @@
end
end

describe '#restore_env!' do
it 'removes only the keys this instance added to ENV' do
ENV.delete('RESTORE_NEW_KEY')
ENV['RESTORE_PREEXISTING_KEY'] = 'original'

with_tmp_file(
named: 'restore.env',
content: "RESTORE_NEW_KEY=loaded\nRESTORE_PREEXISTING_KEY=from_file\n"
) do |path|
manager = described_class.new(
env_file_name: File.basename(path),
env_file_folder: File.dirname(path),
print_error_lambda: print_error_lambda
)

expect(ENV.fetch('RESTORE_NEW_KEY')).to eq('loaded')
expect(ENV.fetch('RESTORE_PREEXISTING_KEY')).to eq('original')

manager.restore_env!

expect(ENV.fetch('RESTORE_NEW_KEY', nil)).to be_nil
expect(ENV.fetch('RESTORE_PREEXISTING_KEY')).to eq('original')
end
end

it 'is idempotent' do
ENV.delete('IDEMPOTENT_KEY')

with_tmp_file(named: 'restore.env', content: "IDEMPOTENT_KEY=loaded\n") do |path|
manager = described_class.new(
env_file_name: File.basename(path),
env_file_folder: File.dirname(path),
print_error_lambda: print_error_lambda
)

manager.restore_env!
expect { manager.restore_env! }.not_to raise_error
expect(ENV.fetch('IDEMPOTENT_KEY', nil)).to be_nil
end
end

it 'leaves a key alone when a later caller has overwritten it' do
ENV.delete('RESTORE_OVERWRITTEN_KEY')

with_tmp_file(named: 'restore.env', content: "RESTORE_OVERWRITTEN_KEY=loaded\n") do |path|
manager = described_class.new(
env_file_name: File.basename(path),
env_file_folder: File.dirname(path),
print_error_lambda: print_error_lambda
)

expect(ENV.fetch('RESTORE_OVERWRITTEN_KEY')).to eq('loaded')

# Simulate a later caller overwriting the value the manager set.
ENV['RESTORE_OVERWRITTEN_KEY'] = 'overwritten'

manager.restore_env!

expect(ENV.fetch('RESTORE_OVERWRITTEN_KEY')).to eq('overwritten')
end
end

it 'is a no-op when mutate_env was false' do
ENV.delete('NOOP_KEY')

with_tmp_file(named: 'restore.env', content: "NOOP_KEY=loaded\n") do |path|
manager = described_class.new(
env_file_name: File.basename(path),
env_file_folder: File.dirname(path),
mutate_env: false,
print_error_lambda: print_error_lambda
)

expect { manager.restore_env! }.not_to raise_error
expect(ENV.fetch('NOOP_KEY', nil)).to be_nil
end
end
end

describe 'CI environment helpers' do
subject(:manager) do
described_class.new(
Expand Down Expand Up @@ -522,6 +682,42 @@

expect(described_class).not_to be_configured
end

it 'rolls back ENV mutations the default instance performed' do
described_class.reset!
ENV.delete('RESET_ROLLBACK_KEY')

with_tmp_file(named: 'reset.env', content: "RESET_ROLLBACK_KEY=loaded\n") do |path|
described_class.set_up(
env_file_name: File.basename(path),
env_file_folder: File.dirname(path),
print_error_lambda: print_error_lambda
)

expect(ENV.fetch('RESET_ROLLBACK_KEY')).to eq('loaded')

described_class.reset!

expect(ENV.fetch('RESET_ROLLBACK_KEY', nil)).to be_nil
end
end

it 'leaves pre-existing ENV entries alone on rollback' do
described_class.reset!
ENV['PREEXISTING_KEY'] = 'original'

with_tmp_file(named: 'reset.env', content: "PREEXISTING_KEY=from_file\n") do |path|
described_class.set_up(
env_file_name: File.basename(path),
env_file_folder: File.dirname(path),
print_error_lambda: print_error_lambda
)

described_class.reset!

expect(ENV.fetch('PREEXISTING_KEY')).to eq('original')
end
end
end

describe '.configured?' do
Expand Down