diff --git a/CHANGELOG.md b/CHANGELOG.md index 26acbe613..f8dce6a3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/MIGRATION.md b/MIGRATION.md index 894c88369..399c6f6dc 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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 diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/env_manager/env_manager.rb b/lib/fastlane/plugin/wpmreleasetoolkit/env_manager/env_manager.rb index 75078af5d..4328cbfc2 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit/env_manager/env_manager.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit/env_manager/env_manager.rb @@ -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) } ) @@ -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. @@ -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 diff --git a/spec/env_manager_spec.rb b/spec/env_manager_spec.rb index 11ca45375..60053d037 100644 --- a/spec/env_manager_spec.rb +++ b/spec/env_manager_spec.rb @@ -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| @@ -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 ) @@ -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 @@ -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( @@ -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