From 27fcdf71c453b942b5087ecd70767a1dc237a153 Mon Sep 17 00:00:00 2001 From: Sean Dickinson <90267290+sean-dickinson@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:57:03 -0400 Subject: [PATCH 01/17] feat: support explicit template-file-path / output-file-path The replacer already substitutes {TOKEN} in any text file, but the file handling assumed the ".env. -> .env" convention: it derived the output by stripping "." and always deleted the template. That can't fill a file whose environment name sits mid-name, e.g. ASP.NET Core's appsettings.Production.json, without clobbering appsettings.json. Add two optional, backward-compatible inputs: * template-file-path - literal template to read (skips the sibling convention) * output-file-path - literal output to write; defaults to the template (fill in place). When distinct from the template the template is deleted, matching convention-mode behaviour. environment-name still drives only the _-over- precedence. bin/replace reads the new paths from the environment and falls back to the existing positional / ENV_FILE_PATH behaviour, so current callers and the `ruby replacer.rb .env staging` CLI are unaffected. Adds tests covering in-place JSON templating, distinct-output deletion, and env-specific tokens. --- action.yml | 18 +++++++++++++----- bin/replace | 25 +++++++++++++++++++++---- lib/replacer.rb | 42 ++++++++++++++++++++++++++++++------------ test/replacer_test.rb | 41 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 21 deletions(-) diff --git a/action.yml b/action.yml index 2eb8a99..dad77d3 100644 --- a/action.yml +++ b/action.yml @@ -8,8 +8,14 @@ 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' - required: true + description: 'Convention mode: 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. Optional when template-file-path is set.' + required: false + template-file-path: + description: 'Explicit mode: literal path to the template file to read. Overrides the . convention. Use for files that do not follow that naming, e.g. appsettings.Production.json.' + required: false + output-file-path: + description: 'Explicit mode: literal path to write the result to. Defaults to template-file-path (fill in place). When different from the template, the template is deleted after writing (as in convention mode).' + required: false additional-variables: description: 'A json glob of additional variables to use in the replacement' required: false @@ -22,7 +28,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 +37,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 +45,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 }} + OUTPUT_FILE_PATH: ${{ inputs.output-file-path }} diff --git a/bin/replace b/bin/replace index baa4a21..61d6a5c 100755 --- a/bin/replace +++ b/bin/replace @@ -1,8 +1,25 @@ #!/usr/bin/env ruby 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 +# Two invocation modes: +# +# 1. Convention (sibling template). Positional args, or ENV_FILE_PATH/ENVIRONMENT_NAME: +# ruby replacer.rb .env staging +# Reads .env.staging, writes .env, deletes the template. +# +# 2. Explicit template/output paths (convention-independent). Set TEMPLATE_FILE_PATH +# (and optionally OUTPUT_FILE_PATH, defaulting to the template for an in-place fill) +# plus ENVIRONMENT_NAME. Used for files like appsettings.Production.json. -Replacer.from_args(ARGV).replace +template = ENV['TEMPLATE_FILE_PATH'] + +if template && !template.strip.empty? + environment = ENV.fetch('ENVIRONMENT_NAME') + output = ENV['OUTPUT_FILE_PATH'] + output = template if output.nil? || output.strip.empty? + Replacer.from_paths(template, environment, output).replace +else + # Fall back to positional args, then to ENV_FILE_PATH/ENVIRONMENT_NAME. + args = ARGV.empty? ? [ENV.fetch('ENV_FILE_PATH'), ENV.fetch('ENVIRONMENT_NAME')] : ARGV + Replacer.from_args(args).replace +end diff --git a/lib/replacer.rb b/lib/replacer.rb index bb5c363..7e53057 100644 --- a/lib/replacer.rb +++ b/lib/replacer.rb @@ -4,16 +4,35 @@ # We will first look for an environment prefixed version of the token, e.g. PRODUCTION_TOKEN_NAME # If that is not found, we will look for the non-environment specific version # If that is not found, we will raise an error +# +# Two ways to point at files: +# * Convention (default): a sibling "." template is read and +# "" is written (the source template is then deleted). This is the +# ".env.production -> .env" flow. +# * Explicit: pass a template path and an output path directly. When the two are +# equal the file is filled in place and not deleted. This supports files whose +# name does not follow the "." convention, e.g. +# ASP.NET Core's appsettings.Production.json. class Replacer class MissingTokensError < StandardError; end class << self - # Factory to create a new Replacer instance from positional command line arguments + # Factory from positional command-line args following the sibling convention: + # replace (reads .) def from_args(args) validate_args!(args) environment = args[1] - new(file_path(args), environment) + template = file_path(args) + output = template.gsub(".#{environment}", "") + new(template, environment, output) + end + + # Factory with explicit template/output paths (convention-independent). + def from_paths(template_path, environment, output_path) + raise ArgumentError, "File not found: #{File.expand_path(template_path)}" unless File.exist?(template_path) + + new(template_path, environment, output_path) end private @@ -30,30 +49,29 @@ def validate_args!(args) attr_reader :normalized_environment - def initialize(file_path, environment) - @file_path = file_path + def initialize(template_path, environment, output_path) + @template_path = template_path @environment = environment + @output_path = output_path @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!(/(? "abc123"}) do + Replacer.from_paths(template, "production", template).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_paths_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_paths(template, "production", output).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_paths_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_paths(template, "production", template).replace + assert_equal %({"ClientId":"prod"}), File.read(template) + end + ensure + FileUtils.rm(template) if File.exist?(template) + end + + def test_from_paths_fails_if_template_missing + assert_raises(ArgumentError) { Replacer.from_paths("nope.json", "production", "nope.json") } + end end From 6e16e6713798d40e1c413354df4f602f38a73af8 Mon Sep 17 00:00:00 2001 From: Sean Dickinson <90267290+sean-dickinson@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:59:06 -0400 Subject: [PATCH 02/17] style: drop alignment spacing flagged by standardrb --- lib/replacer.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/replacer.rb b/lib/replacer.rb index 7e53057..262280d 100644 --- a/lib/replacer.rb +++ b/lib/replacer.rb @@ -23,8 +23,8 @@ class << self def from_args(args) validate_args!(args) environment = args[1] - template = file_path(args) - output = template.gsub(".#{environment}", "") + template = file_path(args) + output = template.gsub(".#{environment}", "") new(template, environment, output) end From 8ec5e7f147f0d65b2c2d4e1570a9089d54791b7f Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Tue, 2 Jun 2026 13:33:11 -0400 Subject: [PATCH 03/17] feat: updates --- README.md | 60 +++++++++++++++++++++++++++++++++---------- action.yml | 14 ++++++---- bin/replace | 39 ++++++++++++++++------------ lib/replacer.rb | 27 ++++++++++--------- test/replacer_test.rb | 27 +++++++++++++++++-- 5 files changed, 115 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 1f99c13..38c1b2c 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` | No* | — | Path to the output file. In convention mode, the action reads a sibling template named `.` (e.g. `.env.staging`). Required when `template-file-path` is not set. | +| `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`). Required when `env-file-path` is not set. | +| `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 dad77d3..5c51f86 100644 --- a/action.yml +++ b/action.yml @@ -11,11 +11,12 @@ inputs: description: 'Convention mode: 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. Optional when template-file-path is set.' required: false template-file-path: - description: 'Explicit mode: literal path to the template file to read. Overrides the . convention. Use for files that do not follow that naming, e.g. appsettings.Production.json.' + 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 - output-file-path: - description: 'Explicit mode: literal path to write the result to. Defaults to template-file-path (fill in place). When different from the template, the template is deleted after writing (as in convention mode).' + 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 @@ -45,9 +46,12 @@ runs: - name: Run replacement shell: bash run: | - ${GITHUB_ACTION_PATH}/bin/replace + ARGS=("$ENV_FILE_PATH" "$ENVIRONMENT_NAME") + [ -n "$TEMPLATE_FILE_PATH" ] && ARGS+=("--template" "$TEMPLATE_FILE_PATH") + [ "$DELETE_TEMPLATE" = "false" ] && ARGS+=("--no-delete-template") + "${GITHUB_ACTION_PATH}/bin/replace" "${ARGS[@]}" env: ENV_FILE_PATH: ${{ inputs.env-file-path }} ENVIRONMENT_NAME: ${{ inputs.environment-name }} TEMPLATE_FILE_PATH: ${{ inputs.template-file-path }} - OUTPUT_FILE_PATH: ${{ inputs.output-file-path }} + DELETE_TEMPLATE: ${{ inputs.delete-template }} diff --git a/bin/replace b/bin/replace index 61d6a5c..2064663 100755 --- a/bin/replace +++ b/bin/replace @@ -1,25 +1,30 @@ #!/usr/bin/env ruby -require_relative '../lib/replacer' +require "optparse" +require_relative "../lib/replacer" -# Two invocation modes: +# Invocation: +# replace [--template ] [--no-delete-template] # -# 1. Convention (sibling template). Positional args, or ENV_FILE_PATH/ENVIRONMENT_NAME: -# ruby replacer.rb .env staging -# Reads .env.staging, writes .env, deletes the template. +# Convention mode (no --template): +# replace .env staging +# Reads .env.staging, writes .env, deletes the template (unless --no-delete-template). # -# 2. Explicit template/output paths (convention-independent). Set TEMPLATE_FILE_PATH -# (and optionally OUTPUT_FILE_PATH, defaulting to the template for an in-place fill) -# plus ENVIRONMENT_NAME. Used for files like appsettings.Production.json. +# Explicit template mode: +# replace .env staging --template .env.production +# Reads .env.production, writes .env, deletes the template (unless --no-delete-template). -template = ENV['TEMPLATE_FILE_PATH'] +options = { template_path: nil, delete_template: true } +OptionParser.new do |opts| + opts.on("--template PATH") { |p| options[:template_path] = p } + opts.on("--[no-]delete-template") { |v| options[:delete_template] = v } +end.parse! -if template && !template.strip.empty? - environment = ENV.fetch('ENVIRONMENT_NAME') - output = ENV['OUTPUT_FILE_PATH'] - output = template if output.nil? || output.strip.empty? - Replacer.from_paths(template, environment, output).replace +output_file, environment = ARGV + +if options[:template_path] + Replacer.from_paths(options[:template_path], environment, output_file, + delete_template: options[:delete_template]).replace else - # Fall back to positional args, then to ENV_FILE_PATH/ENVIRONMENT_NAME. - args = ARGV.empty? ? [ENV.fetch('ENV_FILE_PATH'), ENV.fetch('ENVIRONMENT_NAME')] : ARGV - Replacer.from_args(args).replace + Replacer.from_args([output_file, environment], + delete_template: options[:delete_template]).replace end diff --git a/lib/replacer.rb b/lib/replacer.rb index 262280d..a5a9c2e 100644 --- a/lib/replacer.rb +++ b/lib/replacer.rb @@ -7,12 +7,12 @@ # # Two ways to point at files: # * Convention (default): a sibling "." template is read and -# "" is written (the source template is then deleted). This is the -# ".env.production -> .env" flow. -# * Explicit: pass a template path and an output path directly. When the two are -# equal the file is filled in place and not deleted. This supports files whose -# name does not follow the "." convention, e.g. -# ASP.NET Core's appsettings.Production.json. +# "" is written. This is the ".env.production -> .env" flow. +# * Explicit: pass a template path and an output path directly. Useful for files +# whose name does not follow the "." convention. +# +# In both modes, pass delete_template: false to keep the template file after writing. +# By default the template is deleted (so e.g. .env.production is not left alongside .env). class Replacer class MissingTokensError < StandardError; end @@ -20,19 +20,19 @@ class MissingTokensError < StandardError; end class << self # Factory from positional command-line args following the sibling convention: # replace (reads .) - def from_args(args) + def from_args(args, delete_template: true) validate_args!(args) environment = args[1] template = file_path(args) output = template.gsub(".#{environment}", "") - new(template, environment, output) + new(template, environment, output, delete_template: delete_template) end # Factory with explicit template/output paths (convention-independent). - def from_paths(template_path, environment, output_path) + def from_paths(template_path, environment, output_path, delete_template: true) raise ArgumentError, "File not found: #{File.expand_path(template_path)}" unless File.exist?(template_path) - new(template_path, environment, output_path) + new(template_path, environment, output_path, delete_template: delete_template) end private @@ -49,10 +49,11 @@ def validate_args!(args) attr_reader :normalized_environment - def initialize(template_path, environment, output_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 @@ -63,9 +64,7 @@ def replace content.gsub!(/(? "abc123"}) do - Replacer.from_paths(template, "production", template).replace + Replacer.from_paths(template, "production", template, delete_template: false).replace assert_equal %({"ClientId":"abc123"}), File.read(template) assert File.exist?(template), "in-place fill must keep the file" end @@ -133,11 +133,34 @@ def test_from_paths_with_distinct_output_deletes_the_template FileUtils.rm(output) if File.exist?(output) end + def test_from_paths_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_paths(template, "production", output, 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_args_keeps_template_when_delete_is_false + with_environment({"NAME" => "Sean"}) do + File.write(@file_path, "NAME={NAME}") + Replacer.from_args([@file_name, @environment], 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_paths_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_paths(template, "production", template).replace + Replacer.from_paths(template, "production", template, delete_template: false).replace assert_equal %({"ClientId":"prod"}), File.read(template) end ensure From ebfc6d81d79844c938f3e6aa100bf48714bfe6de Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:37:06 -0400 Subject: [PATCH 04/17] build(deps): bump minitest from 6.0.2 to 6.0.6 (#16) Bumps [minitest](https://github.com/minitest/minitest) from 6.0.2 to 6.0.6. - [Changelog](https://github.com/minitest/minitest/blob/master/History.rdoc) - [Commits](https://github.com/minitest/minitest/compare/v6.0.2...v6.0.6) --- updated-dependencies: - dependency-name: minitest dependency-version: 6.0.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index fcabbbe..0e60cda 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,7 +6,7 @@ GEM json (2.7.2) language_server-protocol (3.17.0.3) lint_roller (1.1.0) - minitest (6.0.2) + minitest (6.0.6) drb (~> 2.0) prism (~> 1.5) mutex_m (0.3.0) From 252d6893183691669dd6b0fc8c526c30acb4ddb5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:37:20 -0400 Subject: [PATCH 05/17] build(deps): bump rake from 13.3.1 to 13.4.2 (#14) Bumps [rake](https://github.com/ruby/rake) from 13.3.1 to 13.4.2. - [Release notes](https://github.com/ruby/rake/releases) - [Changelog](https://github.com/ruby/rake/blob/master/History.rdoc) - [Commits](https://github.com/ruby/rake/compare/v13.3.1...v13.4.2) --- updated-dependencies: - dependency-name: rake dependency-version: 13.4.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0e60cda..6ca88fb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -17,7 +17,7 @@ GEM prism (1.9.0) racc (1.7.3) rainbow (3.1.1) - rake (13.3.1) + rake (13.4.2) regexp_parser (2.9.1) rexml (3.4.2) rubocop (1.63.5) From a26b4e78bcb94f9c6877830be0cf03267ecf3b99 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jun 2026 13:40:42 -0400 Subject: [PATCH 06/17] build(deps): bump standard from 1.36.0 to 1.54.0 (#11) Bumps [standard](https://github.com/standardrb/standard) from 1.36.0 to 1.54.0. - [Release notes](https://github.com/standardrb/standard/releases) - [Changelog](https://github.com/standardrb/standard/blob/main/CHANGELOG.md) - [Commits](https://github.com/standardrb/standard/compare/v1.36.0...v1.54.0) --- updated-dependencies: - dependency-name: standard dependency-version: 1.54.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 53 +++++++++++++++++++++++++++------------------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 6ca88fb..7850394 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,55 +1,58 @@ 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.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.4.2) - regexp_parser (2.9.1) - rexml (3.4.2) - rubocop (1.63.5) + 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 From 4b60317625bc7f0d547e969ce5b9316644943b15 Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Tue, 2 Jun 2026 14:26:46 -0400 Subject: [PATCH 07/17] fix: simplify --- action.yml | 9 ++---- bin/replace | 31 ++++-------------- lib/replacer.rb | 33 ++++--------------- test/replacer_test.rb | 75 +++++++++++++++++-------------------------- 4 files changed, 46 insertions(+), 102 deletions(-) diff --git a/action.yml b/action.yml index 5c51f86..9e34685 100644 --- a/action.yml +++ b/action.yml @@ -8,8 +8,8 @@ inputs: description: 'The environment to replace variables for' required: true env-file-path: - description: 'Convention mode: 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. Optional when template-file-path is set.' - required: false + 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 @@ -46,10 +46,7 @@ runs: - name: Run replacement shell: bash run: | - ARGS=("$ENV_FILE_PATH" "$ENVIRONMENT_NAME") - [ -n "$TEMPLATE_FILE_PATH" ] && ARGS+=("--template" "$TEMPLATE_FILE_PATH") - [ "$DELETE_TEMPLATE" = "false" ] && ARGS+=("--no-delete-template") - "${GITHUB_ACTION_PATH}/bin/replace" "${ARGS[@]}" + "${GITHUB_ACTION_PATH}/bin/replace" env: ENV_FILE_PATH: ${{ inputs.env-file-path }} ENVIRONMENT_NAME: ${{ inputs.environment-name }} diff --git a/bin/replace b/bin/replace index 2064663..72bcb63 100755 --- a/bin/replace +++ b/bin/replace @@ -2,29 +2,12 @@ require "optparse" require_relative "../lib/replacer" -# Invocation: -# replace [--template ] [--no-delete-template] -# -# Convention mode (no --template): -# replace .env staging -# Reads .env.staging, writes .env, deletes the template (unless --no-delete-template). -# -# Explicit template mode: -# replace .env staging --template .env.production -# Reads .env.production, writes .env, deletes the template (unless --no-delete-template). +options = { + environment: ENV.fetch("ENVIRONMENT_NAME"), + output_file: ENV.fetch("ENV_FILE_PATH"), + template_path: ENV["TEMPLATE_FILE_PATH"], + delete_template: ENV.fetch("DELETE_TEMPLATE", "true") == "true" +} -options = { template_path: nil, delete_template: true } -OptionParser.new do |opts| - opts.on("--template PATH") { |p| options[:template_path] = p } - opts.on("--[no-]delete-template") { |v| options[:delete_template] = v } -end.parse! -output_file, environment = ARGV - -if options[:template_path] - Replacer.from_paths(options[:template_path], environment, output_file, - delete_template: options[:delete_template]).replace -else - Replacer.from_args([output_file, environment], - delete_template: options[:delete_template]).replace -end +Replacer.from(**options).replace diff --git a/lib/replacer.rb b/lib/replacer.rb index a5a9c2e..86bc470 100644 --- a/lib/replacer.rb +++ b/lib/replacer.rb @@ -4,35 +4,15 @@ # We will first look for an environment prefixed version of the token, e.g. PRODUCTION_TOKEN_NAME # If that is not found, we will look for the non-environment specific version # If that is not found, we will raise an error -# -# Two ways to point at files: -# * Convention (default): a sibling "." template is read and -# "" is written. This is the ".env.production -> .env" flow. -# * Explicit: pass a template path and an output path directly. Useful for files -# whose name does not follow the "." convention. -# -# In both modes, pass delete_template: false to keep the template file after writing. -# By default the template is deleted (so e.g. .env.production is not left alongside .env). class Replacer class MissingTokensError < StandardError; end class << self - # Factory from positional command-line args following the sibling convention: - # replace (reads .) - def from_args(args, delete_template: true) - validate_args!(args) - environment = args[1] - template = file_path(args) - output = template.gsub(".#{environment}", "") - new(template, environment, output, delete_template: delete_template) - end - - # Factory with explicit template/output paths (convention-independent). - def from_paths(template_path, environment, output_path, delete_template: true) - raise ArgumentError, "File not found: #{File.expand_path(template_path)}" unless File.exist?(template_path) - - new(template_path, environment, output_path, delete_template: delete_template) + 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 @@ -41,9 +21,8 @@ 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 diff --git a/test/replacer_test.rb b/test/replacer_test.rb index 908bd21..fd56e4b 100644 --- a/test/replacer_test.rb +++ b/test/replacer_test.rb @@ -19,47 +19,36 @@ def teardown FileUtils.rm(@file_name) if File.exist?(@file_name) end - def test_it_can_be_constructed_from_args - args = [@file_name, @environment] - replacer = Replacer.from_args(args) + def test_it_can_be_constructed + replacer = Replacer.from(environment: @environment, output_file: @file_name) assert_instance_of Replacer, replacer end - def test_it_fails_if_not_given_2_args - args = [@file_name] - assert_raises(ArgumentError) { Replacer.from_args(args) } - end - def test_it_fails_if_file_does_not_exist - args = ["non_existent_file", "environment"] - assert_raises(ArgumentError) { Replacer.from_args(args) } + assert_raises(ArgumentError) { Replacer.from(environment: "environment", output_file: "non_existent_file") } end def test_it_fails_if_the_environment_doesnt_match - args = [@file_name, "non_existent_environment"] - assert_raises(ArgumentError) { Replacer.from_args(args) } + assert_raises(ArgumentError) { Replacer.from(environment: "non_existent_environment", output_file: @file_name) } end def test_it_fails_if_tokens_are_missing with_environment({"NAME" => "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 +56,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 +64,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 +72,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,19 +85,27 @@ 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_from_paths_fills_a_json_file_in_place + 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_paths(template, "production", template, delete_template: false).replace + 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 @@ -119,12 +113,12 @@ def test_from_paths_fills_a_json_file_in_place FileUtils.rm(template) if File.exist?(template) end - def test_from_paths_with_distinct_output_deletes_the_template + 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_paths(template, "production", output).replace + 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 @@ -133,12 +127,12 @@ def test_from_paths_with_distinct_output_deletes_the_template FileUtils.rm(output) if File.exist?(output) end - def test_from_paths_keeps_template_when_delete_is_false + 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_paths(template, "production", output, delete_template: false).replace + 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 @@ -147,27 +141,18 @@ def test_from_paths_keeps_template_when_delete_is_false FileUtils.rm(output) if File.exist?(output) end - def test_from_args_keeps_template_when_delete_is_false - with_environment({"NAME" => "Sean"}) do - File.write(@file_path, "NAME={NAME}") - Replacer.from_args([@file_name, @environment], 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_paths_prefers_environment_specific_token + 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_paths(template, "production", template, delete_template: false).replace + 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_paths_fails_if_template_missing - assert_raises(ArgumentError) { Replacer.from_paths("nope.json", "production", "nope.json") } + def test_from_fails_if_template_missing + assert_raises(ArgumentError) { Replacer.from(environment: "production", output_file: "nope.json", template_path: "nope.json") } end end From b43a66b5a6eba9d67abd826fcc79a26430d4ef6b Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Tue, 2 Jun 2026 14:32:41 -0400 Subject: [PATCH 08/17] fix: add platform for ci --- Gemfile.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Gemfile.lock b/Gemfile.lock index fcabbbe..f7d56a8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -54,6 +54,7 @@ GEM PLATFORMS ruby x86_64-darwin-23 + x86_64-linux DEPENDENCIES minitest From fce3e8edc4167883935aee3959c82b7fd29f83ca Mon Sep 17 00:00:00 2001 From: Sean Dickinson <90267290+sean-dickinson@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:57:03 -0400 Subject: [PATCH 09/17] feat: support explicit template-file-path / output-file-path The replacer already substitutes {TOKEN} in any text file, but the file handling assumed the ".env. -> .env" convention: it derived the output by stripping "." and always deleted the template. That can't fill a file whose environment name sits mid-name, e.g. ASP.NET Core's appsettings.Production.json, without clobbering appsettings.json. Add two optional, backward-compatible inputs: * template-file-path - literal template to read (skips the sibling convention) * output-file-path - literal output to write; defaults to the template (fill in place). When distinct from the template the template is deleted, matching convention-mode behaviour. environment-name still drives only the _-over- precedence. bin/replace reads the new paths from the environment and falls back to the existing positional / ENV_FILE_PATH behaviour, so current callers and the `ruby replacer.rb .env staging` CLI are unaffected. Adds tests covering in-place JSON templating, distinct-output deletion, and env-specific tokens. --- action.yml | 18 +++++++++++++----- bin/replace | 25 +++++++++++++++++++++---- lib/replacer.rb | 42 ++++++++++++++++++++++++++++++------------ test/replacer_test.rb | 41 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 21 deletions(-) diff --git a/action.yml b/action.yml index 2eb8a99..dad77d3 100644 --- a/action.yml +++ b/action.yml @@ -8,8 +8,14 @@ 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' - required: true + description: 'Convention mode: 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. Optional when template-file-path is set.' + required: false + template-file-path: + description: 'Explicit mode: literal path to the template file to read. Overrides the . convention. Use for files that do not follow that naming, e.g. appsettings.Production.json.' + required: false + output-file-path: + description: 'Explicit mode: literal path to write the result to. Defaults to template-file-path (fill in place). When different from the template, the template is deleted after writing (as in convention mode).' + required: false additional-variables: description: 'A json glob of additional variables to use in the replacement' required: false @@ -22,7 +28,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 +37,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 +45,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 }} + OUTPUT_FILE_PATH: ${{ inputs.output-file-path }} diff --git a/bin/replace b/bin/replace index baa4a21..61d6a5c 100755 --- a/bin/replace +++ b/bin/replace @@ -1,8 +1,25 @@ #!/usr/bin/env ruby 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 +# Two invocation modes: +# +# 1. Convention (sibling template). Positional args, or ENV_FILE_PATH/ENVIRONMENT_NAME: +# ruby replacer.rb .env staging +# Reads .env.staging, writes .env, deletes the template. +# +# 2. Explicit template/output paths (convention-independent). Set TEMPLATE_FILE_PATH +# (and optionally OUTPUT_FILE_PATH, defaulting to the template for an in-place fill) +# plus ENVIRONMENT_NAME. Used for files like appsettings.Production.json. -Replacer.from_args(ARGV).replace +template = ENV['TEMPLATE_FILE_PATH'] + +if template && !template.strip.empty? + environment = ENV.fetch('ENVIRONMENT_NAME') + output = ENV['OUTPUT_FILE_PATH'] + output = template if output.nil? || output.strip.empty? + Replacer.from_paths(template, environment, output).replace +else + # Fall back to positional args, then to ENV_FILE_PATH/ENVIRONMENT_NAME. + args = ARGV.empty? ? [ENV.fetch('ENV_FILE_PATH'), ENV.fetch('ENVIRONMENT_NAME')] : ARGV + Replacer.from_args(args).replace +end diff --git a/lib/replacer.rb b/lib/replacer.rb index bb5c363..7e53057 100644 --- a/lib/replacer.rb +++ b/lib/replacer.rb @@ -4,16 +4,35 @@ # We will first look for an environment prefixed version of the token, e.g. PRODUCTION_TOKEN_NAME # If that is not found, we will look for the non-environment specific version # If that is not found, we will raise an error +# +# Two ways to point at files: +# * Convention (default): a sibling "." template is read and +# "" is written (the source template is then deleted). This is the +# ".env.production -> .env" flow. +# * Explicit: pass a template path and an output path directly. When the two are +# equal the file is filled in place and not deleted. This supports files whose +# name does not follow the "." convention, e.g. +# ASP.NET Core's appsettings.Production.json. class Replacer class MissingTokensError < StandardError; end class << self - # Factory to create a new Replacer instance from positional command line arguments + # Factory from positional command-line args following the sibling convention: + # replace (reads .) def from_args(args) validate_args!(args) environment = args[1] - new(file_path(args), environment) + template = file_path(args) + output = template.gsub(".#{environment}", "") + new(template, environment, output) + end + + # Factory with explicit template/output paths (convention-independent). + def from_paths(template_path, environment, output_path) + raise ArgumentError, "File not found: #{File.expand_path(template_path)}" unless File.exist?(template_path) + + new(template_path, environment, output_path) end private @@ -30,30 +49,29 @@ def validate_args!(args) attr_reader :normalized_environment - def initialize(file_path, environment) - @file_path = file_path + def initialize(template_path, environment, output_path) + @template_path = template_path @environment = environment + @output_path = output_path @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!(/(? "abc123"}) do + Replacer.from_paths(template, "production", template).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_paths_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_paths(template, "production", output).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_paths_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_paths(template, "production", template).replace + assert_equal %({"ClientId":"prod"}), File.read(template) + end + ensure + FileUtils.rm(template) if File.exist?(template) + end + + def test_from_paths_fails_if_template_missing + assert_raises(ArgumentError) { Replacer.from_paths("nope.json", "production", "nope.json") } + end end From 058bd49cb2cc924448a268b7e8ab5c3fa4aef532 Mon Sep 17 00:00:00 2001 From: Sean Dickinson <90267290+sean-dickinson@users.noreply.github.com> Date: Tue, 2 Jun 2026 12:59:06 -0400 Subject: [PATCH 10/17] style: drop alignment spacing flagged by standardrb --- lib/replacer.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/replacer.rb b/lib/replacer.rb index 7e53057..262280d 100644 --- a/lib/replacer.rb +++ b/lib/replacer.rb @@ -23,8 +23,8 @@ class << self def from_args(args) validate_args!(args) environment = args[1] - template = file_path(args) - output = template.gsub(".#{environment}", "") + template = file_path(args) + output = template.gsub(".#{environment}", "") new(template, environment, output) end From c45168341ef5bd762fff62aebdde99f82a0b3b58 Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Tue, 2 Jun 2026 13:33:11 -0400 Subject: [PATCH 11/17] feat: updates --- README.md | 60 +++++++++++++++++++++++++++++++++---------- action.yml | 14 ++++++---- bin/replace | 39 ++++++++++++++++------------ lib/replacer.rb | 27 ++++++++++--------- test/replacer_test.rb | 27 +++++++++++++++++-- 5 files changed, 115 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 1f99c13..38c1b2c 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` | No* | — | Path to the output file. In convention mode, the action reads a sibling template named `.` (e.g. `.env.staging`). Required when `template-file-path` is not set. | +| `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`). Required when `env-file-path` is not set. | +| `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 dad77d3..5c51f86 100644 --- a/action.yml +++ b/action.yml @@ -11,11 +11,12 @@ inputs: description: 'Convention mode: 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. Optional when template-file-path is set.' required: false template-file-path: - description: 'Explicit mode: literal path to the template file to read. Overrides the . convention. Use for files that do not follow that naming, e.g. appsettings.Production.json.' + 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 - output-file-path: - description: 'Explicit mode: literal path to write the result to. Defaults to template-file-path (fill in place). When different from the template, the template is deleted after writing (as in convention mode).' + 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 @@ -45,9 +46,12 @@ runs: - name: Run replacement shell: bash run: | - ${GITHUB_ACTION_PATH}/bin/replace + ARGS=("$ENV_FILE_PATH" "$ENVIRONMENT_NAME") + [ -n "$TEMPLATE_FILE_PATH" ] && ARGS+=("--template" "$TEMPLATE_FILE_PATH") + [ "$DELETE_TEMPLATE" = "false" ] && ARGS+=("--no-delete-template") + "${GITHUB_ACTION_PATH}/bin/replace" "${ARGS[@]}" env: ENV_FILE_PATH: ${{ inputs.env-file-path }} ENVIRONMENT_NAME: ${{ inputs.environment-name }} TEMPLATE_FILE_PATH: ${{ inputs.template-file-path }} - OUTPUT_FILE_PATH: ${{ inputs.output-file-path }} + DELETE_TEMPLATE: ${{ inputs.delete-template }} diff --git a/bin/replace b/bin/replace index 61d6a5c..2064663 100755 --- a/bin/replace +++ b/bin/replace @@ -1,25 +1,30 @@ #!/usr/bin/env ruby -require_relative '../lib/replacer' +require "optparse" +require_relative "../lib/replacer" -# Two invocation modes: +# Invocation: +# replace [--template ] [--no-delete-template] # -# 1. Convention (sibling template). Positional args, or ENV_FILE_PATH/ENVIRONMENT_NAME: -# ruby replacer.rb .env staging -# Reads .env.staging, writes .env, deletes the template. +# Convention mode (no --template): +# replace .env staging +# Reads .env.staging, writes .env, deletes the template (unless --no-delete-template). # -# 2. Explicit template/output paths (convention-independent). Set TEMPLATE_FILE_PATH -# (and optionally OUTPUT_FILE_PATH, defaulting to the template for an in-place fill) -# plus ENVIRONMENT_NAME. Used for files like appsettings.Production.json. +# Explicit template mode: +# replace .env staging --template .env.production +# Reads .env.production, writes .env, deletes the template (unless --no-delete-template). -template = ENV['TEMPLATE_FILE_PATH'] +options = { template_path: nil, delete_template: true } +OptionParser.new do |opts| + opts.on("--template PATH") { |p| options[:template_path] = p } + opts.on("--[no-]delete-template") { |v| options[:delete_template] = v } +end.parse! -if template && !template.strip.empty? - environment = ENV.fetch('ENVIRONMENT_NAME') - output = ENV['OUTPUT_FILE_PATH'] - output = template if output.nil? || output.strip.empty? - Replacer.from_paths(template, environment, output).replace +output_file, environment = ARGV + +if options[:template_path] + Replacer.from_paths(options[:template_path], environment, output_file, + delete_template: options[:delete_template]).replace else - # Fall back to positional args, then to ENV_FILE_PATH/ENVIRONMENT_NAME. - args = ARGV.empty? ? [ENV.fetch('ENV_FILE_PATH'), ENV.fetch('ENVIRONMENT_NAME')] : ARGV - Replacer.from_args(args).replace + Replacer.from_args([output_file, environment], + delete_template: options[:delete_template]).replace end diff --git a/lib/replacer.rb b/lib/replacer.rb index 262280d..a5a9c2e 100644 --- a/lib/replacer.rb +++ b/lib/replacer.rb @@ -7,12 +7,12 @@ # # Two ways to point at files: # * Convention (default): a sibling "." template is read and -# "" is written (the source template is then deleted). This is the -# ".env.production -> .env" flow. -# * Explicit: pass a template path and an output path directly. When the two are -# equal the file is filled in place and not deleted. This supports files whose -# name does not follow the "." convention, e.g. -# ASP.NET Core's appsettings.Production.json. +# "" is written. This is the ".env.production -> .env" flow. +# * Explicit: pass a template path and an output path directly. Useful for files +# whose name does not follow the "." convention. +# +# In both modes, pass delete_template: false to keep the template file after writing. +# By default the template is deleted (so e.g. .env.production is not left alongside .env). class Replacer class MissingTokensError < StandardError; end @@ -20,19 +20,19 @@ class MissingTokensError < StandardError; end class << self # Factory from positional command-line args following the sibling convention: # replace (reads .) - def from_args(args) + def from_args(args, delete_template: true) validate_args!(args) environment = args[1] template = file_path(args) output = template.gsub(".#{environment}", "") - new(template, environment, output) + new(template, environment, output, delete_template: delete_template) end # Factory with explicit template/output paths (convention-independent). - def from_paths(template_path, environment, output_path) + def from_paths(template_path, environment, output_path, delete_template: true) raise ArgumentError, "File not found: #{File.expand_path(template_path)}" unless File.exist?(template_path) - new(template_path, environment, output_path) + new(template_path, environment, output_path, delete_template: delete_template) end private @@ -49,10 +49,11 @@ def validate_args!(args) attr_reader :normalized_environment - def initialize(template_path, environment, output_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 @@ -63,9 +64,7 @@ def replace content.gsub!(/(? "abc123"}) do - Replacer.from_paths(template, "production", template).replace + Replacer.from_paths(template, "production", template, delete_template: false).replace assert_equal %({"ClientId":"abc123"}), File.read(template) assert File.exist?(template), "in-place fill must keep the file" end @@ -133,11 +133,34 @@ def test_from_paths_with_distinct_output_deletes_the_template FileUtils.rm(output) if File.exist?(output) end + def test_from_paths_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_paths(template, "production", output, 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_args_keeps_template_when_delete_is_false + with_environment({"NAME" => "Sean"}) do + File.write(@file_path, "NAME={NAME}") + Replacer.from_args([@file_name, @environment], 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_paths_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_paths(template, "production", template).replace + Replacer.from_paths(template, "production", template, delete_template: false).replace assert_equal %({"ClientId":"prod"}), File.read(template) end ensure From cfe293d00459c8f778ac4ab032a3aaefb67bf644 Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Tue, 2 Jun 2026 14:26:46 -0400 Subject: [PATCH 12/17] fix: simplify --- action.yml | 9 ++---- bin/replace | 31 ++++-------------- lib/replacer.rb | 33 ++++--------------- test/replacer_test.rb | 75 +++++++++++++++++-------------------------- 4 files changed, 46 insertions(+), 102 deletions(-) diff --git a/action.yml b/action.yml index 5c51f86..9e34685 100644 --- a/action.yml +++ b/action.yml @@ -8,8 +8,8 @@ inputs: description: 'The environment to replace variables for' required: true env-file-path: - description: 'Convention mode: 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. Optional when template-file-path is set.' - required: false + 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 @@ -46,10 +46,7 @@ runs: - name: Run replacement shell: bash run: | - ARGS=("$ENV_FILE_PATH" "$ENVIRONMENT_NAME") - [ -n "$TEMPLATE_FILE_PATH" ] && ARGS+=("--template" "$TEMPLATE_FILE_PATH") - [ "$DELETE_TEMPLATE" = "false" ] && ARGS+=("--no-delete-template") - "${GITHUB_ACTION_PATH}/bin/replace" "${ARGS[@]}" + "${GITHUB_ACTION_PATH}/bin/replace" env: ENV_FILE_PATH: ${{ inputs.env-file-path }} ENVIRONMENT_NAME: ${{ inputs.environment-name }} diff --git a/bin/replace b/bin/replace index 2064663..72bcb63 100755 --- a/bin/replace +++ b/bin/replace @@ -2,29 +2,12 @@ require "optparse" require_relative "../lib/replacer" -# Invocation: -# replace [--template ] [--no-delete-template] -# -# Convention mode (no --template): -# replace .env staging -# Reads .env.staging, writes .env, deletes the template (unless --no-delete-template). -# -# Explicit template mode: -# replace .env staging --template .env.production -# Reads .env.production, writes .env, deletes the template (unless --no-delete-template). +options = { + environment: ENV.fetch("ENVIRONMENT_NAME"), + output_file: ENV.fetch("ENV_FILE_PATH"), + template_path: ENV["TEMPLATE_FILE_PATH"], + delete_template: ENV.fetch("DELETE_TEMPLATE", "true") == "true" +} -options = { template_path: nil, delete_template: true } -OptionParser.new do |opts| - opts.on("--template PATH") { |p| options[:template_path] = p } - opts.on("--[no-]delete-template") { |v| options[:delete_template] = v } -end.parse! -output_file, environment = ARGV - -if options[:template_path] - Replacer.from_paths(options[:template_path], environment, output_file, - delete_template: options[:delete_template]).replace -else - Replacer.from_args([output_file, environment], - delete_template: options[:delete_template]).replace -end +Replacer.from(**options).replace diff --git a/lib/replacer.rb b/lib/replacer.rb index a5a9c2e..86bc470 100644 --- a/lib/replacer.rb +++ b/lib/replacer.rb @@ -4,35 +4,15 @@ # We will first look for an environment prefixed version of the token, e.g. PRODUCTION_TOKEN_NAME # If that is not found, we will look for the non-environment specific version # If that is not found, we will raise an error -# -# Two ways to point at files: -# * Convention (default): a sibling "." template is read and -# "" is written. This is the ".env.production -> .env" flow. -# * Explicit: pass a template path and an output path directly. Useful for files -# whose name does not follow the "." convention. -# -# In both modes, pass delete_template: false to keep the template file after writing. -# By default the template is deleted (so e.g. .env.production is not left alongside .env). class Replacer class MissingTokensError < StandardError; end class << self - # Factory from positional command-line args following the sibling convention: - # replace (reads .) - def from_args(args, delete_template: true) - validate_args!(args) - environment = args[1] - template = file_path(args) - output = template.gsub(".#{environment}", "") - new(template, environment, output, delete_template: delete_template) - end - - # Factory with explicit template/output paths (convention-independent). - def from_paths(template_path, environment, output_path, delete_template: true) - raise ArgumentError, "File not found: #{File.expand_path(template_path)}" unless File.exist?(template_path) - - new(template_path, environment, output_path, delete_template: delete_template) + 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 @@ -41,9 +21,8 @@ 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 diff --git a/test/replacer_test.rb b/test/replacer_test.rb index 908bd21..fd56e4b 100644 --- a/test/replacer_test.rb +++ b/test/replacer_test.rb @@ -19,47 +19,36 @@ def teardown FileUtils.rm(@file_name) if File.exist?(@file_name) end - def test_it_can_be_constructed_from_args - args = [@file_name, @environment] - replacer = Replacer.from_args(args) + def test_it_can_be_constructed + replacer = Replacer.from(environment: @environment, output_file: @file_name) assert_instance_of Replacer, replacer end - def test_it_fails_if_not_given_2_args - args = [@file_name] - assert_raises(ArgumentError) { Replacer.from_args(args) } - end - def test_it_fails_if_file_does_not_exist - args = ["non_existent_file", "environment"] - assert_raises(ArgumentError) { Replacer.from_args(args) } + assert_raises(ArgumentError) { Replacer.from(environment: "environment", output_file: "non_existent_file") } end def test_it_fails_if_the_environment_doesnt_match - args = [@file_name, "non_existent_environment"] - assert_raises(ArgumentError) { Replacer.from_args(args) } + assert_raises(ArgumentError) { Replacer.from(environment: "non_existent_environment", output_file: @file_name) } end def test_it_fails_if_tokens_are_missing with_environment({"NAME" => "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 +56,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 +64,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 +72,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,19 +85,27 @@ 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_from_paths_fills_a_json_file_in_place + 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_paths(template, "production", template, delete_template: false).replace + 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 @@ -119,12 +113,12 @@ def test_from_paths_fills_a_json_file_in_place FileUtils.rm(template) if File.exist?(template) end - def test_from_paths_with_distinct_output_deletes_the_template + 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_paths(template, "production", output).replace + 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 @@ -133,12 +127,12 @@ def test_from_paths_with_distinct_output_deletes_the_template FileUtils.rm(output) if File.exist?(output) end - def test_from_paths_keeps_template_when_delete_is_false + 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_paths(template, "production", output, delete_template: false).replace + 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 @@ -147,27 +141,18 @@ def test_from_paths_keeps_template_when_delete_is_false FileUtils.rm(output) if File.exist?(output) end - def test_from_args_keeps_template_when_delete_is_false - with_environment({"NAME" => "Sean"}) do - File.write(@file_path, "NAME={NAME}") - Replacer.from_args([@file_name, @environment], 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_paths_prefers_environment_specific_token + 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_paths(template, "production", template, delete_template: false).replace + 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_paths_fails_if_template_missing - assert_raises(ArgumentError) { Replacer.from_paths("nope.json", "production", "nope.json") } + def test_from_fails_if_template_missing + assert_raises(ArgumentError) { Replacer.from(environment: "production", output_file: "nope.json", template_path: "nope.json") } end end From 0244d96dc17271c3ea4313ed7d8524be7902ecdf Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Tue, 2 Jun 2026 14:32:41 -0400 Subject: [PATCH 13/17] fix: add platform for ci --- Gemfile.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Gemfile.lock b/Gemfile.lock index 7850394..4302b29 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -57,6 +57,7 @@ GEM PLATFORMS ruby x86_64-darwin-23 + x86_64-linux DEPENDENCIES minitest From 7a4bc726cbefb6013f6e8306af1cd939bd4f7db2 Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Tue, 2 Jun 2026 14:37:40 -0400 Subject: [PATCH 14/17] chore: lint --- test/replacer_test.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/test/replacer_test.rb b/test/replacer_test.rb index fd56e4b..fdfbdc5 100644 --- a/test/replacer_test.rb +++ b/test/replacer_test.rb @@ -7,6 +7,7 @@ class ReplacerTest < Minitest::Test include EnvironmentHelper + def setup @environment = "staging" @file_name = "test_file" From 0ad6b3e7bc7bbb026ca4f8a92c3cf19a812d55b6 Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Tue, 2 Jun 2026 14:43:10 -0400 Subject: [PATCH 15/17] fix: correct readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 38c1b2c..fd4c971 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ It validates the .env file to ensure that we actually have defined a secret for |---|---|---|---| | `secrets` | Yes | — | JSON glob of all secrets (`${{ toJSON(secrets) }}`). | | `environment-name` | Yes | — | The environment to replace variables for (e.g. `staging`). | -| `env-file-path` | No* | — | Path to the output file. In convention mode, the action reads a sibling template named `.` (e.g. `.env.staging`). Required when `template-file-path` is not set. | +| `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`). Required when `env-file-path` is not set. | | `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"}`). | From 3ca8eed9fd1dd11c868ec2add514cd5853cb5962 Mon Sep 17 00:00:00 2001 From: Sean Dickinson <90267290+sean-dickinson@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:49:35 -0400 Subject: [PATCH 16/17] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fd4c971..cbac19b 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ It validates the .env file to ensure that we actually have defined a secret for | `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`). Required when `env-file-path` is not set. | +| `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"}`). | From c412fca51a4870660cc55efd861ce25780fa082a Mon Sep 17 00:00:00 2001 From: Sean Dickinson Date: Tue, 2 Jun 2026 14:54:50 -0400 Subject: [PATCH 17/17] fix: address empty string issue --- bin/replace | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bin/replace b/bin/replace index 72bcb63..fdf44a6 100755 --- a/bin/replace +++ b/bin/replace @@ -2,10 +2,12 @@ require "optparse" require_relative "../lib/replacer" +def coerce_empty_string_to_nil(value) = value.empty? ? nil : value + options = { environment: ENV.fetch("ENVIRONMENT_NAME"), output_file: ENV.fetch("ENV_FILE_PATH"), - template_path: ENV["TEMPLATE_FILE_PATH"], + template_path: coerce_empty_string_to_nil(ENV["TEMPLATE_FILE_PATH"]), delete_template: ENV.fetch("DELETE_TEMPLATE", "true") == "true" }