Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

### Breaking Changes

_None_
- `EnvManager`: populate the process `ENV` from the loaded `.env` file by default (no-override semantics — pre-existing `ENV` values win), so fastlane actions that read their `default_value:` from `ENV` find loaded secrets without callers having to thread them through explicitly. Pass `mutate_env: false` to opt out and keep the parse-only / instance-isolation semantics introduced in [#578]. `EnvManager.reset!` now also rolls back the keys it added. [#XXX]

### New Features

Expand Down
41 changes: 37 additions & 4 deletions lib/fastlane/plugin/wpmreleasetoolkit/env_manager/env_manager.rb
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,29 @@ 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`.
# Always parse rather than load: it lets us track exactly which keys we
# add to `ENV` so `reset!` can undo only those, without disturbing keys
# that pre-existed in the process environment.
@loaded_env = File.exist?(@env_path) ? Dotenv.parse(@env_path) : {}
@mutated_keys = []

return unless mutate_env

@loaded_env.each do |key, value|
# No-override: anything already in `ENV` (e.g. set by CI) wins.
next if ENV.key?(key)

ENV[key] = value
@mutated_keys << key
end
end

# Remove from `ENV` any keys this instance added via `mutate_env: true`.
# Idempotent — calling more than once is safe. Used by `reset!` and
# available for callers that want to roll back manually.
def restore_env!
@mutated_keys.each { |key| ENV.delete(key) }
@mutated_keys = []
Comment on lines +66 to +68
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 +166,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
141 changes: 139 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,31 @@
)

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

Expand Down Expand Up @@ -329,6 +372,64 @@
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 '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 +623,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