Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 = []
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 97a2002.

@mutated_keys is now @mutations (a key => value hash recording what this instance wrote), and restore_env! only deletes when ENV[key] still equals the value we set. Any later overwrite by another caller is left in place. Added a regression test in spec/env_manager_spec.rb.

Posted by Claude (Opus 4.7) on behalf of @mokagio with approval.

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