diff --git a/Gemfile.lock b/Gemfile.lock index fcabbbe..4302b29 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,59 +1,63 @@ GEM remote: https://rubygems.org/ specs: - ast (2.4.2) + ast (2.4.3) drb (2.2.3) - json (2.7.2) - language_server-protocol (3.17.0.3) + json (2.19.7) + language_server-protocol (3.17.0.5) lint_roller (1.1.0) - minitest (6.0.2) + minitest (6.0.6) drb (~> 2.0) prism (~> 1.5) mutex_m (0.3.0) - parallel (1.24.0) - parser (3.3.1.0) + parallel (1.28.0) + parser (3.3.11.1) ast (~> 2.4.1) racc prism (1.9.0) - racc (1.7.3) + racc (1.8.1) rainbow (3.1.1) - rake (13.3.1) - regexp_parser (2.9.1) - rexml (3.4.2) - rubocop (1.63.5) + rake (13.4.2) + regexp_parser (2.12.0) + rubocop (1.84.2) json (~> 2.3) - language_server-protocol (>= 3.17.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.31.1, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.31.3) - parser (>= 3.3.1.0) - rubocop-performance (1.21.0) - rubocop (>= 1.48.1, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (1.13.0) - standard (1.36.0) + standard (1.54.0) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) - rubocop (~> 1.63.0) + rubocop (~> 1.84.0) standard-custom (~> 1.0.0) - standard-performance (~> 1.4) + standard-performance (~> 1.8) standard-custom (1.0.2) lint_roller (~> 1.0) rubocop (~> 1.50) - standard-performance (1.4.0) + standard-performance (1.9.0) lint_roller (~> 1.1) - rubocop-performance (~> 1.21.0) - unicode-display_width (2.5.0) + rubocop-performance (~> 1.26.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) PLATFORMS ruby x86_64-darwin-23 + x86_64-linux DEPENDENCIES minitest diff --git a/README.md b/README.md index 1f99c13..cbac19b 100644 --- a/README.md +++ b/README.md @@ -10,28 +10,60 @@ It validates the .env file to ensure that we actually have defined a secret for 1. ENVIRONMENT_NAME_SECRET_KEY (e.g. STAGING_SECRET_KEY will replace SECRET_KEY in .env.staging) 2. SECRET_KEY (e.g. SECRET_KEY will replace SECRET_KEY in .env.staging only if STAGING_SECRET_KEY is not defined) +## Inputs + +| Input | Required | Default | Description | +|---|---|---|---| +| `secrets` | Yes | — | JSON glob of all secrets (`${{ toJSON(secrets) }}`). | +| `environment-name` | Yes | — | The environment to replace variables for (e.g. `staging`). | +| `env-file-path` | Yes | — | Path to the output file. | +| `template-file-path` | No | — | Explicit path to the template file. Use this when the template does not follow the `.` naming convention (e.g. `appsettings.Production.json`). When omitted, the template is inferred from `env-file-path` + `environment-name`. | +| `delete-template` | No | `true` | Whether to delete the template file after writing the output. Set to `false` to keep it. | +| `additional-variables` | No | `{}` | JSON object of extra non-secret variables to substitute (e.g. `{"APP_SHA": "abc123"}`). | + ## Usage -The following is an example of how to use this action in your github workflow. +**Convention mode** — template is inferred from `env-file-path` + `environment-name`: ```yaml -name: Replace Environment Secrets -uses: bythepixel/env-replacer-action@1.0.0 -with: +- name: Replace Environment Secrets + uses: bythepixel/env-replacer-action@1.0.0 + with: environment-name: staging env-file-path: .env secrets: ${{ toJSON(secrets) }} ``` -If you have additional variables you would like to include that are not secrets but are dynamic, you can pass them in as well using the additional-variables input. +**Explicit template mode** — use `template-file-path` when the template doesn't follow the standard naming convention: +```yaml +- name: Replace Environment Secrets + uses: bythepixel/env-replacer-action@1.0.0 + with: + environment-name: production + template-file-path: appsettings.Production.json + env-file-path: appsettings.json + secrets: ${{ toJSON(secrets) }} +``` + +If you have additional variables that are not secrets but are dynamic, pass them via `additional-variables`: ```yaml -name: Replace Environment Secrets - - name: Replace Environment Secrets - uses: bythepixel/env-replacer-action@1.0.0 - with: - environment-name: staging - env-file-path: .env - secrets: ${{ toJSON(secrets) }} - additional-variables: '{"APP_SHA": "${{ env.sha }}" }' +- name: Replace Environment Secrets + uses: bythepixel/env-replacer-action@1.0.0 + with: + environment-name: staging + env-file-path: .env + secrets: ${{ toJSON(secrets) }} + additional-variables: '{"APP_SHA": "${{ env.sha }}" }' +``` + +To keep the template file after replacement (e.g. for debugging), set `delete-template: false`: +```yaml +- name: Replace Environment Secrets + uses: bythepixel/env-replacer-action@1.0.0 + with: + environment-name: staging + env-file-path: .env + secrets: ${{ toJSON(secrets) }} + delete-template: false ``` ## Examples @@ -42,7 +74,7 @@ You can cross reference the [examples](./examples) directory as well as the defi - This action is written as a "composite" action, meaning it runs on github runner that uses it. - It does not use docker or any other dependencies. It is written in Ruby with no gem dependencies. Github runners come with Ruby pre-installed and we are not using any version specific features. - The moment you need to use a gem, you will need to update the action to install a specific ruby version and bundle install the gems. -- This action will take the input file, replace all the keys with the secrets you pass in, and write to the file you specify. It will delete the original "environment specific" version of the file. +- This action will take the template file, replace all the keys with the secrets you pass in, and write to the output file you specify. By default it deletes the template file after writing; set `delete-template: false` to keep it. # Local Development diff --git a/action.yml b/action.yml index 2eb8a99..9e34685 100644 --- a/action.yml +++ b/action.yml @@ -8,8 +8,15 @@ inputs: description: 'The environment to replace variables for' required: true env-file-path: - description: 'The path to the final environment file to generate. It should have a sibling file with the same name but with a . extension. Ex. .env.staging' + description: 'Path to the final environment file to generate. This may be the same as the template file path.' required: true + template-file-path: + description: 'Optional explicit path to the template file. Overrides the . convention. Use for files that do not follow that naming, e.g. appsettings.Production.json.' + required: false + delete-template: + description: 'Whether to delete the template file after writing the output. Defaults to true so that e.g. .env.production is not left alongside .env.' + required: false + default: 'true' additional-variables: description: 'A json glob of additional variables to use in the replacement' required: false @@ -22,7 +29,7 @@ runs: shell: bash run: | EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) - to_envs() { jq -r "to_entries[] | \"\(.key)<<$EOF\n\(.value)\n$EOF\n\""; } + to_envs() { jq -r "to_entries[] | \"\(.key)<<$EOF\n\(.value)\n$EOF\n\""; } echo "$SECRETS_CONTEXT" | to_envs >> $GITHUB_ENV env: SECRETS_CONTEXT: ${{ inputs.secrets }} @@ -31,7 +38,7 @@ runs: shell: bash run: | EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64) - to_envs() { jq -r "to_entries[] | \"\(.key)<<$EOF\n\(.value)\n$EOF\n\""; } + to_envs() { jq -r "to_entries[] | \"\(.key)<<$EOF\n\(.value)\n$EOF\n\""; } echo "$ADDITIONAL_VARIABLES" | to_envs >> $GITHUB_ENV env: ADDITIONAL_VARIABLES: ${{ inputs.additional-variables }} @@ -39,7 +46,9 @@ runs: - name: Run replacement shell: bash run: | - ${GITHUB_ACTION_PATH}/bin/replace $ENV_FILE_PATH $ENVIRONMENT_NAME + "${GITHUB_ACTION_PATH}/bin/replace" env: ENV_FILE_PATH: ${{ inputs.env-file-path }} ENVIRONMENT_NAME: ${{ inputs.environment-name }} + TEMPLATE_FILE_PATH: ${{ inputs.template-file-path }} + DELETE_TEMPLATE: ${{ inputs.delete-template }} diff --git a/bin/replace b/bin/replace index baa4a21..fdf44a6 100755 --- a/bin/replace +++ b/bin/replace @@ -1,8 +1,15 @@ #!/usr/bin/env ruby -require_relative '../lib/replacer' +require "optparse" +require_relative "../lib/replacer" -# Example usage: -# ruby replacer.rb .env staging -# Note that we are expecting to find a .env.staging file for this example and will end up creating a new file with the replaced tokens called .env +def coerce_empty_string_to_nil(value) = value.empty? ? nil : value -Replacer.from_args(ARGV).replace +options = { + environment: ENV.fetch("ENVIRONMENT_NAME"), + output_file: ENV.fetch("ENV_FILE_PATH"), + template_path: coerce_empty_string_to_nil(ENV["TEMPLATE_FILE_PATH"]), + delete_template: ENV.fetch("DELETE_TEMPLATE", "true") == "true" +} + + +Replacer.from(**options).replace diff --git a/lib/replacer.rb b/lib/replacer.rb index bb5c363..86bc470 100644 --- a/lib/replacer.rb +++ b/lib/replacer.rb @@ -9,11 +9,10 @@ class Replacer class MissingTokensError < StandardError; end class << self - # Factory to create a new Replacer instance from positional command line arguments - def from_args(args) - validate_args!(args) - environment = args[1] - new(file_path(args), environment) + def from(environment:, output_file:, template_path: nil, delete_template: true) + template = template_path || "#{output_file}.#{environment}" + fail_unless_file!(template) + new(template, environment, output_file, delete_template: delete_template) end private @@ -22,38 +21,35 @@ def file_path(args) args.join(".") end - def validate_args!(args) - raise ArgumentError, "Usage: ruby replacer.rb " if args.length != 2 - raise ArgumentError, "File not found: #{File.expand_path(file_path(args))}" unless File.exist?(file_path(args)) + def fail_unless_file!(file_path) + raise ArgumentError, "File not found: #{File.expand_path(file_path)}" unless File.exist?(file_path) end end attr_reader :normalized_environment - def initialize(file_path, environment) - @file_path = file_path + def initialize(template_path, environment, output_path, delete_template: true) + @template_path = template_path @environment = environment + @output_path = output_path + @delete_template = delete_template @normalized_environment = environment.upcase.tr("-", "_") validate! end def replace - content = File.read(@file_path) + content = File.read(@template_path) tokens_needing_replacement.each do |token| content.gsub!(/(? "Sean"}) do File.write(@file_path, "NAME={NAME}\nAGE={AGE}") - args = [@file_name, @environment] - assert_raises(Replacer::MissingTokensError) { Replacer.from_args(args) } + assert_raises(Replacer::MissingTokensError) { Replacer.from(environment: @environment, output_file: @file_name) } end end def test_it_ignore_dollar_sign_prefixed_tokens File.write(@file_path, "NAME=Cool\nOTHER_NAME=${NAME}") - args = [@file_name, @environment] - Replacer.from_args(args).replace + Replacer.from(environment: @environment, output_file: @file_name).replace assert_equal "NAME=Cool\nOTHER_NAME=${NAME}", File.read(@file_name) end def test_it_replaces_tokens_in_a_file with_environment({"NAME" => "Sean"}) do File.write(@file_path, "NAME={NAME}") - args = [@file_name, @environment] - Replacer.from_args(args).replace + Replacer.from(environment: @environment, output_file: @file_name).replace assert_equal "NAME=Sean", File.read(@file_name) end end @@ -67,8 +57,7 @@ def test_it_replaces_tokens_in_a_file def test_it_deletes_the_environment_specific_file_after_replacing with_environment({"NAME" => "Sean"}) do File.write(@file_path, "NAME={NAME}") - args = [@file_name, @environment] - Replacer.from_args(args).replace + Replacer.from(environment: @environment, output_file: @file_name).replace refute File.exist?(@file_path) end end @@ -76,8 +65,7 @@ def test_it_deletes_the_environment_specific_file_after_replacing def test_it_defaults_to_environment_specific_token with_environment({"STAGING_NAME" => "Seanster", "NAME" => "Sean"}) do File.write(@file_path, "NAME={NAME}") - args = [@file_name, @environment] - Replacer.from_args(args).replace + Replacer.from(environment: @environment, output_file: @file_name).replace assert_equal "NAME=Seanster", File.read(@file_name) end end @@ -85,8 +73,7 @@ def test_it_defaults_to_environment_specific_token def test_it_does_not_replace_dollar_sign_prefixed_tokens with_environment({"NAME" => "Sean"}) do File.write(@file_path, "NAME={NAME}\nOTHER_NAME=${NAME}") - args = [@file_name, @environment] - Replacer.from_args(args).replace + Replacer.from(environment: @environment, output_file: @file_name).replace assert_equal "NAME=Sean\nOTHER_NAME=${NAME}", File.read(@file_name) end @@ -99,11 +86,74 @@ def test_it_supports_hyphenated_environment_names with_environment({"TEST_ENVIRONMENT_SECRET_1" => "secret_value"}) do File.write(file_path, "SECRET_1={SECRET_1}") - args = [@file_name, environment] - Replacer.from_args(args).replace + Replacer.from(environment: environment, output_file: @file_name).replace assert_equal "SECRET_1=secret_value", File.read(@file_name) end ensure FileUtils.rm(file_path) if File.exist?(file_path) end + + def test_keeps_template_when_delete_is_false + with_environment({"NAME" => "Sean"}) do + File.write(@file_path, "NAME={NAME}") + Replacer.from(environment: @environment, output_file: @file_name, delete_template: false).replace + assert_equal "NAME=Sean", File.read(@file_name) + assert File.exist?(@file_path), "sibling template must be kept when delete_template is false" + end + end + + def test_from_fills_a_json_file_in_place + template = "appsettings.Production.json" + File.write(template, %({"ClientId":"{RAMP_CLIENT_ID}"})) + with_environment({"RAMP_CLIENT_ID" => "abc123"}) do + Replacer.from(environment: "production", output_file: template, template_path: template, delete_template: false).replace + assert_equal %({"ClientId":"abc123"}), File.read(template) + assert File.exist?(template), "in-place fill must keep the file" + end + ensure + FileUtils.rm(template) if File.exist?(template) + end + + def test_from_with_distinct_output_deletes_the_template + template = "config.template.json" + output = "config.json" + File.write(template, %({"name":"{NAME}"})) + with_environment({"NAME" => "Sean"}) do + Replacer.from(environment: "production", output_file: output, template_path: template).replace + assert_equal %({"name":"Sean"}), File.read(output) + refute File.exist?(template), "a distinct output must delete the template" + end + ensure + FileUtils.rm(template) if File.exist?(template) + FileUtils.rm(output) if File.exist?(output) + end + + def test_from_keeps_template_when_delete_is_false + template = "config.template.json" + output = "config.json" + File.write(template, %({"name":"{NAME}"})) + with_environment({"NAME" => "Sean"}) do + Replacer.from(environment: "production", output_file: output, template_path: template, delete_template: false).replace + assert_equal %({"name":"Sean"}), File.read(output) + assert File.exist?(template), "template must be kept when delete_template is false" + end + ensure + FileUtils.rm(template) if File.exist?(template) + FileUtils.rm(output) if File.exist?(output) + end + + def test_from_prefers_environment_specific_token + template = "appsettings.Production.json" + File.write(template, %({"ClientId":"{RAMP_CLIENT_ID}"})) + with_environment({"PRODUCTION_RAMP_CLIENT_ID" => "prod", "RAMP_CLIENT_ID" => "bare"}) do + Replacer.from(environment: "production", output_file: template, template_path: template, delete_template: false).replace + assert_equal %({"ClientId":"prod"}), File.read(template) + end + ensure + FileUtils.rm(template) if File.exist?(template) + end + + def test_from_fails_if_template_missing + assert_raises(ArgumentError) { Replacer.from(environment: "production", output_file: "nope.json", template_path: "nope.json") } + end end