diff --git a/CHANGELOG.md b/CHANGELOG.md index 09e832b0e..32e6b07bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _None_ ### New Features -_None_ +- Add `EnvManager` class for loading `.env` files and accessing required environment variables with user-friendly error messages. [#578] ### Bug Fixes diff --git a/Gemfile.lock b/Gemfile.lock index e8e938988..8b27212d9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,6 +5,7 @@ PATH buildkit (~> 1.5) chroma (= 0.2.0) diffy (~> 3.3) + dotenv (~> 2.8) fastlane (~> 2.231) gettext (~> 3.5) git (~> 1.3) diff --git a/fastlane-plugin-wpmreleasetoolkit.gemspec b/fastlane-plugin-wpmreleasetoolkit.gemspec index d0f1995a4..8844e00b4 100644 --- a/fastlane-plugin-wpmreleasetoolkit.gemspec +++ b/fastlane-plugin-wpmreleasetoolkit.gemspec @@ -29,6 +29,7 @@ Gem::Specification.new do |spec| spec.add_dependency 'buildkit', '~> 1.5' spec.add_dependency 'chroma', '0.2.0' spec.add_dependency 'diffy', '~> 3.3' + spec.add_dependency 'dotenv', '~> 2.8' spec.add_dependency 'fastlane', '~> 2.231' spec.add_dependency 'gettext', '~> 3.5' spec.add_dependency 'git', '~> 1.3' diff --git a/lib/fastlane/plugin/wpmreleasetoolkit.rb b/lib/fastlane/plugin/wpmreleasetoolkit.rb index b99c3bdd7..e62197ca0 100644 --- a/lib/fastlane/plugin/wpmreleasetoolkit.rb +++ b/lib/fastlane/plugin/wpmreleasetoolkit.rb @@ -4,9 +4,9 @@ module Fastlane module Wpmreleasetoolkit - # Return all .rb files inside the "actions", "helper" and "models" directories + # Return all .rb files inside the "actions", "env_manager", "helper", "models", and "versioning" directories def self.all_classes - Dir[File.expand_path('**/{actions,helper,models,versioning}/**/*.rb', File.dirname(__FILE__))] + Dir[File.expand_path('**/{actions,env_manager,helper,models,versioning}/**/*.rb', File.dirname(__FILE__))] end end end diff --git a/lib/fastlane/plugin/wpmreleasetoolkit/env_manager/env_manager.rb b/lib/fastlane/plugin/wpmreleasetoolkit/env_manager/env_manager.rb new file mode 100644 index 000000000..747bcd15f --- /dev/null +++ b/lib/fastlane/plugin/wpmreleasetoolkit/env_manager/env_manager.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +require 'dotenv' +require 'shellwords' +# TODO: It would be nice to decouple this from Fastlane. +# To give a good UX in the current use case, however, it's best to access the Fastlane UI methods directly. +require 'fastlane' + +# Manages loading of environment variables from a .env and accessing them in a user-friendly way. +class EnvManager + attr_reader :env_path, :env_example_path, :print_error_lambda + + class << self + attr_writer :default_print_error_lambda + end + + # Set up by loading the .env file with the given name. + # + # TODO: We could go one step and guess the name based on the repo URL. + def initialize( + env_file_name:, + env_file_folder: File.join(Dir.home, '.a8c-apps'), + example_env_file_path: 'fastlane/example.env', + print_error_lambda: ->(message) { FastlaneCore::UI.user_error!(message) }, + print_warning_lambda: ->(message) { FastlaneCore::UI.important(message) } + ) + @env_path = File.join(env_file_folder, env_file_name) + @env_example_path = example_env_file_path + @print_error_lambda = print_error_lambda + @print_warning_lambda = print_warning_lambda + + unless File.exist?(@env_path) || running_on_ci? + @print_warning_lambda.call("Warning: env file not found at #{@env_path}. Environment variables may not be loaded.") + end + + Dotenv.load(@env_path) + end + + # Use this instead of getting values from `ENV` directly. It will throw an error if the requested value is missing or empty. + def get_required_env!(key) + unless ENV.key?(key) + message = "Environment variable '#{key}' is not set." + + error_message = + if running_on_ci? + message + elsif File.exist?(@env_path) + "#{message} Consider adding it to #{@env_path}." + else + env_file_dir = File.dirname(@env_path) + env_file_name = File.basename(@env_path) + + <<~MSG + #{env_file_name} not found in #{env_file_dir} while looking for env var #{key}. + + Please copy #{@env_example_path} to #{@env_path} and fill in the value for #{key}. + + mkdir -p #{Shellwords.shellescape(env_file_dir)} && cp #{Shellwords.shellescape(@env_example_path)} #{Shellwords.shellescape(@env_path)} + MSG + end + + @print_error_lambda.call(error_message) + raise KeyError, error_message + end + + value = ENV.fetch(key) + + if value.to_s.empty? + empty_message = "Env var for key #{key} is set but empty. Please set a value for #{key}." + @print_error_lambda.call(empty_message) + raise ArgumentError, empty_message + end + + value + end + + # Use this to ensure all env vars a lane requires are set. + # + # The best place to call this is at the start of a lane, to fail early. + def require_env_vars!(*keys) + keys.flatten.each { |key| get_required_env!(key) } + end + + # CI environment helpers — read common metadata from the CI provider. + # + # Notice that given Buildkite is the only CI provider we use, they are Buildkite-dependent. + # + # If this were to be adopted more broadly, we'd need a two-tier approach: + # 1. Detect which CI is in use + # 2. Use its specific env vars + # 3. Maybe fallback to best guess or outright error if no vendor detected + + def build_number + ENV.fetch('BUILDKITE_BUILD_NUMBER', '0') + end + + def branch_name + ENV.fetch('BUILDKITE_BRANCH', nil) + end + + def commit_hash + ENV.fetch('BUILDKITE_COMMIT', nil) + end + + # Returns the PR number as an Integer, or nil if not running on a PR build. + # Buildkite sets BUILDKITE_PULL_REQUEST to 'false' (not nil) when not on a PR. + def pull_request_number + pr_num = ENV.fetch('BUILDKITE_PULL_REQUEST', 'false') + pr_num == 'false' ? nil : Integer(pr_num) + end + + # Returns a human-readable label: "PR #123" for PR builds, or the branch name otherwise. + def pr_number_or_branch_name + pull_request_number&.then { |num| "PR ##{num}" } || branch_name + end + + # Class-level convenience methods that delegate to a default instance. + # This preserves the existing API: `EnvManager.set_up(...)` then `EnvManager.get_required_env!(...)`. + + def self.set_up(**args) + if configured? + default_print_error_lambda.call('EnvManager is already configured. Call `EnvManager.reset!` before calling `EnvManager.set_up(...)` again.') + return @default + end + + @default = new(**args) + end + + def self.get_required_env!(key) + default!.get_required_env!(key) + end + + def self.require_env_vars!(*keys) + default!.require_env_vars!(*keys) + end + + # Clears the default instance, useful for test teardown. + def self.reset! + @default = nil + end + + # Returns true if a default instance has been configured via `.set_up`. + def self.configured? + !@default.nil? + end + + def self.default! + return @default if configured? + + message = 'EnvManager is not configured. Call `EnvManager.set_up(...)` first.' + default_print_error_lambda.call(message) + raise message + end + + def self.default_print_error_lambda + @default&.print_error_lambda || @default_print_error_lambda || ->(message) { FastlaneCore::UI.user_error!(message) } + end + + private + + # Consider any non-empty, non-falsy value of `CI` to mean we're running on CI. + # Most CI providers set `CI=true`, but some use `CI=1`. + # + # Note: the CI helpers above (`build_number`, etc.) remain Buildkite-specific — + # see the block comment on them. Detection via `CI` is a de facto standard and + # cheap to generalize; value-fetching is not. + def running_on_ci? + value = ENV.fetch('CI', nil) + return false if value.nil? || value.empty? + + !%w[false 0].include?(value.downcase) + end +end diff --git a/spec/env_manager_spec.rb b/spec/env_manager_spec.rb new file mode 100644 index 000000000..1e9a38251 --- /dev/null +++ b/spec/env_manager_spec.rb @@ -0,0 +1,502 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe EnvManager do + let(:errors) { [] } + let(:print_error_lambda) do + lambda do |message| + errors << message + raise message + end + end + + let(:warnings) { [] } + let(:print_warning_lambda) { ->(message) { warnings << message } } + + # Capture and restore ENV state around each test + around do |example| + saved_env = ENV.to_h + example.run + ensure + ENV.replace(saved_env) + described_class.default_print_error_lambda = nil + end + + describe '#initialize' do + it 'sets env_path from folder and file name' do + manager = described_class.new( + env_file_name: 'my-app.env', + env_file_folder: '/tmp/test-env', + print_error_lambda: print_error_lambda + ) + + expect(manager.env_path).to eq('/tmp/test-env/my-app.env') + end + + it 'defaults env_file_folder to ~/.a8c-apps' do + manager = described_class.new( + env_file_name: 'my-app.env', + print_error_lambda: print_error_lambda + ) + + expect(manager.env_path).to eq(File.join(Dir.home, '.a8c-apps', 'my-app.env')) + end + + it 'sets env_example_path' do + manager = described_class.new( + env_file_name: 'my-app.env', + example_env_file_path: 'custom/example.env', + print_error_lambda: print_error_lambda + ) + + expect(manager.env_example_path).to eq('custom/example.env') + end + + it 'defaults env_example_path to fastlane/example.env' do + manager = described_class.new( + env_file_name: 'my-app.env', + print_error_lambda: print_error_lambda + ) + + expect(manager.env_example_path).to eq('fastlane/example.env') + end + + it 'loads the .env file via Dotenv' do + with_tmp_file(named: 'test.env', content: "TEST_INIT_VAR=loaded\n") do |path| + described_class.new( + env_file_name: File.basename(path), + env_file_folder: File.dirname(path), + print_error_lambda: print_error_lambda + ) + + expect(ENV.fetch('TEST_INIT_VAR', nil)).to eq('loaded') + end + end + + it 'warns when the env file does not exist' do + ENV.delete('CI') + + described_class.new( + env_file_name: 'nonexistent.env', + env_file_folder: '/tmp/no-such-dir', + print_error_lambda: print_error_lambda, + print_warning_lambda: print_warning_lambda + ) + + expect(warnings).to include(a_string_matching(/env file not found/)) + end + + it 'does not warn when the env file exists' do + with_tmp_file(named: 'exists.env', content: '') do |path| + described_class.new( + env_file_name: File.basename(path), + env_file_folder: File.dirname(path), + print_error_lambda: print_error_lambda, + print_warning_lambda: print_warning_lambda + ) + + expect(warnings).to be_empty + end + end + + it 'does not warn on CI even if the env file is missing' do + ENV['CI'] = 'true' + + described_class.new( + env_file_name: 'nonexistent.env', + env_file_folder: '/tmp/no-such-dir', + print_error_lambda: print_error_lambda, + print_warning_lambda: print_warning_lambda + ) + + expect(warnings).to be_empty + end + + # Guard against the common pitfall of a strict `CI == 'true'` check — + # some providers set `CI=1` or similar truthy values. + %w[true TRUE 1 yes on].each do |ci_value| + it "treats CI=#{ci_value.inspect} as running on CI" do + ENV['CI'] = ci_value + + described_class.new( + env_file_name: 'nonexistent.env', + env_file_folder: '/tmp/no-such-dir', + print_error_lambda: print_error_lambda, + print_warning_lambda: print_warning_lambda + ) + + expect(warnings).to be_empty + end + end + + %w[false FALSE 0].each do |ci_value| + it "does not treat CI=#{ci_value.inspect} as running on CI" do + ENV['CI'] = ci_value + + described_class.new( + env_file_name: 'nonexistent.env', + env_file_folder: '/tmp/no-such-dir', + print_error_lambda: print_error_lambda, + print_warning_lambda: print_warning_lambda + ) + + expect(warnings).not_to be_empty + end + end + end + + describe '#get_required_env!' do + subject(:manager) do + described_class.new( + env_file_name: 'test.env', + env_file_folder: env_file_folder, + print_error_lambda: print_error_lambda + ) + end + + let(:env_file_folder) { '/tmp/nonexistent-env-folder' } + + it 'returns the value when the env var is set' do + ENV['TEST_KEY'] = 'test_value' + + expect(manager.get_required_env!('TEST_KEY')).to eq('test_value') + end + + context 'when the env var is missing' do + before { ENV.delete('MISSING_KEY') } + + it 'prints a CI-specific error when running on CI' do + ENV['CI'] = 'true' + + expect { manager.get_required_env!('MISSING_KEY') } + .to raise_error("Environment variable 'MISSING_KEY' is not set.") + end + + it 'suggests adding the var to the env file when the file exists' do + ENV.delete('CI') + + in_tmp_dir do |tmpdir| + env_path = File.join(tmpdir, 'test.env') + File.write(env_path, '') + + local_manager = described_class.new( + env_file_name: 'test.env', + env_file_folder: tmpdir, + print_error_lambda: print_error_lambda + ) + + expect { local_manager.get_required_env!('MISSING_KEY') } + .to raise_error("Environment variable 'MISSING_KEY' is not set. Consider adding it to #{env_path}.") + end + end + + it 'prints setup instructions when the env file does not exist' do + ENV.delete('CI') + + expect { manager.get_required_env!('MISSING_KEY') } + .to raise_error(%r{test\.env not found in /tmp/nonexistent-env-folder}) + end + + it 'escapes paths with spaces in the shell command' do + ENV.delete('CI') + + spaced_manager = described_class.new( + env_file_name: 'test.env', + env_file_folder: '/tmp/path with spaces', + example_env_file_path: 'lane/example file.env', + print_error_lambda: print_error_lambda + ) + + expect { spaced_manager.get_required_env!('MISSING_KEY') } + .to raise_error(%r{mkdir -p /tmp/path\\ with\\ spaces && cp lane/example\\ file\.env /tmp/path\\ with\\ spaces/test\.env}) + end + + it 'raises KeyError even when the error lambda does not raise' do + ENV['CI'] = 'true' + non_raising_errors = [] + + non_raising_manager = described_class.new( + env_file_name: 'test.env', + env_file_folder: '/tmp', + print_error_lambda: ->(message) { non_raising_errors << message } + ) + + expect { non_raising_manager.get_required_env!('MISSING_KEY') } + .to raise_error(KeyError, /MISSING_KEY/) + expect(non_raising_errors).to include(a_string_matching(/not set/)) + end + end + + it 'prints an error when the env var is set but empty' do + ENV['EMPTY_KEY'] = '' + + expect { manager.get_required_env!('EMPTY_KEY') } + .to raise_error(/is set but empty/) + end + + it 'raises ArgumentError for empty values even when the error lambda does not raise' do + ENV['EMPTY_KEY'] = '' + non_raising_errors = [] + + non_raising_manager = described_class.new( + env_file_name: 'test.env', + env_file_folder: '/tmp', + print_error_lambda: ->(message) { non_raising_errors << message } + ) + + expect { non_raising_manager.get_required_env!('EMPTY_KEY') } + .to raise_error(ArgumentError, /is set but empty/) + expect(non_raising_errors).to include(a_string_matching(/is set but empty/)) + end + end + + describe '#require_env_vars!' do + subject(:manager) do + described_class.new( + env_file_name: 'test.env', + env_file_folder: '/tmp', + print_error_lambda: print_error_lambda + ) + end + + it 'validates each key' do + ENV['KEY_A'] = 'a' + ENV['KEY_B'] = 'b' + + result_a = manager.get_required_env!('KEY_A') + result_b = manager.get_required_env!('KEY_B') + + manager.require_env_vars!('KEY_A', 'KEY_B') + + expect(result_a).to eq('a') + expect(result_b).to eq('b') + end + + it 'accepts an array of keys' do + ENV['KEY_A'] = 'a' + ENV['KEY_B'] = 'b' + + manager.require_env_vars!(%w[KEY_A KEY_B]) + + expect(manager.get_required_env!('KEY_A')).to eq('a') + expect(manager.get_required_env!('KEY_B')).to eq('b') + end + + it 'raises on the first missing key' do + ENV['KEY_A'] = 'a' + ENV.delete('KEY_B') + + expect { manager.require_env_vars!('KEY_A', 'KEY_B') } + .to raise_error(/KEY_B/) + end + end + + describe 'CI environment helpers' do + subject(:manager) do + described_class.new( + env_file_name: 'test.env', + env_file_folder: '/tmp', + print_error_lambda: print_error_lambda + ) + end + + describe '#build_number' do + it 'returns the Buildkite build number' do + ENV['BUILDKITE_BUILD_NUMBER'] = '42' + + expect(manager.build_number).to eq('42') + end + + it 'defaults to 0 when not set' do + ENV.delete('BUILDKITE_BUILD_NUMBER') + + expect(manager.build_number).to eq('0') + end + end + + describe '#branch_name' do + it 'returns the Buildkite branch' do + ENV['BUILDKITE_BRANCH'] = 'feature/cool' + + expect(manager.branch_name).to eq('feature/cool') + end + + it 'returns nil when not set' do + ENV.delete('BUILDKITE_BRANCH') + + expect(manager.branch_name).to be_nil + end + end + + describe '#commit_hash' do + it 'returns the Buildkite commit' do + ENV['BUILDKITE_COMMIT'] = 'abc123' + + expect(manager.commit_hash).to eq('abc123') + end + + it 'returns nil when not set' do + ENV.delete('BUILDKITE_COMMIT') + + expect(manager.commit_hash).to be_nil + end + end + + describe '#pull_request_number' do + it 'returns the PR number as an integer' do + ENV['BUILDKITE_PULL_REQUEST'] = '99' + + expect(manager.pull_request_number).to eq(99) + end + + it 'returns nil when set to false' do + ENV['BUILDKITE_PULL_REQUEST'] = 'false' + + expect(manager.pull_request_number).to be_nil + end + + it 'returns nil when not set' do + ENV.delete('BUILDKITE_PULL_REQUEST') + + expect(manager.pull_request_number).to be_nil + end + end + + describe '#pr_number_or_branch_name' do + it 'returns PR label when on a PR build' do + ENV['BUILDKITE_PULL_REQUEST'] = '42' + ENV['BUILDKITE_BRANCH'] = 'feature/x' + + expect(manager.pr_number_or_branch_name).to eq('PR #42') + end + + it 'falls back to branch name when not on a PR' do + ENV['BUILDKITE_PULL_REQUEST'] = 'false' + ENV['BUILDKITE_BRANCH'] = 'trunk' + + expect(manager.pr_number_or_branch_name).to eq('trunk') + end + + it 'returns nil when neither PR nor branch is set' do + ENV.delete('BUILDKITE_PULL_REQUEST') + ENV.delete('BUILDKITE_BRANCH') + + expect(manager.pr_number_or_branch_name).to be_nil + end + end + end + + describe 'class-level convenience methods' do + before do + described_class.reset! + described_class.set_up( + env_file_name: 'test.env', + env_file_folder: '/tmp', + print_error_lambda: print_error_lambda + ) + end + + after { described_class.reset! } + + describe '.set_up' do + it 'raises a clear error when called twice without reset' do + expect do + described_class.set_up( + env_file_name: 'other.env', + env_file_folder: '/tmp', + print_error_lambda: print_error_lambda + ) + end.to raise_error('EnvManager is already configured. Call `EnvManager.reset!` before calling `EnvManager.set_up(...)` again.') + end + + it 'does not overwrite the default instance when the error lambda does not raise' do + # Set up with a non-raising error lambda so we can verify the guard behavior + described_class.reset! + non_raising_errors = [] + non_raising_lambda = ->(message) { non_raising_errors << message } + + described_class.set_up( + env_file_name: 'test.env', + env_file_folder: '/tmp', + print_error_lambda: non_raising_lambda + ) + original_default = described_class.send(:instance_variable_get, :@default) + + # Second call should not overwrite the original + described_class.set_up( + env_file_name: 'other.env', + env_file_folder: '/tmp', + print_error_lambda: non_raising_lambda + ) + + expect(described_class.send(:instance_variable_get, :@default)).to equal(original_default) + expect(non_raising_errors).to include(a_string_matching(/already configured/)) + end + end + + describe '.get_required_env!' do + it 'delegates to the default instance' do + ENV['CLASS_KEY'] = 'class_value' + + expect(described_class.get_required_env!('CLASS_KEY')).to eq('class_value') + end + + it 'raises a clear error when called before set_up' do + described_class.reset! + described_class.default_print_error_lambda = print_error_lambda + + expect { described_class.get_required_env!('CLASS_KEY') } + .to raise_error('EnvManager is not configured. Call `EnvManager.set_up(...)` first.') + end + + it 'raises even when the error lambda does not raise' do + described_class.reset! + described_class.default_print_error_lambda = ->(message) { errors << message } + + expect { described_class.get_required_env!('CLASS_KEY') } + .to raise_error(RuntimeError, 'EnvManager is not configured. Call `EnvManager.set_up(...)` first.') + expect(errors).to include(a_string_matching(/not configured/)) + end + end + + describe '.require_env_vars!' do + it 'delegates to the default instance' do + ENV['CK1'] = 'v1' + ENV['CK2'] = 'v2' + + expect { described_class.require_env_vars!('CK1', 'CK2') }.not_to raise_error + end + + it 'raises a clear error when called before set_up' do + described_class.reset! + described_class.default_print_error_lambda = print_error_lambda + + expect { described_class.require_env_vars!('CK1', 'CK2') } + .to raise_error('EnvManager is not configured. Call `EnvManager.set_up(...)` first.') + end + end + + describe '.reset!' do + it 'clears the default instance' do + expect(described_class).to be_configured + + described_class.reset! + + expect(described_class).not_to be_configured + end + end + + describe '.configured?' do + it 'returns false before set_up' do + described_class.reset! + + expect(described_class).not_to be_configured + end + + it 'returns true after set_up' do + expect(described_class).to be_configured + end + end + end +end