From 6b2a85ade57f84c975a3754a343434436e9f3c3b Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Fri, 24 Apr 2026 18:41:59 +0200 Subject: [PATCH 01/69] almost initial commit: add action and some tests --- .github/workflows/test.yml | 45 ++++- Action.java | 323 +++++++++++++++++++++++++++++++++ Dockerfile | 12 ++ README.md | 123 +++++++++++++ action.yml | 54 ++++++ test/resources/test.properties | 24 +++ 6 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 Action.java create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 action.yml create mode 100644 test/resources/test.properties diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8cb65e0..9d3e535 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,12 +3,55 @@ on: pull_request jobs: test: - runs-on: ubuntu + runs-on: ubuntu-latest defaults: run: shell: bash steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: 'test (should fail): invalid input: resultType' + id: error-input-resultType + continue-on-error: true + uses: ./ + with: + file: test/resources/test.properties + resultType: rt1 - name: 'validate "test (should fail): invalid input: resultType"' run: |- [ '${{steps.error-input-resultType.outcome}}' = failure ] + + - name: 'test: query all properties as action outputs' + id: all-output + uses: ./ + with: + file: test/resources/test.properties + - name: 'validate "test: query all properties as action outputs"' + env: + RESULT1: ${{steps.all-output.outputs._sourceJavaVersion}} + RESULT2: ${{steps.all-output.outputs._targetJavaVersion}} + RESULT3: ${{steps.all-output.outputs._org.gradle.jvmargs}} + run: |- + [ "$RESULT1" = 21 ] + [ "$RESULT2" = 17 ] + [ "$RESULT3" = '-ea -showversion' ] + + - name: 'test: query all properties as action outputs' + id: multi-output + uses: ./ + with: + file: test/resources/test.properties + keys: sourceJavaVersion,targetJavaVersion + - name: 'validate "test: query all properties as action outputs"' + env: + RESULT1: ${{steps.multi-output.outputs._sourceJavaVersion}} + RESULT2: ${{steps.multi-output.outputs._targetJavaVersion}} + run: |- + [ "$RESULT1" = 21 ] + [ "$RESULT2" = 17 ] diff --git a/Action.java b/Action.java new file mode 100644 index 0000000..c474166 --- /dev/null +++ b/Action.java @@ -0,0 +1,323 @@ +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.OpenOption; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.AbstractMap; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + + +public class Action { + static void main(String[] args) throws Exception { + Input input = Input.fromEnv(); + ResultWriter resultWriter = ResultWriter.of(input.resultType()); + for (String file : args) { + Properties properties = input.selectedKeys() == null ? Util.readProperties(file) + : Util.selectProperties(Util.readProperties(file), input.selectedKeys(), file); + resultWriter.write(properties, input); + } + } +} + + +record Input(String[] selectedKeys, String keySeparator, String resultTypeWithArg, String resultType, String resultTypeArg, + String resultNameSeparator, String outputPrefix) { + public static Input fromEnv() { + // In order to keep the defaults DRY (in action.yml), the environment variables are all mandatory. + String keySeparator = Util.getRequiredEnv("KEY_SEPARATOR"); + // System.getenv("KEYS") == null if set to empty string?! So this cannot be checked to be set if we want to allow empty + // string: + String keysStr = System.getenv().getOrDefault("KEYS", ""); + String[] keys = Util.splitArray(keysStr, keySeparator, null); + String resultNameSeparator = Util.getRequiredEnv("RESULT_NAME_SEPARATOR"); + + // RESULT_TYPE format: "[:]" + String resultTypeWithArg = Util.getRequiredEnv("RESULT_TYPE"); + Matcher matcher = Pattern.compile("([^:]+)(?::(.*))?").matcher(resultTypeWithArg); + if (!matcher.matches()) { + throw new IllegalArgumentException("invalid resultType: " + resultTypeWithArg); + } + String resultType = matcher.group(1); + String resultTypeArg = Optional.ofNullable(matcher.group(2)).orElse(""); + String outputPrefix = Util.getRequiredEnv("OUTPUT_PREFIX"); + return new Input(keys, keySeparator, resultTypeWithArg, resultType, resultTypeArg, resultNameSeparator, outputPrefix); + } + + public String requiredResultTypeArg() { + String arg = resultTypeArg(); + if ("".equals(arg)) { + throw new IllegalArgumentException("invalid resultType " + resultTypeWithArg() + " (missing argument)"); + } + return arg; + } + + public void requireNoArg() { + if (!"".equals(resultTypeArg())) { + throw new IllegalArgumentException("invalid resultType " + resultTypeWithArg() + " (non-empty argument)"); + } + } +} + + +enum ResultWriter { + OUTPUT("output") { + @Override + public void write(Properties props, Input input) throws IOException { + try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { + for (Map.Entry entry : Util.stringEntries(props)) { + writer.write(input.outputPrefix() + entry.getKey(), entry.getValue()); + } + + if (props.size() == 1) { + String value = Util.stringEntries(props).iterator().next().getValue(); + writer.write("value", value); + } + } + } + }, + OUTPUT_NAMED("output-named") { + @Override + public void write(Properties props, Input input) throws IOException { + writeNamedImpl(props, input, GitHubOutputFile.OUTPUT); + } + }, + ENV_NAMED("env-named") { + @Override + public void write(Properties props, Input input) throws IOException { + writeNamedImpl(props, input, GitHubOutputFile.ENV); + } + }, + ENV("env") { + @Override + public void write(Properties props, Input input) throws IOException { + String prefix = input.resultTypeArg(); + try (GitHubVariableWriter writer = GitHubOutputFile.ENV.open()) { + for (Map.Entry entry : Util.stringEntries(props)) { + writer.write(prefix + entry.getKey(), entry.getValue()); + } + } + } + }, + JSON("json") { + @Override + public void write(Properties props, Input input) throws IOException { + input.requireNoArg(); + try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { + writer.write("json", Util.toJson(props)); + } + } + }, + JSON_FILE("json-file") { + @Override + public void write(Properties props, Input input) throws IOException { + try (Writer writer = Util.openFile(input.requiredResultTypeArg(), StandardOpenOption.CREATE)) { + writer.write(Util.toJson(props)); + writer.write('\n'); + writer.flush(); + } + } + }; + + private final String inputName; + + private ResultWriter(String inputName) { + this.inputName = inputName; + } + + public static ResultWriter of(String name) { + for (ResultWriter rw : ResultWriter.values()) { + if (rw.inputName.equals(name)) { + return rw; + } + } + throw new IllegalArgumentException("invalid resultType: " + name); + } + + public abstract void write(Properties props, Input input) throws IOException; + + private static void writeNamedImpl(Properties props, Input input, GitHubOutputFile gitHubOutputFile) throws IOException { + String[] selectedKeys = input.selectedKeys(); + String[] resultNames = Util.splitArray(input.requiredResultTypeArg(), input.resultNameSeparator(), null); + if (resultNames.length != selectedKeys.length && resultNames.length != 1) { + throw new IllegalArgumentException("resultType " + input.resultTypeWithArg() + " has " + resultNames.length + + " arguments, but " + selectedKeys.length + " keys are selected"); + } + try (GitHubVariableWriter writer = gitHubOutputFile.open()) { + for (int i = 0; i < selectedKeys.length; i++) { + String varName = resultNames[resultNames.length == 1 ? 0 : i]; + String value = props.getProperty(selectedKeys[i]); + writer.write(varName, value); + } + } + } +} + + +enum GitHubOutputFile { + OUTPUT("GITHUB_OUTPUT"), ENV("GITHUB_ENV"); + + private final String fileName; + + private GitHubOutputFile(String fileNameEnvVar) { + this.fileName = Util.getRequiredEnv(fileNameEnvVar); + } + + public GitHubVariableWriter open() throws IOException { + return new GitHubVariableWriter(fileName); + } +} + + +class GitHubVariableWriter implements AutoCloseable { + private static final Pattern SIMPLE_VALUE = Pattern.compile("[\\w.-]+"); + + private final Writer writer; + + public GitHubVariableWriter(String fileName) throws IOException { + this.writer = Util.openFile(fileName, StandardOpenOption.APPEND); + } + + public void write(String key, String value) throws IOException { + // write (very) simple values in format "=": + if (SIMPLE_VALUE.matcher(value).matches()) { + this.writer.write(key); + this.writer.write('='); + this.writer.write(value); + this.writer.write('\n'); + return; + } + + // determine separator line which does not occur in value: + Set valueLines = Set.of(value.split("(?s)\n")); + String separatorPart = "----"; + String separator = separatorPart; + while (valueLines.contains(separator)) { + separator = separator + separatorPart; + } + + // write in multiline format + // [https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#multiline-strings]: + this.writer.write(key); + this.writer.write("<<"); + this.writer.write(separator); + this.writer.write('\n'); + this.writer.write(value); + this.writer.write('\n'); + this.writer.write(separator); + this.writer.write('\n'); + } + + @Override + public void close() throws IOException { + this.writer.close(); + } +} + + +class Util { + private Util() {} + + public static String getRequiredEnv(String varName) { + String value = System.getenv(varName); + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException("missing or empty environment variable " + varName); + } + return value; + } + + public static String[] splitArray(String arrayStr, String separator, String[] defaultResults) { + return arrayStr.isEmpty() ? defaultResults : arrayStr.split(Pattern.quote(separator), -1); + } + + public static Writer openFile(String name, OpenOption... options) throws IOException { + return new BufferedWriter(new OutputStreamWriter(Files.newOutputStream(Paths.get(name), options), StandardCharsets.UTF_8)); + } + + public static Properties readProperties(String file) throws IOException { + Properties allProps = new Properties(); + try (InputStream in = Files.newInputStream(Paths.get(file))) { + allProps.load(in); + } + return allProps; + } + + public static Properties selectProperties(Properties allProps, String[] selectedKeys, String file) { + Set selectedKeysSet = new LinkedHashSet<>(List.of(selectedKeys)); + Properties props = new Properties(); + for (Map.Entry entry : stringEntries(allProps)) { + if (selectedKeysSet.contains(entry.getKey())) { + selectedKeysSet.remove(entry.getKey()); + props.setProperty(entry.getKey(), entry.getValue()); + } + } + for (String key : selectedKeysSet) { + System.err.format("Property %s not found in %s%n", key, file); + } + return props; + } + + public static Iterable> stringEntries(Properties props) { + return () -> new Iterator<>() { + private final Iterator keysIter = props.stringPropertyNames().iterator(); + + @Override + public Map.Entry next() { + String key = keysIter.next(); + return new AbstractMap.SimpleImmutableEntry<>(key, props.getProperty(key)); + } + + @Override + public boolean hasNext() { + return keysIter.hasNext(); + } + }; + } + + public static String toJson(Properties props) { + StringBuilder s = new StringBuilder(50).append('{'); + int initialLength = s.length(); + for (Map.Entry entry : stringEntries(props)) { + s.append(s.length() == initialLength ? '"' : ", \""); + appendJsonString(s, entry.getKey()); + s.append("\":\""); + appendJsonString(s, entry.getValue()); + s.append('"'); + } + return s.append('}').toString(); + } + + private static void appendJsonString(StringBuilder buffer, String s) { + for (char c : s.toCharArray()) { + if (c == '"') { + buffer.append('\\').append('"'); + } else if (c == '\t') { + buffer.append('\\').append('t'); + } else if (c == '\n') { + buffer.append('\\').append('n'); + } else if (c == '\r') { + buffer.append('\\').append('r'); + } else if (c == '\f') { + buffer.append('\\').append('f'); + } else if (c == '\b') { + buffer.append('\\').append('b'); + } else if (c < ' ' || Character.isWhitespace(c)) { + buffer.append('\\').append('u').append(String.format("%04X", (int) c)); + } else { + buffer.append(c); + } + } + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4a43212 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM azul/zulu-openjdk:25-latest + +LABEL "com.github.actions.name"="read Java properties" +LABEL "com.github.actions.description"="read Java properties file and return one or more as property values as plain text or JSON" +LABEL "repository"="https://github.com/freenet-actions/read-java-properties" +LABEL "homepage"="https://github.com/freenet-actions" +LABEL "maintainer"="Team MCBS Core " + +WORKDIR /action +COPY *.java . + +ENTRYPOINT ["java", "Action.java"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..5330f0a --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# read-java-properties + +Github Action to read a Java .properties file and output one, multiple, or all properties as plain strings or JSON. + +## Usage example: + +Suppose file gradle.properties contains properties `sourceJavaVersion= 21`, `targetJavaVersion= 17`, and `org.gradle.jvmargs= -ea -showversion`. + +Query all properties as action outputs: +``` +- id: readProp + uses: freenet-actions/read-java-properties@v1 + with: + file: gradle.properties +``` +⇒ `${{steps.readProp.outputs._sourceJavaVersion}}` == `21`, `${{steps.readProp.outputs._targetJavaVersion}}` == `17`, `${{steps.readProp.outputs._org.gradle.jvmargs}}` == `-ea -showversion`. + +Query multiple properties as action outputs: +``` +- id: readProp + uses: freenet-actions/read-java-properties@v1 + with: + file: gradle.properties + keys: sourceJavaVersion,targetJavaVersion +``` +or with another key separator: +``` +- id: readProp + uses: freenet-actions/read-java-properties@v1 + with: + file: gradle.properties + keys: "sourceJavaVersion; targetJavaVersion" + keySeparator: "; " +``` +⇒ `${{steps.readProp.outputs._sourceJavaVersion}}` == `21`, `${{steps.readProp.outputs._targetJavaVersion}}` == `17`, `${{steps.readProp.outputs.value}}` == `17`. + +Query multiple properties as action output of which only one is found: +``` +- id: readProp + uses: freenet-actions/read-java-properties@v1 + with: + file: gradle.properties + keys: foo,targetJavaVersion,bar +``` +⇒ `${{steps.readProp.outputs._targetJavaVersion}}` == `17`, `${{steps.readProp.outputs.value}}` == `17`. + +Query a single property as action output: +``` +- id: readProp + uses: freenet-actions/read-java-properties@v1 + with: + file: gradle.properties + keys: org.gradle.jvmargs +``` +⇒ `${{steps.readProp.outputs.org.gradle.jvmargs}}` == `-ea -showversion`, `${{steps.readProp.outputs.value}}` == `-ea -showversion`. + +Query all properties as environment variables of the same names: +``` +- uses: freenet-actions/read-java-properties@v1 + with: + file: gradle.properties + resultType: env +``` +⇒ Environment variables `sourceJavaVersion` == `21`, `targetJavaVersion` == `17`, `org.gradle.jvmargs` == `-ea -showversion`. + +Query all properties as environment variables with prefix: +``` +- uses: freenet-actions/read-java-properties@v1 + with: + file: gradle.properties + resultType: "env:GRADLE_PROP_" +``` +⇒ Environment variables `GRADLE_PROP_sourceJavaVersion` == `21`, `GRADLE_PROP_targetJavaVersion` == `17`, `GRADLE_PROP_org.gradle.jvmargs` == `-ea -showversion`. + +Query multiple properties as environment variables: +``` +- uses: freenet-actions/read-java-properties@v1 + with: + file: gradle.properties + keys: sourceJavaVersion,targetJavaVersion + resultType: env +``` +⇒ Environment variables `sourceJavaVersion` == `21`, `targetJavaVersion` == `17`. + +Query multiple properties as environment variables of given names: +``` +- uses: freenet-actions/read-java-properties@v1 + with: + file: gradle.properties + keys: sourceJavaVersion,targetJavaVersion + resultType: env-named:SOURCE_JAVA_VERSION,TARGET_JAVA_VERSION +``` +⇒ Environment variables `SOURCE_JAVA_VERSION` == `21`, `TARGET_JAVA_VERSION` == `17`. + +Query multiple (alternative) properties as a single environment variable: +``` +- uses: freenet-actions/read-java-properties@v1 + with: + file: gradle.properties + keys: sourceJavaVersion,targetJavaVersion + resultType: "env-named:JAVA_VERSION" +``` +⇒ Environment variable `JAVA_VERSION` == `17`. + +Query all properties as JSON action output: +``` +- id: readProp + uses: freenet-actions/read-java-properties@v1 + with: + file: gradle.properties + resultType: json +``` +⇒ `${{steps.readProp.outputs.json}}` == `{"sourceJavaVersion": "21", "targetJavaVersion": "17", "org.gradle.jvmargs": "-ea -showversion"}` (or similar; order not guaranteed). + +Query all properties as JSON file: +``` +- id: readProp + uses: freenet-actions/read-java-properties@v1 + with: + file: gradle.properties + resultType: json-file:/tmp/gradle-properties.json +``` +⇒ File /tmp/gradle-properties.json contains `{"sourceJavaVersion": "21", "targetJavaVersion": "17", "org.gradle.jvmargs": "-ea -showversion"}` (or similar; order and formatting not guaranteed). diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..e2a1491 --- /dev/null +++ b/action.yml @@ -0,0 +1,54 @@ +name: Read Java Properties File +author: freenet-actions +description: >- + Read Java properties file and return one or more as property values as plain text or JSON. + +inputs: + file: + description: the properties file name + required: true + keys: + description: |- + Selects the property keys (names). Format: `keySeparator`-separated list. + If not specified or empty, all properties are returned. + required: false + keySeparator: + description: 'The separator string used in `keys`' + required: false + default: ' ' + resultNameSeparator: + description: 'The separator string used in some types of `resultType`' + required: false + default: ' ' + resultType: + description: |- + The result format and target. One of: + - "output": For each property key , set an output named "_". (The leading underscore serves to avoid conflicts with future output names used by this action.) Additionally, set output "value" to the last found value (which is only usefull if `keys` is non-empty, i.e. last given key wins; without keys, the last property in the undefined java.util.Properties iteration order wins). + - "output-named:": is a `resultNameSeparator`-separated list of output names of the same length as (the `keySeparator`-separated list) `keys`. The value for `keys[i]` is set as output `[i]`. In order not to hide any future builtin outputs of this action, it is recommended to prefix each name with an underscore ("_"). In this mode, `keys` is required. + - "output-named:": Special case: A single name is supported even with multiple `keys`. All found values for the keys are set as the same output; the last found key wins. In this mode, `keys` is required. + - "env": For each property key , set an environment variable . + - "env:": For each property key , set an environment variable . + - "env-named:": is a `resultNameSeparator`-separated list of variable names of the same length as (the `keySeparator`-separated list) `keys`. The value for `keys[i]` is set as environment variable `[i]`. In this mode, `keys` is required. + - "env-named:": Special case: A single name is supported even with multiple `keys`. All found values for the keys are set as the same environment variable; the last found key wins. In this mode, `keys` is required. + - "json": Set an action output "json" to all properties as a JSON object, formatted as a single-line string. + - "json-file:": Write a file with name with contents: all properties as a JSON object (formatting not specified). + required: false + default: 'output' + +outputs: + value: + description: The (plain) property value. Only set in certain modes, see input `resultType`. + json: + description: All found properties matching the `keys` input as a single-line JSON object. Only set in certain modes, see input `resultType`. + +runs: + using: docker + image: Dockerfile + args: + - ${{inputs.file}} + env: + KEYS: ${{inputs.keys}} + KEY_SEPARATOR: ${{inputs.keySeparator}} + RESULT_NAME_SEPARATOR: ${{inputs.resultNameSeparator}} + RESULT_TYPE: ${{inputs.resultType}} + OUTPUT_PREFIX: '_' diff --git a/test/resources/test.properties b/test/resources/test.properties new file mode 100644 index 0000000..22a896e --- /dev/null +++ b/test/resources/test.properties @@ -0,0 +1,24 @@ +sourceJavaVersion : 21 +targetJavaVersion : 17 +org.gradle.jvmargs: -ea -showversion + +colon\:in\:Key: c +whitespace_escapes = a\fb\rc\n +uni = a\u00AB,\u00BBc + +# For white box tests: When writing to $GITHUB_OUTPUT or $GITHUB_ENV, "----" is the default delimiter used in this action. +# Whenever the value contains a line "----", another delimiter must be chosen. +dash_1 = - +dash_2 = -- +dash_3 = --- +dash_4 = ---- +dash_5 = ----- +dash_6 = ------ +dash_7 = ------- +dash_8 = -------- +dash_9 = --------- +dash_10 = ---------- +dash_11 = ----------- +dash_12 = ------------ +dash_3_4_1 = ---\n----\n- +dash_3_4_8_1 = ---\n----\n--------\n- From 539e9057ae12928deec36f6a8366fbad674e39a9 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Fri, 24 Apr 2026 18:48:13 +0200 Subject: [PATCH 02/69] encode punctuation chars in output names --- .github/workflows/test.yml | 2 +- Action.java | 33 ++++++++++++++++++++++++++++----- action.yml | 4 ++++ test/resources/test.properties | 4 ++++ 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9d3e535..23c3d17 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,7 +36,7 @@ jobs: env: RESULT1: ${{steps.all-output.outputs._sourceJavaVersion}} RESULT2: ${{steps.all-output.outputs._targetJavaVersion}} - RESULT3: ${{steps.all-output.outputs._org.gradle.jvmargs}} + RESULT3: ${{steps.all-output.outputs._org-002Egradle-002Ejvmargs}} run: |- [ "$RESULT1" = 21 ] [ "$RESULT2" = 17 ] diff --git a/Action.java b/Action.java index c474166..756f1cd 100644 --- a/Action.java +++ b/Action.java @@ -13,9 +13,11 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Properties; import java.util.Set; +import java.util.function.Function; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -167,16 +169,33 @@ private static void writeNamedImpl(Properties props, Input input, GitHubOutputFi enum GitHubOutputFile { - OUTPUT("GITHUB_OUTPUT"), ENV("GITHUB_ENV"); + OUTPUT("GITHUB_OUTPUT", v -> encodeOutputValue(v)), // + ENV("GITHUB_ENV"); private final String fileName; + private final Function keyReplacer; private GitHubOutputFile(String fileNameEnvVar) { + this(fileNameEnvVar, v -> v); + } + + private GitHubOutputFile(String fileNameEnvVar, Function valueReplacer) { this.fileName = Util.getRequiredEnv(fileNameEnvVar); + this.keyReplacer = valueReplacer; } public GitHubVariableWriter open() throws IOException { - return new GitHubVariableWriter(fileName); + return new GitHubVariableWriter(fileName, keyReplacer); + } + + private static String encodeOutputValue(String value) { + StringBuilder result = new StringBuilder(value.length() + 4); + Matcher matcher = Pattern.compile("([\\p{Punct}&&[^_]])").matcher(value); + while (matcher.find()) { + matcher.appendReplacement(result, String.format("-%04X", (int) matcher.group(1).charAt(0))); + } + matcher.appendTail(result); + return result.toString(); } } @@ -184,16 +203,20 @@ public GitHubVariableWriter open() throws IOException { class GitHubVariableWriter implements AutoCloseable { private static final Pattern SIMPLE_VALUE = Pattern.compile("[\\w.-]+"); + private final Function keyReplacer; private final Writer writer; - public GitHubVariableWriter(String fileName) throws IOException { + public GitHubVariableWriter(String fileName, Function valueReplacer) throws IOException { this.writer = Util.openFile(fileName, StandardOpenOption.APPEND); + this.keyReplacer = Objects.requireNonNull(valueReplacer); } public void write(String key, String value) throws IOException { + String encodedKey = keyReplacer.apply(key); + // write (very) simple values in format "=": if (SIMPLE_VALUE.matcher(value).matches()) { - this.writer.write(key); + this.writer.write(encodedKey); this.writer.write('='); this.writer.write(value); this.writer.write('\n'); @@ -210,7 +233,7 @@ public void write(String key, String value) throws IOException { // write in multiline format // [https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#multiline-strings]: - this.writer.write(key); + this.writer.write(encodedKey); this.writer.write("<<"); this.writer.write(separator); this.writer.write('\n'); diff --git a/action.yml b/action.yml index e2a1491..dc2d6d7 100644 --- a/action.yml +++ b/action.yml @@ -32,6 +32,10 @@ inputs: - "env-named:": Special case: A single name is supported even with multiple `keys`. All found values for the keys are set as the same environment variable; the last found key wins. In this mode, `keys` is required. - "json": Set an action output "json" to all properties as a JSON object, formatted as a single-line string. - "json-file:": Write a file with name with contents: all properties as a JSON object (formatting not specified). + + For all output names, punctuation characters except the underscore ("_") are replaced with "-" followed by four hex digits + of its unicode code point. For example, for a property key "a.b-c_d", resultType "output" sets an output with name + "a-002Eb-002Dc_d". required: false default: 'output' diff --git a/test/resources/test.properties b/test/resources/test.properties index 22a896e..9b0b9b0 100644 --- a/test/resources/test.properties +++ b/test/resources/test.properties @@ -3,6 +3,10 @@ targetJavaVersion : 17 org.gradle.jvmargs: -ea -showversion colon\:in\:Key: c +colonInValue: cv: +dash-in-Key: d +dashInValue: d- + whitespace_escapes = a\fb\rc\n uni = a\u00AB,\u00BBc From fc52849a1af9eaa8e5a4320dd8f4623bde303f3c Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Fri, 24 Apr 2026 18:53:11 +0200 Subject: [PATCH 03/69] fix test: default keySeparator is space --- .github/workflows/test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 23c3d17..ad13a22 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,13 +42,13 @@ jobs: [ "$RESULT2" = 17 ] [ "$RESULT3" = '-ea -showversion' ] - - name: 'test: query all properties as action outputs' + - name: 'test: query multiple properties as action outputs' id: multi-output uses: ./ with: file: test/resources/test.properties - keys: sourceJavaVersion,targetJavaVersion - - name: 'validate "test: query all properties as action outputs"' + keys: 'sourceJavaVersion targetJavaVersion' + - name: 'validate "test: query multiple properties as action outputs"' env: RESULT1: ${{steps.multi-output.outputs._sourceJavaVersion}} RESULT2: ${{steps.multi-output.outputs._targetJavaVersion}} From 3b72ca4410eeae095ba6305c079b3daa97f68a2e Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Fri, 24 Apr 2026 19:01:01 +0200 Subject: [PATCH 04/69] add stderr logging --- .github/workflows/test.yml | 20 ++++++++++---------- Action.java | 7 +++++-- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ad13a22..b1d473b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,13 +34,13 @@ jobs: file: test/resources/test.properties - name: 'validate "test: query all properties as action outputs"' env: - RESULT1: ${{steps.all-output.outputs._sourceJavaVersion}} - RESULT2: ${{steps.all-output.outputs._targetJavaVersion}} - RESULT3: ${{steps.all-output.outputs._org-002Egradle-002Ejvmargs}} + RESULT_SJV: ${{steps.all-output.outputs._sourceJavaVersion}} + RESULT_TJV: ${{steps.all-output.outputs._targetJavaVersion}} + RESULT_GRO: ${{steps.all-output.outputs._org-002Egradle-002Ejvmargs}} run: |- - [ "$RESULT1" = 21 ] - [ "$RESULT2" = 17 ] - [ "$RESULT3" = '-ea -showversion' ] + [ "$RESULT_SJV" = 21 ] + [ "$RESULT_TJV" = 17 ] + [ "$RESULT_GRO" = '-ea -showversion' ] - name: 'test: query multiple properties as action outputs' id: multi-output @@ -50,8 +50,8 @@ jobs: keys: 'sourceJavaVersion targetJavaVersion' - name: 'validate "test: query multiple properties as action outputs"' env: - RESULT1: ${{steps.multi-output.outputs._sourceJavaVersion}} - RESULT2: ${{steps.multi-output.outputs._targetJavaVersion}} + RESULT_SJV: ${{steps.multi-output.outputs._sourceJavaVersion}} + RESULT_TJV: ${{steps.multi-output.outputs._targetJavaVersion}} run: |- - [ "$RESULT1" = 21 ] - [ "$RESULT2" = 17 ] + [ "$RESULT_SJV" = 21 ] + [ "$RESULT_TJV" = 17 ] diff --git a/Action.java b/Action.java index 756f1cd..839b99a 100644 --- a/Action.java +++ b/Action.java @@ -185,7 +185,7 @@ private GitHubOutputFile(String fileNameEnvVar, Function valueRe } public GitHubVariableWriter open() throws IOException { - return new GitHubVariableWriter(fileName, keyReplacer); + return new GitHubVariableWriter(this.toString().replaceFirst("^GITHUB_", "").toLowerCase(), fileName, keyReplacer); } private static String encodeOutputValue(String value) { @@ -203,16 +203,19 @@ private static String encodeOutputValue(String value) { class GitHubVariableWriter implements AutoCloseable { private static final Pattern SIMPLE_VALUE = Pattern.compile("[\\w.-]+"); + private final String description; private final Function keyReplacer; private final Writer writer; - public GitHubVariableWriter(String fileName, Function valueReplacer) throws IOException { + public GitHubVariableWriter(String description, String fileName, Function valueReplacer) throws IOException { + this.description = description; this.writer = Util.openFile(fileName, StandardOpenOption.APPEND); this.keyReplacer = Objects.requireNonNull(valueReplacer); } public void write(String key, String value) throws IOException { String encodedKey = keyReplacer.apply(key); + System.err.format("%s %s\t:= \"%s\"\n", description, encodedKey, value); // write (very) simple values in format "=": if (SIMPLE_VALUE.matcher(value).matches()) { From c7a83e73d8f71cfff375a09e31df5f018bbbb157 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Fri, 24 Apr 2026 21:00:57 +0200 Subject: [PATCH 05/69] resultType "json-file": create parent dir --- Action.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Action.java b/Action.java index 839b99a..1cf2cf0 100644 --- a/Action.java +++ b/Action.java @@ -125,7 +125,9 @@ public void write(Properties props, Input input) throws IOException { JSON_FILE("json-file") { @Override public void write(Properties props, Input input) throws IOException { - try (Writer writer = Util.openFile(input.requiredResultTypeArg(), StandardOpenOption.CREATE)) { + String outputFile = input.requiredResultTypeArg(); + Files.createDirectories((Paths.get(outputFile).getParent())); + try (Writer writer = Util.openFile(outputFile, StandardOpenOption.CREATE)) { writer.write(Util.toJson(props)); writer.write('\n'); writer.flush(); From 368470c4ac9f6375d8842449f6e83a49de1d4433 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Fri, 24 Apr 2026 21:03:33 +0200 Subject: [PATCH 06/69] more tests --- .github/workflows/test.yml | 72 ++++++++++++++++++++++++++++++++++ test/resources/test.properties | 2 +- 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b1d473b..48c1ce7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,6 +7,8 @@ jobs: defaults: run: shell: bash + env: + SORT_OBJECT_JQ_EXPR: 'to_entries | sort_by(.key) []' steps: - name: Checkout @@ -55,3 +57,73 @@ jobs: run: |- [ "$RESULT_SJV" = 21 ] [ "$RESULT_TJV" = 17 ] + + - name: 'test: query all properties as environment variables' + id: all-env + uses: ./ + with: + file: test/resources/test.properties + resultType: env + - name: 'validate "test: query all properties as environment variables"' + run: |- + [ "$sourceJavaVersion" = 21 ] + [ "$targetJavaVersion" = 17 ] + [ "$org.gradle.jvmargs" = '-ea -showversion' ] + [ "$whitespace_escapes" = $'a\fb\rc\n' ] + [ "$unicode_escapes" = 'a«,»c' ] + + - name: 'test: query multiple properties as environment variables' + id: multi-env + uses: ./ + with: + file: test/resources/test.properties + keys: 'sourceJavaVersion org.gradle.jvmargs unicode_escapes' + resultType: env + - name: 'validate "test: query multiple properties as environment variables"' + run: |- + [ "$sourceJavaVersion" = 21 ] + [ "$org.gradle.jvmargs" = '-ea -showversion' ] + [ "$unicode_escapes" = 'a«,»c' ] + [ "$targetJavaVersion" = '' ] + [ "$whitespace_escapes" = '' ] + + - name: 'test: query all properties as JSON action output' + id: all-json + uses: ./ + with: + file: test/resources/test.properties + resultType: json + - name: 'validate "test: query all properties as JSON action output"' + env: + RESULT: ${{steps.all-json.outputs.json}} + run: |- + jq <<<"$RESULT" + [ "$(jq --raw-output '.unicode_escapes')" = 'a«,»c' ] + [ "$(jq --raw-output '.["colon:in:Key"]')" = c ] + + - name: 'test: query multiple properties as JSON action output' + id: multiple-json + uses: ./ + with: + file: test/resources/test.properties + keys: 'colon:in:Key unicode_escapes' + resultType: json + - name: 'validate "test: query multiple properties as JSON action output"' + env: + RESULT: ${{steps.multiple-json.outputs.json}} + run: |- + diff --minimal \ + <(jq --null-input --arg col c --arg uni 'a«,»c' '{ "colon:in:Key": $col, "unicode_escapes": $uni }'"| $SORT_OBJECT_JQ_EXPR") \ + <(jq "$SORT_OBJECT_JQ_EXPR" <<<"$RESULT") + + - name: 'test: query multiple properties as JSON file' + uses: ./ + with: + file: test/resources/test.properties + keys: 'colon:in:Key unicode_escapes' + resultType: json-file:tmp/multi.json + - name: 'validate "test: query multiple properties as JSON file"' + run: |- + diff --minimal \ + <(jq --null-input --arg col c --arg uni 'a«,»c' '{ "colon:in:Key": $col, "unicode_escapes": $uni }'"| $SORT_OBJECT_JQ_EXPR") \ + <(jq "$SORT_OBJECT_JQ_EXPR" Date: Fri, 24 Apr 2026 21:25:45 +0200 Subject: [PATCH 07/69] tests: print message on assertion errors --- .github/workflows/test.yml | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 48c1ce7..82961e2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,6 +8,8 @@ jobs: run: shell: bash env: + ASSERT_EQUALS: >- + [ "$1" = "$2" ] || { printf 'assertion error: "%s" != "%s"\n' "$1" "$2" >&2; exit 1; } SORT_OBJECT_JQ_EXPR: 'to_entries | sort_by(.key) []' steps: @@ -27,7 +29,7 @@ jobs: resultType: rt1 - name: 'validate "test (should fail): invalid input: resultType"' run: |- - [ '${{steps.error-input-resultType.outcome}}' = failure ] + bash -c "$ASSERT_EQUALS" -- '${{steps.error-input-resultType.outcome}}' failure - name: 'test: query all properties as action outputs' id: all-output @@ -40,9 +42,9 @@ jobs: RESULT_TJV: ${{steps.all-output.outputs._targetJavaVersion}} RESULT_GRO: ${{steps.all-output.outputs._org-002Egradle-002Ejvmargs}} run: |- - [ "$RESULT_SJV" = 21 ] - [ "$RESULT_TJV" = 17 ] - [ "$RESULT_GRO" = '-ea -showversion' ] + bash -c "$ASSERT_EQUALS" -- "$RESULT_SJV" 21 + bash -c "$ASSERT_EQUALS" -- "$RESULT_TJV" 17 + bash -c "$ASSERT_EQUALS" -- "$RESULT_GRO" '-ea -showversion' - name: 'test: query multiple properties as action outputs' id: multi-output @@ -55,8 +57,8 @@ jobs: RESULT_SJV: ${{steps.multi-output.outputs._sourceJavaVersion}} RESULT_TJV: ${{steps.multi-output.outputs._targetJavaVersion}} run: |- - [ "$RESULT_SJV" = 21 ] - [ "$RESULT_TJV" = 17 ] + bash -c "$ASSERT_EQUALS" "$RESULT_SJV" 21 + bash -c "$ASSERT_EQUALS" "$RESULT_TJV" 17 - name: 'test: query all properties as environment variables' id: all-env @@ -66,11 +68,11 @@ jobs: resultType: env - name: 'validate "test: query all properties as environment variables"' run: |- - [ "$sourceJavaVersion" = 21 ] - [ "$targetJavaVersion" = 17 ] - [ "$org.gradle.jvmargs" = '-ea -showversion' ] - [ "$whitespace_escapes" = $'a\fb\rc\n' ] - [ "$unicode_escapes" = 'a«,»c' ] + bash -c "$ASSERT_EQUALS" -- "$sourceJavaVersion" 21 + bash -c "$ASSERT_EQUALS" -- "$targetJavaVersion" 17 + bash -c "$ASSERT_EQUALS" -- "$org.gradle.jvmargs" '-ea -showversion' + bash -c "$ASSERT_EQUALS" -- "$whitespace_escapes" $'a\fb\rc\n' + bash -c "$ASSERT_EQUALS" -- "$unicode_escapes" 'a«,»c' - name: 'test: query multiple properties as environment variables' id: multi-env @@ -81,11 +83,11 @@ jobs: resultType: env - name: 'validate "test: query multiple properties as environment variables"' run: |- - [ "$sourceJavaVersion" = 21 ] - [ "$org.gradle.jvmargs" = '-ea -showversion' ] - [ "$unicode_escapes" = 'a«,»c' ] - [ "$targetJavaVersion" = '' ] - [ "$whitespace_escapes" = '' ] + bash -c "$ASSERT_EQUALS" -- "$sourceJavaVersion" 21 + bash -c "$ASSERT_EQUALS" -- "$org.gradle.jvmargs" '-ea -showversion' + bash -c "$ASSERT_EQUALS" -- "$unicode_escapes" 'a«,»c' + bash -c "$ASSERT_EQUALS" -- "$targetJavaVersion" '' + bash -c "$ASSERT_EQUALS" -- "$whitespace_escapes" '' - name: 'test: query all properties as JSON action output' id: all-json @@ -98,8 +100,8 @@ jobs: RESULT: ${{steps.all-json.outputs.json}} run: |- jq <<<"$RESULT" - [ "$(jq --raw-output '.unicode_escapes')" = 'a«,»c' ] - [ "$(jq --raw-output '.["colon:in:Key"]')" = c ] + bash -c "$ASSERT_EQUALS" -- "$(jq --raw-output '.unicode_escapes')" 'a«,»c' + bash -c "$ASSERT_EQUALS" -- "$(jq --raw-output '["colon:in:Key"]')" c - name: 'test: query multiple properties as JSON action output' id: multiple-json From 75b8e28d5e4a922e428813736d2970080c709b43 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Fri, 24 Apr 2026 21:30:04 +0200 Subject: [PATCH 08/69] fix test: missing -- in assertion --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 82961e2..511f958 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,8 +57,8 @@ jobs: RESULT_SJV: ${{steps.multi-output.outputs._sourceJavaVersion}} RESULT_TJV: ${{steps.multi-output.outputs._targetJavaVersion}} run: |- - bash -c "$ASSERT_EQUALS" "$RESULT_SJV" 21 - bash -c "$ASSERT_EQUALS" "$RESULT_TJV" 17 + bash -c "$ASSERT_EQUALS" -- "$RESULT_SJV" 21 + bash -c "$ASSERT_EQUALS" -- "$RESULT_TJV" 17 - name: 'test: query all properties as environment variables' id: all-env From bf2c9c11d4e1b16ee051d4bbc70c8f966886df59 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Fri, 24 Apr 2026 21:34:20 +0200 Subject: [PATCH 09/69] fix tests: bash does not support variable names with dot --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 511f958..8b01a98 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -70,7 +70,7 @@ jobs: run: |- bash -c "$ASSERT_EQUALS" -- "$sourceJavaVersion" 21 bash -c "$ASSERT_EQUALS" -- "$targetJavaVersion" 17 - bash -c "$ASSERT_EQUALS" -- "$org.gradle.jvmargs" '-ea -showversion' + bash -c "$ASSERT_EQUALS" -- "$(printenv org.gradle.jvmargs)" '-ea -showversion' bash -c "$ASSERT_EQUALS" -- "$whitespace_escapes" $'a\fb\rc\n' bash -c "$ASSERT_EQUALS" -- "$unicode_escapes" 'a«,»c' @@ -84,7 +84,7 @@ jobs: - name: 'validate "test: query multiple properties as environment variables"' run: |- bash -c "$ASSERT_EQUALS" -- "$sourceJavaVersion" 21 - bash -c "$ASSERT_EQUALS" -- "$org.gradle.jvmargs" '-ea -showversion' + bash -c "$ASSERT_EQUALS" -- "$(printenv org.gradle.jvmargs)" '-ea -showversion' bash -c "$ASSERT_EQUALS" -- "$unicode_escapes" 'a«,»c' bash -c "$ASSERT_EQUALS" -- "$targetJavaVersion" '' bash -c "$ASSERT_EQUALS" -- "$whitespace_escapes" '' From 18deed8618017cf08ff93d9cd8bf70a991726877 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Fri, 24 Apr 2026 21:41:39 +0200 Subject: [PATCH 10/69] fix tests with env variables: isolation --- .github/workflows/test.yml | 79 ++++++++++++++++++++------------------ 1 file changed, 42 insertions(+), 37 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8b01a98..4595fac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,18 +1,17 @@ name: Test on: pull_request +defaults: + run: + shell: bash +env: + ASSERT_EQUALS: >- + [ "$1" = "$2" ] || { printf 'assertion error: "%s" != "%s"\n' "$1" "$2" >&2; exit 1; } + SORT_OBJECT_JQ_EXPR: 'to_entries | sort_by(.key) []' jobs: test: runs-on: ubuntu-latest - defaults: - run: - shell: bash - env: - ASSERT_EQUALS: >- - [ "$1" = "$2" ] || { printf 'assertion error: "%s" != "%s"\n' "$1" "$2" >&2; exit 1; } - SORT_OBJECT_JQ_EXPR: 'to_entries | sort_by(.key) []' steps: - - name: Checkout uses: actions/checkout@v6 with: @@ -60,35 +59,6 @@ jobs: bash -c "$ASSERT_EQUALS" -- "$RESULT_SJV" 21 bash -c "$ASSERT_EQUALS" -- "$RESULT_TJV" 17 - - name: 'test: query all properties as environment variables' - id: all-env - uses: ./ - with: - file: test/resources/test.properties - resultType: env - - name: 'validate "test: query all properties as environment variables"' - run: |- - bash -c "$ASSERT_EQUALS" -- "$sourceJavaVersion" 21 - bash -c "$ASSERT_EQUALS" -- "$targetJavaVersion" 17 - bash -c "$ASSERT_EQUALS" -- "$(printenv org.gradle.jvmargs)" '-ea -showversion' - bash -c "$ASSERT_EQUALS" -- "$whitespace_escapes" $'a\fb\rc\n' - bash -c "$ASSERT_EQUALS" -- "$unicode_escapes" 'a«,»c' - - - name: 'test: query multiple properties as environment variables' - id: multi-env - uses: ./ - with: - file: test/resources/test.properties - keys: 'sourceJavaVersion org.gradle.jvmargs unicode_escapes' - resultType: env - - name: 'validate "test: query multiple properties as environment variables"' - run: |- - bash -c "$ASSERT_EQUALS" -- "$sourceJavaVersion" 21 - bash -c "$ASSERT_EQUALS" -- "$(printenv org.gradle.jvmargs)" '-ea -showversion' - bash -c "$ASSERT_EQUALS" -- "$unicode_escapes" 'a«,»c' - bash -c "$ASSERT_EQUALS" -- "$targetJavaVersion" '' - bash -c "$ASSERT_EQUALS" -- "$whitespace_escapes" '' - - name: 'test: query all properties as JSON action output' id: all-json uses: ./ @@ -129,3 +99,38 @@ jobs: diff --minimal \ <(jq --null-input --arg col c --arg uni 'a«,»c' '{ "colon:in:Key": $col, "unicode_escapes": $uni }'"| $SORT_OBJECT_JQ_EXPR") \ <(jq "$SORT_OBJECT_JQ_EXPR" Date: Fri, 24 Apr 2026 21:45:28 +0200 Subject: [PATCH 11/69] fix tests: missing jq input; missing checkout --- .github/workflows/test.yml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4595fac..4280ded 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -70,8 +70,8 @@ jobs: RESULT: ${{steps.all-json.outputs.json}} run: |- jq <<<"$RESULT" - bash -c "$ASSERT_EQUALS" -- "$(jq --raw-output '.unicode_escapes')" 'a«,»c' - bash -c "$ASSERT_EQUALS" -- "$(jq --raw-output '["colon:in:Key"]')" c + bash -c "$ASSERT_EQUALS" -- "$(jq --raw-output '.unicode_escapes' <<<"$RESULT")" 'a«,»c' + bash -c "$ASSERT_EQUALS" -- "$(jq --raw-output '["colon:in:Key"]' <<<"$RESULT")" c - name: 'test: query multiple properties as JSON action output' id: multiple-json @@ -104,6 +104,13 @@ jobs: test-all-env: runs-on: ubuntu-latest steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + - name: 'test: query all properties as environment variables' uses: ./ with: @@ -120,6 +127,13 @@ jobs: test-multi-env: runs-on: ubuntu-latest steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + - name: 'test: query multiple properties as environment variables' uses: ./ with: From 58dccb172fbfea817a7e58034b589bc85c100b3c Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Fri, 24 Apr 2026 21:47:19 +0200 Subject: [PATCH 12/69] fix test: missing "." in jq expr --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4280ded..e1edfc1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -70,8 +70,8 @@ jobs: RESULT: ${{steps.all-json.outputs.json}} run: |- jq <<<"$RESULT" - bash -c "$ASSERT_EQUALS" -- "$(jq --raw-output '.unicode_escapes' <<<"$RESULT")" 'a«,»c' - bash -c "$ASSERT_EQUALS" -- "$(jq --raw-output '["colon:in:Key"]' <<<"$RESULT")" c + bash -c "$ASSERT_EQUALS" -- "$(jq --raw-output '.unicode_escapes' <<<"$RESULT")" 'a«,»c' + bash -c "$ASSERT_EQUALS" -- "$(jq --raw-output '.["colon:in:Key"]' <<<"$RESULT")" c - name: 'test: query multiple properties as JSON action output' id: multiple-json From 258195a7b42b4e3cd3a51323ad5a3be967c3e4be Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Fri, 24 Apr 2026 21:51:36 +0200 Subject: [PATCH 13/69] stderr logging also for succeeding assertions --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e1edfc1..ee637ab 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,7 +5,7 @@ defaults: shell: bash env: ASSERT_EQUALS: >- - [ "$1" = "$2" ] || { printf 'assertion error: "%s" != "%s"\n' "$1" "$2" >&2; exit 1; } + if [ "$1" = "$2" ]; then printf 'ok: "%s" == "%s"\n' "$1" "$2" >&2; else printf 'assertion error: "%s" != "%s"\n' "$1" "$2" >&2; exit 1; fi SORT_OBJECT_JQ_EXPR: 'to_entries | sort_by(.key) []' jobs: From 85d731b77985230ee4270be90bfc87f49d46e9e6 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Mon, 11 May 2026 17:35:09 +0200 Subject: [PATCH 14/69] YAML cleanup: use anchors for repeated step --- .github/workflows/test.yml | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ee637ab..23e2e33 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,8 @@ jobs: test: runs-on: ubuntu-latest steps: - - name: Checkout + - &checkoutStep + name: Checkout uses: actions/checkout@v6 with: ref: ${{ github.head_ref }} @@ -104,13 +105,7 @@ jobs: test-all-env: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - + - *checkoutStep - name: 'test: query all properties as environment variables' uses: ./ with: @@ -127,13 +122,7 @@ jobs: test-multi-env: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - + - *checkoutStep - name: 'test: query multiple properties as environment variables' uses: ./ with: From bdd177321858ac3f00f3291175e9c4a534016441 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Mon, 11 May 2026 18:30:28 +0200 Subject: [PATCH 15/69] test workflow: simplify bash code reuse --- .github/workflows/test.yml | 66 ++++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 23e2e33..da1c159 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,9 +4,16 @@ defaults: run: shell: bash env: - ASSERT_EQUALS: >- - if [ "$1" = "$2" ]; then printf 'ok: "%s" == "%s"\n' "$1" "$2" >&2; else printf 'assertion error: "%s" != "%s"\n' "$1" "$2" >&2; exit 1; fi - SORT_OBJECT_JQ_EXPR: 'to_entries | sort_by(.key) []' + SHELL_SETUP: >- + function assertEquals() { + if [ "$1" = "$2" ]; then + printf 'ok: "%s" == "%s"\n' "$1" "$2" >&2 + else + printf 'assertion error: "%s" != "%s"\n' "$1" "$2" >&2 + return 1 + fi + } + sortObjectJqExpr='to_entries | sort_by(.key) []' jobs: test: @@ -29,7 +36,8 @@ jobs: resultType: rt1 - name: 'validate "test (should fail): invalid input: resultType"' run: |- - bash -c "$ASSERT_EQUALS" -- '${{steps.error-input-resultType.outcome}}' failure + eval "$SHELL_SETUP" + assertEquals '${{steps.error-input-resultType.outcome}}' failure - name: 'test: query all properties as action outputs' id: all-output @@ -42,9 +50,10 @@ jobs: RESULT_TJV: ${{steps.all-output.outputs._targetJavaVersion}} RESULT_GRO: ${{steps.all-output.outputs._org-002Egradle-002Ejvmargs}} run: |- - bash -c "$ASSERT_EQUALS" -- "$RESULT_SJV" 21 - bash -c "$ASSERT_EQUALS" -- "$RESULT_TJV" 17 - bash -c "$ASSERT_EQUALS" -- "$RESULT_GRO" '-ea -showversion' + eval "$SHELL_SETUP" + assertEquals "$RESULT_SJV" 21 + assertEquals "$RESULT_TJV" 17 + assertEquals "$RESULT_GRO" '-ea -showversion' - name: 'test: query multiple properties as action outputs' id: multi-output @@ -57,8 +66,9 @@ jobs: RESULT_SJV: ${{steps.multi-output.outputs._sourceJavaVersion}} RESULT_TJV: ${{steps.multi-output.outputs._targetJavaVersion}} run: |- - bash -c "$ASSERT_EQUALS" -- "$RESULT_SJV" 21 - bash -c "$ASSERT_EQUALS" -- "$RESULT_TJV" 17 + eval "$SHELL_SETUP" + assertEquals "$RESULT_SJV" 21 + assertEquals "$RESULT_TJV" 17 - name: 'test: query all properties as JSON action output' id: all-json @@ -70,9 +80,10 @@ jobs: env: RESULT: ${{steps.all-json.outputs.json}} run: |- - jq <<<"$RESULT" - bash -c "$ASSERT_EQUALS" -- "$(jq --raw-output '.unicode_escapes' <<<"$RESULT")" 'a«,»c' - bash -c "$ASSERT_EQUALS" -- "$(jq --raw-output '.["colon:in:Key"]' <<<"$RESULT")" c + eval "$SHELL_SETUP" + jq <<<"$RESULT" # assert parseable (and print) + assertEquals "$(jq --raw-output '.unicode_escapes' <<<"$RESULT")" 'a«,»c' + assertEquals "$(jq --raw-output '.["colon:in:Key"]' <<<"$RESULT")" c - name: 'test: query multiple properties as JSON action output' id: multiple-json @@ -86,8 +97,8 @@ jobs: RESULT: ${{steps.multiple-json.outputs.json}} run: |- diff --minimal \ - <(jq --null-input --arg col c --arg uni 'a«,»c' '{ "colon:in:Key": $col, "unicode_escapes": $uni }'"| $SORT_OBJECT_JQ_EXPR") \ - <(jq "$SORT_OBJECT_JQ_EXPR" <<<"$RESULT") + <(jq --null-input --arg col c --arg uni 'a«,»c' '{ "colon:in:Key": $col, "unicode_escapes": $uni }'"| $sortObjectJqExpr") \ + <(jq "$sortObjectJqExpr" <<<"$RESULT") - name: 'test: query multiple properties as JSON file' uses: ./ @@ -98,8 +109,8 @@ jobs: - name: 'validate "test: query multiple properties as JSON file"' run: |- diff --minimal \ - <(jq --null-input --arg col c --arg uni 'a«,»c' '{ "colon:in:Key": $col, "unicode_escapes": $uni }'"| $SORT_OBJECT_JQ_EXPR") \ - <(jq "$SORT_OBJECT_JQ_EXPR" Date: Mon, 11 May 2026 18:36:05 +0200 Subject: [PATCH 16/69] test workflow: fix missing semicolon in eval'ed string --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index da1c159..2934c6d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,8 +12,8 @@ env: printf 'assertion error: "%s" != "%s"\n' "$1" "$2" >&2 return 1 fi - } - sortObjectJqExpr='to_entries | sort_by(.key) []' + }; + sortObjectJqExpr='to_entries | sort_by(.key) []'; jobs: test: From 1ad8d8e045a8e31754b946b0e3f51ca3fbeb5b09 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Mon, 11 May 2026 18:38:25 +0200 Subject: [PATCH 17/69] test workflow: make SHELL_SETUP variable less error-prone --- .github/workflows/test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2934c6d..d367996 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,7 +4,7 @@ defaults: run: shell: bash env: - SHELL_SETUP: >- + SHELL_SETUP: |- function assertEquals() { if [ "$1" = "$2" ]; then printf 'ok: "%s" == "%s"\n' "$1" "$2" >&2 @@ -12,8 +12,8 @@ env: printf 'assertion error: "%s" != "%s"\n' "$1" "$2" >&2 return 1 fi - }; - sortObjectJqExpr='to_entries | sort_by(.key) []'; + } + sortObjectJqExpr='to_entries | sort_by(.key) []' jobs: test: From be44bf35ba24fc8f914bf5f824174c3ab29d5689 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Mon, 11 May 2026 19:07:46 +0200 Subject: [PATCH 18/69] test workflow: fix missing $sortObjectJqExpr --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d367996..7afdc80 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -96,6 +96,7 @@ jobs: env: RESULT: ${{steps.multiple-json.outputs.json}} run: |- + eval "$SHELL_SETUP" diff --minimal \ <(jq --null-input --arg col c --arg uni 'a«,»c' '{ "colon:in:Key": $col, "unicode_escapes": $uni }'"| $sortObjectJqExpr") \ <(jq "$sortObjectJqExpr" <<<"$RESULT") @@ -108,6 +109,7 @@ jobs: resultType: json-file:tmp/multi.json - name: 'validate "test: query multiple properties as JSON file"' run: |- + eval "$SHELL_SETUP" diff --minimal \ <(jq --null-input --arg col c --arg uni 'a«,»c' '{ "colon:in:Key": $col, "unicode_escapes": $uni }'"| $sortObjectJqExpr") \ <(jq "$sortObjectJqExpr" Date: Mon, 11 May 2026 19:10:48 +0200 Subject: [PATCH 19/69] test workflow: simplify assertions One value per assertion instead of sorted JSON diff. Plus: add JSON size assertions. --- .github/workflows/test.yml | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7afdc80..c0134ac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,6 @@ env: return 1 fi } - sortObjectJqExpr='to_entries | sort_by(.key) []' jobs: test: @@ -82,24 +81,29 @@ jobs: run: |- eval "$SHELL_SETUP" jq <<<"$RESULT" # assert parseable (and print) + # check some values: assertEquals "$(jq --raw-output '.unicode_escapes' <<<"$RESULT")" 'a«,»c' - assertEquals "$(jq --raw-output '.["colon:in:Key"]' <<<"$RESULT")" c + assertEquals "$(jq --raw-output '.["colon:in:Key"]' <<<"$RESULT")" 'c' + # assert size >= 10: + test "$(jq --raw-output 'keys | length' <<<"$RESULT")" -ge 10 - name: 'test: query multiple properties as JSON action output' id: multiple-json uses: ./ with: file: test/resources/test.properties - keys: 'colon:in:Key unicode_escapes' + keys: 'colon:in:Key colonInValue unicode_escapes' resultType: json - name: 'validate "test: query multiple properties as JSON action output"' env: RESULT: ${{steps.multiple-json.outputs.json}} run: |- eval "$SHELL_SETUP" - diff --minimal \ - <(jq --null-input --arg col c --arg uni 'a«,»c' '{ "colon:in:Key": $col, "unicode_escapes": $uni }'"| $sortObjectJqExpr") \ - <(jq "$sortObjectJqExpr" <<<"$RESULT") + assertEquals "$(jq --raw-output '.unicode_escapes' <<<"$RESULT")" 'a«,»c' + assertEquals "$(jq --raw-output '.colonInValue' <<<"$RESULT")" 'cv:' + assertEquals "$(jq --raw-output '.["colon:in:Key"]' <<<"$RESULT")" 'c' + # assert size 3 (i.e. no additional results): + assertEquals "$(jq --raw-output 'keys | length' <<<"$RESULT")" 3 - name: 'test: query multiple properties as JSON file' uses: ./ @@ -110,9 +114,11 @@ jobs: - name: 'validate "test: query multiple properties as JSON file"' run: |- eval "$SHELL_SETUP" - diff --minimal \ - <(jq --null-input --arg col c --arg uni 'a«,»c' '{ "colon:in:Key": $col, "unicode_escapes": $uni }'"| $sortObjectJqExpr") \ - <(jq "$sortObjectJqExpr" Date: Mon, 11 May 2026 19:25:44 +0200 Subject: [PATCH 20/69] test workflow: fix `keys` input for new assertion --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c0134ac..59ad5e1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -109,7 +109,7 @@ jobs: uses: ./ with: file: test/resources/test.properties - keys: 'colon:in:Key unicode_escapes' + keys: 'colon:in:Key colonInValue unicode_escapes' resultType: json-file:tmp/multi.json - name: 'validate "test: query multiple properties as JSON file"' run: |- From cc78e68dd81e27a23e0b43275783a12621da2cb4 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Mon, 11 May 2026 19:26:07 +0200 Subject: [PATCH 21/69] test workflow: DRY getting of value from JSON; optimize result file reading --- .github/workflows/test.yml | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 59ad5e1..dd6c0be 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,10 @@ env: return 1 fi } + function jsonGet() { + local key=$1; shift + jq --arg k "$key" --raw-output '.[$k] // ""' + } jobs: test: @@ -82,8 +86,8 @@ jobs: eval "$SHELL_SETUP" jq <<<"$RESULT" # assert parseable (and print) # check some values: - assertEquals "$(jq --raw-output '.unicode_escapes' <<<"$RESULT")" 'a«,»c' - assertEquals "$(jq --raw-output '.["colon:in:Key"]' <<<"$RESULT")" 'c' + assertEquals "$(jsonGet 'unicode_escapes' <<<"$RESULT")" 'a«,»c' + assertEquals "$(jsonGet 'colon:in:Key' <<<"$RESULT")" 'c' # assert size >= 10: test "$(jq --raw-output 'keys | length' <<<"$RESULT")" -ge 10 @@ -99,9 +103,9 @@ jobs: RESULT: ${{steps.multiple-json.outputs.json}} run: |- eval "$SHELL_SETUP" - assertEquals "$(jq --raw-output '.unicode_escapes' <<<"$RESULT")" 'a«,»c' - assertEquals "$(jq --raw-output '.colonInValue' <<<"$RESULT")" 'cv:' - assertEquals "$(jq --raw-output '.["colon:in:Key"]' <<<"$RESULT")" 'c' + assertEquals "$(jsonGet 'unicode_escapes' <<<"$RESULT")" 'a«,»c' + assertEquals "$(jsonGet 'colonInValue' <<<"$RESULT")" 'cv:' + assertEquals "$(jsonGet 'colon:in:Key' <<<"$RESULT")" 'c' # assert size 3 (i.e. no additional results): assertEquals "$(jq --raw-output 'keys | length' <<<"$RESULT")" 3 @@ -114,11 +118,12 @@ jobs: - name: 'validate "test: query multiple properties as JSON file"' run: |- eval "$SHELL_SETUP" - assertEquals "$(jq --raw-output '.unicode_escapes' Date: Wed, 13 May 2026 12:22:29 +0200 Subject: [PATCH 22/69] fix missing output name prefix "_" (depending on resultType) --- Action.java | 116 +++++++++++++++++++++++++++------------------------- 1 file changed, 60 insertions(+), 56 deletions(-) diff --git a/Action.java b/Action.java index 1cf2cf0..475374e 100644 --- a/Action.java +++ b/Action.java @@ -24,20 +24,20 @@ public class Action { static void main(String[] args) throws Exception { - Input input = Input.fromEnv(); - ResultWriter resultWriter = ResultWriter.of(input.resultType()); + Config config = Config.fromEnv(); + ResultWriter resultWriter = ResultWriter.of(config.resultType()); for (String file : args) { - Properties properties = input.selectedKeys() == null ? Util.readProperties(file) - : Util.selectProperties(Util.readProperties(file), input.selectedKeys(), file); - resultWriter.write(properties, input); + Properties properties = config.selectedKeys() == null ? Util.readProperties(file) + : Util.selectProperties(Util.readProperties(file), config.selectedKeys(), file); + resultWriter.write(properties, config); } } } -record Input(String[] selectedKeys, String keySeparator, String resultTypeWithArg, String resultType, String resultTypeArg, +record Config(String[] selectedKeys, String keySeparator, String resultTypeWithArg, String resultType, String resultTypeArg, String resultNameSeparator, String outputPrefix) { - public static Input fromEnv() { + public static Config fromEnv() { // In order to keep the defaults DRY (in action.yml), the environment variables are all mandatory. String keySeparator = Util.getRequiredEnv("KEY_SEPARATOR"); // System.getenv("KEYS") == null if set to empty string?! So this cannot be checked to be set if we want to allow empty @@ -55,7 +55,7 @@ public static Input fromEnv() { String resultType = matcher.group(1); String resultTypeArg = Optional.ofNullable(matcher.group(2)).orElse(""); String outputPrefix = Util.getRequiredEnv("OUTPUT_PREFIX"); - return new Input(keys, keySeparator, resultTypeWithArg, resultType, resultTypeArg, resultNameSeparator, outputPrefix); + return new Config(keys, keySeparator, resultTypeWithArg, resultType, resultTypeArg, resultNameSeparator, outputPrefix); } public String requiredResultTypeArg() { @@ -77,10 +77,10 @@ public void requireNoArg() { enum ResultWriter { OUTPUT("output") { @Override - public void write(Properties props, Input input) throws IOException { - try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { + public void write(Properties props, Config config) throws IOException { + try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open(config)) { for (Map.Entry entry : Util.stringEntries(props)) { - writer.write(input.outputPrefix() + entry.getKey(), entry.getValue()); + writer.write(entry.getKey(), entry.getValue()); } if (props.size() == 1) { @@ -92,21 +92,21 @@ public void write(Properties props, Input input) throws IOException { }, OUTPUT_NAMED("output-named") { @Override - public void write(Properties props, Input input) throws IOException { - writeNamedImpl(props, input, GitHubOutputFile.OUTPUT); + public void write(Properties props, Config config) throws IOException { + writeNamedImpl(props, config, GitHubOutputFile.OUTPUT); } }, ENV_NAMED("env-named") { @Override - public void write(Properties props, Input input) throws IOException { - writeNamedImpl(props, input, GitHubOutputFile.ENV); + public void write(Properties props, Config config) throws IOException { + writeNamedImpl(props, config, GitHubOutputFile.ENV); } }, ENV("env") { @Override - public void write(Properties props, Input input) throws IOException { - String prefix = input.resultTypeArg(); - try (GitHubVariableWriter writer = GitHubOutputFile.ENV.open()) { + public void write(Properties props, Config config) throws IOException { + String prefix = config.resultTypeArg(); + try (GitHubVariableWriter writer = GitHubOutputFile.ENV.open(config)) { for (Map.Entry entry : Util.stringEntries(props)) { writer.write(prefix + entry.getKey(), entry.getValue()); } @@ -115,17 +115,17 @@ public void write(Properties props, Input input) throws IOException { }, JSON("json") { @Override - public void write(Properties props, Input input) throws IOException { - input.requireNoArg(); - try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { + public void write(Properties props, Config config) throws IOException { + config.requireNoArg(); + try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open(config)) { writer.write("json", Util.toJson(props)); } } }, JSON_FILE("json-file") { @Override - public void write(Properties props, Input input) throws IOException { - String outputFile = input.requiredResultTypeArg(); + public void write(Properties props, Config config) throws IOException { + String outputFile = config.requiredResultTypeArg(); Files.createDirectories((Paths.get(outputFile).getParent())); try (Writer writer = Util.openFile(outputFile, StandardOpenOption.CREATE)) { writer.write(Util.toJson(props)); @@ -141,25 +141,25 @@ private ResultWriter(String inputName) { this.inputName = inputName; } - public static ResultWriter of(String name) { + public static ResultWriter of(String inputName) { for (ResultWriter rw : ResultWriter.values()) { - if (rw.inputName.equals(name)) { + if (rw.inputName.equals(inputName)) { return rw; } } - throw new IllegalArgumentException("invalid resultType: " + name); + throw new IllegalArgumentException("invalid result type: " + inputName); } - public abstract void write(Properties props, Input input) throws IOException; + public abstract void write(Properties props, Config config) throws IOException; - private static void writeNamedImpl(Properties props, Input input, GitHubOutputFile gitHubOutputFile) throws IOException { - String[] selectedKeys = input.selectedKeys(); - String[] resultNames = Util.splitArray(input.requiredResultTypeArg(), input.resultNameSeparator(), null); + private static void writeNamedImpl(Properties props, Config config, GitHubOutputFile gitHubOutputFile) throws IOException { + String[] selectedKeys = config.selectedKeys(); + String[] resultNames = Util.splitArray(config.requiredResultTypeArg(), config.resultNameSeparator(), null); if (resultNames.length != selectedKeys.length && resultNames.length != 1) { - throw new IllegalArgumentException("resultType " + input.resultTypeWithArg() + " has " + resultNames.length + throw new IllegalArgumentException("resultType " + config.resultTypeWithArg() + " has " + resultNames.length + " arguments, but " + selectedKeys.length + " keys are selected"); } - try (GitHubVariableWriter writer = gitHubOutputFile.open()) { + try (GitHubVariableWriter writer = gitHubOutputFile.open(config)) { for (int i = 0; i < selectedKeys.length; i++) { String varName = resultNames[resultNames.length == 1 ? 0 : i]; String value = props.getProperty(selectedKeys[i]); @@ -171,34 +171,38 @@ private static void writeNamedImpl(Properties props, Input input, GitHubOutputFi enum GitHubOutputFile { - OUTPUT("GITHUB_OUTPUT", v -> encodeOutputValue(v)), // - ENV("GITHUB_ENV"); + OUTPUT("GITHUB_OUTPUT") { + @Override + public GitHubVariableWriter open(Config config) throws IOException { + return new GitHubVariableWriter(this.toString().replaceFirst("^GITHUB_", "").toLowerCase(), fileName, + k -> encodeKey(config, k)); + } - private final String fileName; - private final Function keyReplacer; + private static String encodeKey(Config config, String key) { + StringBuilder result = new StringBuilder(key.length() + config.outputPrefix().length() + 4); + result.append(config.outputPrefix()); + Matcher matcher = Pattern.compile("([\\p{Punct}&&[^_]])").matcher(key); + while (matcher.find()) { + matcher.appendReplacement(result, String.format("-%04X", (int) matcher.group(1).charAt(0))); + } + matcher.appendTail(result); + return result.toString(); + } + }, + ENV("GITHUB_ENV") { + @Override + public GitHubVariableWriter open(Config config) throws IOException { + return new GitHubVariableWriter(this.toString().replaceFirst("^GITHUB_", "").toLowerCase(), fileName, k -> k); + } + }; - private GitHubOutputFile(String fileNameEnvVar) { - this(fileNameEnvVar, v -> v); - } + final String fileName; - private GitHubOutputFile(String fileNameEnvVar, Function valueReplacer) { + private GitHubOutputFile(String fileNameEnvVar) { this.fileName = Util.getRequiredEnv(fileNameEnvVar); - this.keyReplacer = valueReplacer; } - public GitHubVariableWriter open() throws IOException { - return new GitHubVariableWriter(this.toString().replaceFirst("^GITHUB_", "").toLowerCase(), fileName, keyReplacer); - } - - private static String encodeOutputValue(String value) { - StringBuilder result = new StringBuilder(value.length() + 4); - Matcher matcher = Pattern.compile("([\\p{Punct}&&[^_]])").matcher(value); - while (matcher.find()) { - matcher.appendReplacement(result, String.format("-%04X", (int) matcher.group(1).charAt(0))); - } - matcher.appendTail(result); - return result.toString(); - } + public abstract GitHubVariableWriter open(Config config) throws IOException; } @@ -209,10 +213,10 @@ class GitHubVariableWriter implements AutoCloseable { private final Function keyReplacer; private final Writer writer; - public GitHubVariableWriter(String description, String fileName, Function valueReplacer) throws IOException { + public GitHubVariableWriter(String description, String fileName, Function keyReplacer) throws IOException { this.description = description; this.writer = Util.openFile(fileName, StandardOpenOption.APPEND); - this.keyReplacer = Objects.requireNonNull(valueReplacer); + this.keyReplacer = Objects.requireNonNull(keyReplacer); } public void write(String key, String value) throws IOException { From e4ec807089ef78ec102ef947001d6cf837c781f6 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Wed, 13 May 2026 15:56:54 +0200 Subject: [PATCH 23/69] encode & prefix only generated, not given output names --- Action.java | 88 +++++++++++++++++++++++++---------------------------- action.yml | 11 ++++--- 2 files changed, 48 insertions(+), 51 deletions(-) diff --git a/Action.java b/Action.java index 475374e..9429838 100644 --- a/Action.java +++ b/Action.java @@ -13,11 +13,9 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.Optional; import java.util.Properties; import java.util.Set; -import java.util.function.Function; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -78,17 +76,31 @@ enum ResultWriter { OUTPUT("output") { @Override public void write(Properties props, Config config) throws IOException { - try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open(config)) { + String lastValue = null; + try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { for (Map.Entry entry : Util.stringEntries(props)) { - writer.write(entry.getKey(), entry.getValue()); + String key = encodeKey(config, config.outputPrefix() + entry.getKey()); + lastValue = entry.getValue(); + writer.write(key, lastValue); } - if (props.size() == 1) { - String value = Util.stringEntries(props).iterator().next().getValue(); - writer.write("value", value); + // TODO props must be in order of config.selectedKeys() + // Otherwise, this is arbitrary: + if (lastValue != null) { + writer.write("value", lastValue); } } } + + private static String encodeKey(Config config, String key) { + StringBuilder result = new StringBuilder(key.length() + config.outputPrefix().length() + 4); + Matcher matcher = Pattern.compile("([\\p{Punct}&&[^_]])").matcher(key); + while (matcher.find()) { + matcher.appendReplacement(result, String.format("-%04X", (int) matcher.group(1).charAt(0))); + } + matcher.appendTail(result); + return result.toString(); + } }, OUTPUT_NAMED("output-named") { @Override @@ -106,7 +118,7 @@ public void write(Properties props, Config config) throws IOException { @Override public void write(Properties props, Config config) throws IOException { String prefix = config.resultTypeArg(); - try (GitHubVariableWriter writer = GitHubOutputFile.ENV.open(config)) { + try (GitHubVariableWriter writer = GitHubOutputFile.ENV.open()) { for (Map.Entry entry : Util.stringEntries(props)) { writer.write(prefix + entry.getKey(), entry.getValue()); } @@ -117,7 +129,7 @@ public void write(Properties props, Config config) throws IOException { @Override public void write(Properties props, Config config) throws IOException { config.requireNoArg(); - try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open(config)) { + try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { writer.write("json", Util.toJson(props)); } } @@ -159,11 +171,11 @@ private static void writeNamedImpl(Properties props, Config config, GitHubOutput throw new IllegalArgumentException("resultType " + config.resultTypeWithArg() + " has " + resultNames.length + " arguments, but " + selectedKeys.length + " keys are selected"); } - try (GitHubVariableWriter writer = gitHubOutputFile.open(config)) { + try (GitHubVariableWriter writer = gitHubOutputFile.open()) { for (int i = 0; i < selectedKeys.length; i++) { - String varName = resultNames[resultNames.length == 1 ? 0 : i]; + String name = resultNames[resultNames.length == 1 ? 0 : i]; String value = props.getProperty(selectedKeys[i]); - writer.write(varName, value); + writer.write(name, value); } } } @@ -171,30 +183,8 @@ private static void writeNamedImpl(Properties props, Config config, GitHubOutput enum GitHubOutputFile { - OUTPUT("GITHUB_OUTPUT") { - @Override - public GitHubVariableWriter open(Config config) throws IOException { - return new GitHubVariableWriter(this.toString().replaceFirst("^GITHUB_", "").toLowerCase(), fileName, - k -> encodeKey(config, k)); - } - - private static String encodeKey(Config config, String key) { - StringBuilder result = new StringBuilder(key.length() + config.outputPrefix().length() + 4); - result.append(config.outputPrefix()); - Matcher matcher = Pattern.compile("([\\p{Punct}&&[^_]])").matcher(key); - while (matcher.find()) { - matcher.appendReplacement(result, String.format("-%04X", (int) matcher.group(1).charAt(0))); - } - matcher.appendTail(result); - return result.toString(); - } - }, - ENV("GITHUB_ENV") { - @Override - public GitHubVariableWriter open(Config config) throws IOException { - return new GitHubVariableWriter(this.toString().replaceFirst("^GITHUB_", "").toLowerCase(), fileName, k -> k); - } - }; + OUTPUT("GITHUB_OUTPUT"), // + ENV("GITHUB_ENV"); final String fileName; @@ -202,7 +192,9 @@ private GitHubOutputFile(String fileNameEnvVar) { this.fileName = Util.getRequiredEnv(fileNameEnvVar); } - public abstract GitHubVariableWriter open(Config config) throws IOException; + public GitHubVariableWriter open() throws IOException { + return new GitHubVariableWriter(this.toString().replaceFirst("^GITHUB_", "").toLowerCase(), fileName); + } } @@ -210,28 +202,34 @@ class GitHubVariableWriter implements AutoCloseable { private static final Pattern SIMPLE_VALUE = Pattern.compile("[\\w.-]+"); private final String description; - private final Function keyReplacer; private final Writer writer; - public GitHubVariableWriter(String description, String fileName, Function keyReplacer) throws IOException { + public GitHubVariableWriter(String description, String fileName) throws IOException { this.description = description; this.writer = Util.openFile(fileName, StandardOpenOption.APPEND); - this.keyReplacer = Objects.requireNonNull(keyReplacer); } public void write(String key, String value) throws IOException { - String encodedKey = keyReplacer.apply(key); - System.err.format("%s %s\t:= \"%s\"\n", description, encodedKey, value); + System.err.format("%s %s\t:= \"%s\"\n", description, key, value); // write (very) simple values in format "=": if (SIMPLE_VALUE.matcher(value).matches()) { - this.writer.write(encodedKey); + this.writer.write(key); this.writer.write('='); this.writer.write(value); this.writer.write('\n'); return; + } else { + writeMultiLine(key, value); } + } + /** + * Writes a key-value pair in + * multiline + * format. + */ + private void writeMultiLine(String key, String value) throws IOException { // determine separator line which does not occur in value: Set valueLines = Set.of(value.split("(?s)\n")); String separatorPart = "----"; @@ -240,9 +238,7 @@ public void write(String key, String value) throws IOException { separator = separator + separatorPart; } - // write in multiline format - // [https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#multiline-strings]: - this.writer.write(encodedKey); + this.writer.write(key); this.writer.write("<<"); this.writer.write(separator); this.writer.write('\n'); diff --git a/action.yml b/action.yml index dc2d6d7..6b530a1 100644 --- a/action.yml +++ b/action.yml @@ -23,8 +23,12 @@ inputs: resultType: description: |- The result format and target. One of: - - "output": For each property key , set an output named "_". (The leading underscore serves to avoid conflicts with future output names used by this action.) Additionally, set output "value" to the last found value (which is only usefull if `keys` is non-empty, i.e. last given key wins; without keys, the last property in the undefined java.util.Properties iteration order wins). - - "output-named:": is a `resultNameSeparator`-separated list of output names of the same length as (the `keySeparator`-separated list) `keys`. The value for `keys[i]` is set as output `[i]`. In order not to hide any future builtin outputs of this action, it is recommended to prefix each name with an underscore ("_"). In this mode, `keys` is required. + - "output": For each property key , set an output named "_", but with special characters encoded. (The leading underscore serves to avoid conflicts with future output names used by this action.) + Encoding replaces all punctuation or whitespace characters except the underscore ("_") with "-" followed by four hex digits + of its unicode code point. For example, for a property key "a.b-c_d", resultType "output" sets an output with name + "a-002Eb-002Dc_d". + Additionally, set output "value" to the last found value, if any (which is only usefull if `keys` is non-empty, i.e. last given key wins; without keys, the last property in the undefined java.util.Properties iteration order wins). + - "output-named:": is a `resultNameSeparator`-separated list of output names of the same length as (the `keySeparator`-separated list) `keys`. The value for `keys[i]` is set as output `[i]`. In order not to hide any future builtin outputs of this action, it is recommended to prefix each name with an underscore ("_"). In this mode, `keys` is required. Unlike "output", names are taken as-is. (This is because no official output naming rules seem to exist yet; so maybe a future user will know better how to choose valid characters than we would implement now.) - "output-named:": Special case: A single name is supported even with multiple `keys`. All found values for the keys are set as the same output; the last found key wins. In this mode, `keys` is required. - "env": For each property key , set an environment variable . - "env:": For each property key , set an environment variable . @@ -33,9 +37,6 @@ inputs: - "json": Set an action output "json" to all properties as a JSON object, formatted as a single-line string. - "json-file:": Write a file with name with contents: all properties as a JSON object (formatting not specified). - For all output names, punctuation characters except the underscore ("_") are replaced with "-" followed by four hex digits - of its unicode code point. For example, for a property key "a.b-c_d", resultType "output" sets an output with name - "a-002Eb-002Dc_d". required: false default: 'output' From 593fc577340eb87f1a62ed3f2f6063d9bab2c8d2 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Wed, 13 May 2026 17:24:01 +0200 Subject: [PATCH 24/69] test workflow: more cases; refactor into reusable script blocks --- .github/workflows/test.yml | 507 +++++++++++++++++++++++++++++---- test/resources/test.properties | 4 +- 2 files changed, 451 insertions(+), 60 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dd6c0be..bbfe1d9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,8 +4,22 @@ defaults: run: shell: bash env: + ENV_VAR_PREFIX: PROP_TEST__ + JSON_OUTPUT_FILE: tmp/out.json + SELECTED_SINGLE_KEY: 'org.gradle.jvmargs' + SELECTED_KEYS: 'sourceJavaVersion org.gradle.jvmargs unicode_escapes colon:in:Key' + SELECTED_SINGLE_NAME: 'my-prop' + SELECTED_NAMES: 'java-version jvm_args unicode colon' + + NOT_FOUND_STATUS: 54 SHELL_SETUP: |- - function assertEquals() { + debugPrintf() { + local pattern=$1; shift + printf "::debug::${pattern}" "$@" >&2 + } + + assertEquals() { + debugPrintf 'assertEquals("%s", "%s")\n' "$1" "$2" if [ "$1" = "$2" ]; then printf 'ok: "%s" == "%s"\n' "$1" "$2" >&2 else @@ -13,9 +27,70 @@ env: return 1 fi } - function jsonGet() { + + # Queries a value by calling command $1 with the remaining args and expects it to exit with status $NOT_FOUND_STATUS. + assertUndefined() { + local getterFunction=$1; shift + local value st + if value="$("$getterFunction" "$@")"; then + printf 'assertion error: %s == "%s", expected undefined\n' "$*" "$value" >&2 + return 1 + elif [ ${st-$?} -eq "$NOT_FOUND_STATUS" ]; then + printf 'ok: %s is undefined\n' "$*" >&2 + else + # no error message, assuming failed $getterFunction has already written one + return $st + fi + } + + # The following helper functions to get a value for a given property key from the result have a uniform + # API (property key as sole argument) so that the actual test code can be identical (and thereby reusable + # with YAML anchors) for different result types. This leads to some assumptions which might seem + # a little odd, like the JSON result file must be called $JSON_OUTPUT_FILE. + + # Expects JSON as stdin and extracts the value for key $1. + getJsonValue() { + local key=$1; shift + debugPrintf 'getJsonValue("%s")\n' "$key" + jq --arg k "$key" --raw-output '.[$k] // ("" | halt_error(env.NOT_FOUND_STATUS | tonumber))' + } + + # Expects JSON in variable RESULT_JSON and extracts the value for key $1. + getEnvJsonValue() { + getJsonValue "$@" <<<"$RESULT_JSON" + } + + # Expects JSON in variable RESULT_JSON and extracts the value for key . + encodeKeyAndGetEnvJsonValue() { + local key=$1; shift + local encodedKey + encodedKey="_$(perl -pe 's=((?!_)[[:punct:]])= sprintf("-%04X", ord($1)) =ge' <<<"$key")" + getEnvJsonValue "$encodedKey" "$@" + } + + # Prints the value of the environment variable set for key $1 (when not using a prefix). + getEnv() { local key=$1; shift - jq --arg k "$key" --raw-output '.[$k] // ""' + debugPrintf 'getEnv("%s")\n' "$key" + local st + if printenv -- "$key"; then + : # OK + elif [ ${st-$?} -eq 1 ]; then + return $NOT_FOUND_STATUS + else + # no error message, assuming printenv has already written one + return $st + fi + } + + # Prints the value of the environment variable set for key $1 when using a prefix. + getEnvWithPrefix() { + local key=$1; shift + getEnv "${ENV_VAR_PREFIX}${key}" "$@" + } + + getFileJsonValue() { + getJsonValue "$@" <"$JSON_OUTPUT_FILE" } jobs: @@ -30,6 +105,7 @@ jobs: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} + ############################################################ - name: 'test (should fail): invalid input: resultType' id: error-input-resultType continue-on-error: true @@ -42,6 +118,86 @@ jobs: eval "$SHELL_SETUP" assertEquals '${{steps.error-input-resultType.outcome}}' failure + ############################################################ + - name: 'test (should fail): resultType "output-named" without names' + id: error-resultType-output-without-names + continue-on-error: true + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS}}' + resultType: 'output-named:' + - name: 'validate "test (should fail): resultType "output-named" without names"' + run: |- + eval "$SHELL_SETUP" + assertEquals '${{steps.error-resultType-output-without-names.outcome}}' failure + + ############################################################ + - name: 'test (should fail): resultType "env-named" without names' + id: error-resultType-env-without-names + continue-on-error: true + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS}}' + resultType: 'env-named:' + - name: 'validate "test (should fail): resultType "env-named" without names"' + run: |- + eval "$SHELL_SETUP" + assertEquals '${{steps.error-resultType-env-without-names.outcome}}' failure + + ############################################################ + - name: 'test (should fail): resultType "output-named" without keys' + id: error-resultType-output-without-keys + continue-on-error: true + uses: ./ + with: + file: test/resources/test.properties + resultType: 'output-named:a b' + - name: 'validate "test (should fail): resultType "output-named" without keys"' + run: |- + eval "$SHELL_SETUP" + assertEquals '${{steps.error-resultType-output-without-keys.outcome}}' failure + + ############################################################ + - name: 'test (should fail): resultType "env-named" without keys' + id: error-resultType-env-without-keys + continue-on-error: true + uses: ./ + with: + file: test/resources/test.properties + resultType: 'env-named:a b' + - name: 'validate "test (should fail): resultType "env-named" without keys"' + run: |- + eval "$SHELL_SETUP" + assertEquals '${{steps.error-resultType-env-without-keys.outcome}}' failure + + ############################################################ + # This tests a subset of test case multi-output, but with hard-coded encoding from property key to output + # name (instead of re-implemented). + # It also demonstrates how you'd usually use outputs with one environment variable per output. + - name: 'test: query multiple properties as action outputs (simple)' + id: multi-output-simple + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS}} dash-in-Key' + - name: 'validate "test: query multiple properties as action outputs (simple)"' + env: + RESULT_SJV: ${{steps.multi-output-simple.outputs._sourceJavaVersion}} + RESULT_JVMARGS: ${{steps.multi-output-simple.outputs._org-002Egradle-002Ejvmargs}} + RESULT_COLONK: ${{steps.multi-output-simple.outputs._colon-003Ain-003AKey}} + RESULT_DASHK: ${{steps.multi-output-simple.outputs._dash-002Din-002DKey}} + RESULT_UNI: ${{steps.multi-output-simple.outputs._unicode_escapes}} + run: |- + eval "$SHELL_SETUP" + assertEquals "$RESULT_SJV" '21' + assertEquals "$RESULT_JVMARGS" '-ea -showversion' + assertEquals "$RESULT_COLONK" 'c' + assertEquals "$RESULT_DASHK" 'd' + assertEquals "$RESULT_UNI" 'a«,»c' + + ############################################################ - name: 'test: query all properties as action outputs' id: all-output uses: ./ @@ -49,30 +205,176 @@ jobs: file: test/resources/test.properties - name: 'validate "test: query all properties as action outputs"' env: - RESULT_SJV: ${{steps.all-output.outputs._sourceJavaVersion}} - RESULT_TJV: ${{steps.all-output.outputs._targetJavaVersion}} - RESULT_GRO: ${{steps.all-output.outputs._org-002Egradle-002Ejvmargs}} - run: |- + RESULT_JSON: ${{toJSON(steps.all-output.outputs)}} + RESULT_GETTER: encodeKeyAndGetEnvJsonValue + run: &validateAllImpl |- eval "$SHELL_SETUP" - assertEquals "$RESULT_SJV" 21 - assertEquals "$RESULT_TJV" 17 - assertEquals "$RESULT_GRO" '-ea -showversion' + assertEquals "$($RESULT_GETTER sourceJavaVersion )" '21' + assertEquals "$($RESULT_GETTER targetJavaVersion )" '17' + assertEquals "$($RESULT_GETTER org.gradle.jvmargs)" '-ea -showversion' + assertEquals "$($RESULT_GETTER colon:in:Key )" 'c' + assertEquals "$($RESULT_GETTER colonInValue )" 'cv:' + assertEquals "$($RESULT_GETTER dash-in-Key )" 'd' + assertEquals "$($RESULT_GETTER dashInValue )" 'd-' + assertEquals "$($RESULT_GETTER empty )" '' + assertEquals "$($RESULT_GETTER null )" 'null' + # Expect additional linefeed (the usual output of commands, e.g. jq); + # add a trailing dummy char to preserve the otherwise trailing linefeed in bash command substitution [https://stackoverflow.com/a/15184414]: + assertEquals "$($RESULT_GETTER whitespace_escapes && printf '|')" $'A\tB\fC C\nD\n\n|' + assertEquals "$($RESULT_GETTER unicode_escapes )" 'a«,»c' + assertEquals "$($RESULT_GETTER dash_1 )" '-' + assertEquals "$($RESULT_GETTER dash_2 )" '--' + assertEquals "$($RESULT_GETTER dash_3 )" '---' + assertEquals "$($RESULT_GETTER dash_4 )" '----' + assertEquals "$($RESULT_GETTER dash_5 )" '-----' + assertEquals "$($RESULT_GETTER dash_6 )" '------' + assertEquals "$($RESULT_GETTER dash_7 )" '-------' + assertEquals "$($RESULT_GETTER dash_8 )" '--------' + assertEquals "$($RESULT_GETTER dash_9 )" '---------' + assertEquals "$($RESULT_GETTER dash_10 )" '----------' + assertEquals "$($RESULT_GETTER dash_11 )" '-----------' + assertEquals "$($RESULT_GETTER dash_12 )" '------------' + assertEquals "$($RESULT_GETTER dash_3_4_1 )" $'---\n----\n-' + assertEquals "$($RESULT_GETTER dash_3_4_8_1 )" $'---\n----\n--------\n-' + ############################################################ - name: 'test: query multiple properties as action outputs' id: multi-output uses: ./ with: file: test/resources/test.properties - keys: 'sourceJavaVersion targetJavaVersion' + keys: '${{env.SELECTED_KEYS}}' - name: 'validate "test: query multiple properties as action outputs"' env: - RESULT_SJV: ${{steps.multi-output.outputs._sourceJavaVersion}} - RESULT_TJV: ${{steps.multi-output.outputs._targetJavaVersion}} - run: |- + RESULT_JSON: ${{toJSON(steps.multi-output.outputs)}} + RESULT_GETTER: encodeKeyAndGetEnvJsonValue + run: &validateMultiImpl |- + eval "$SHELL_SETUP" + assertEquals "$($RESULT_GETTER sourceJavaVersion )" '21' + assertEquals "$($RESULT_GETTER org.gradle.jvmargs)" '-ea -showversion' + assertEquals "$($RESULT_GETTER colon:in:Key )" 'c' + assertEquals "$($RESULT_GETTER unicode_escapes )" 'a«,»c' + + assertUndefined $RESULT_GETTER targetJavaVersion + assertUndefined $RESULT_GETTER colonInValue + assertUndefined $RESULT_GETTER dash-in-Key + assertUndefined $RESULT_GETTER dashInValue + assertUndefined $RESULT_GETTER empty + assertUndefined $RESULT_GETTER null + assertUndefined $RESULT_GETTER whitespace_escapes + assertUndefined $RESULT_GETTER dash_1 + assertUndefined $RESULT_GETTER dash_2 + assertUndefined $RESULT_GETTER dash_3 + assertUndefined $RESULT_GETTER dash_4 + assertUndefined $RESULT_GETTER dash_5 + assertUndefined $RESULT_GETTER dash_6 + assertUndefined $RESULT_GETTER dash_7 + assertUndefined $RESULT_GETTER dash_8 + assertUndefined $RESULT_GETTER dash_9 + assertUndefined $RESULT_GETTER dash_10 + assertUndefined $RESULT_GETTER dash_11 + assertUndefined $RESULT_GETTER dash_12 + assertUndefined $RESULT_GETTER dash_3_4_1 + assertUndefined $RESULT_GETTER dash_3_4_8_1 + + ############################################################ + - name: 'test: query multiple properties as action outputs of given names' + id: multi-output-named + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS}}' + resultType: 'output-named:${{env.SELECTED_NAMES}}' + - name: 'validate "test: query multiple properties as action outputs of given names"' + env: + RESULT_JSON: ${{toJSON(steps.multi-output-named.outputs)}} + # Output names specified as input are not subject to encoding (prefix "_", replace punctuation except "_"): + RESULT_GETTER: getEnvJsonValue + run: &validateMultiNamedImpl |- + eval "$SHELL_SETUP" + assertEquals "$($RESULT_GETTER java-version)" '21' + assertEquals "$($RESULT_GETTER jvm_args )" '-ea -showversion' + assertEquals "$($RESULT_GETTER colon )" 'c' + assertEquals "$($RESULT_GETTER unicode )" 'a«,»c' + + # The outputs with the default names are not set: + assertUndefined $RESULT_GETTER sourceJavaVersion + assertUndefined $RESULT_GETTER org.gradle.jvmargs + assertUndefined $RESULT_GETTER colon:in:Key + assertUndefined $RESULT_GETTER unicode_escapes + + # Outputs for non-selected keys are not set, for example: + assertUndefined $RESULT_GETTER colonInValue + assertUndefined $RESULT_GETTER dash_1 + + ############################################################ + - name: 'test: query multiple properties as action outputs of given name' + id: multi-output-named1 + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS}}' + resultType: 'output-named:${{env.SELECTED_SINGLE_NAME}}' + - name: 'validate "test: query multiple properties as action outputs of given name"' + env: + RESULT_JSON: ${{toJSON(steps.multi-output-named1.outputs)}} + # Output names specified as input are not subject to encoding (prefix "_", replace punctuation except "_"): + RESULT_GETTER: getEnvJsonValue + run: &validateMultiNamed1Impl |- + eval "$SHELL_SETUP" + assertEquals "$($RESULT_GETTER my-prop)" 'c' + + # The outputs with the default names are not set: + assertUndefined $RESULT_GETTER sourceJavaVersion + assertUndefined $RESULT_GETTER org.gradle.jvmargs + assertUndefined $RESULT_GETTER colon:in:Key + assertUndefined $RESULT_GETTER unicode_escapes + + # Outputs for non-selected keys are not set, for example: + assertUndefined $RESULT_GETTER colonInValue + assertUndefined $RESULT_GETTER dash_1 + + ############################################################ + - name: 'test: query single property as action outputs' + id: single-output + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_SINGLE_KEY}}' + - name: 'validate "test: query multiple properties as action outputs"' + env: + RESULT_JSON: ${{toJSON(steps.single-output.outputs)}} + RESULT_GETTER: encodeKeyAndGetEnvJsonValue + run: &validateSingleImpl |- eval "$SHELL_SETUP" - assertEquals "$RESULT_SJV" 21 - assertEquals "$RESULT_TJV" 17 + assertEquals "$($RESULT_GETTER org.gradle.jvmargs)" '-ea -showversion' + assertUndefined $RESULT_GETTER sourceJavaVersion + assertUndefined $RESULT_GETTER targetJavaVersion + assertUndefined $RESULT_GETTER colon:in:Key + assertUndefined $RESULT_GETTER colonInValue + assertUndefined $RESULT_GETTER dash-in-Key + assertUndefined $RESULT_GETTER dashInValue + assertUndefined $RESULT_GETTER empty + assertUndefined $RESULT_GETTER null + assertUndefined $RESULT_GETTER whitespace_escapes + assertUndefined $RESULT_GETTER unicode_escapes + assertUndefined $RESULT_GETTER dash_1 + assertUndefined $RESULT_GETTER dash_2 + assertUndefined $RESULT_GETTER dash_3 + assertUndefined $RESULT_GETTER dash_4 + assertUndefined $RESULT_GETTER dash_5 + assertUndefined $RESULT_GETTER dash_6 + assertUndefined $RESULT_GETTER dash_7 + assertUndefined $RESULT_GETTER dash_8 + assertUndefined $RESULT_GETTER dash_9 + assertUndefined $RESULT_GETTER dash_10 + assertUndefined $RESULT_GETTER dash_11 + assertUndefined $RESULT_GETTER dash_12 + assertUndefined $RESULT_GETTER dash_3_4_1 + assertUndefined $RESULT_GETTER dash_3_4_8_1 + + ############################################################ - name: 'test: query all properties as JSON action output' id: all-json uses: ./ @@ -81,51 +383,50 @@ jobs: resultType: json - name: 'validate "test: query all properties as JSON action output"' env: - RESULT: ${{steps.all-json.outputs.json}} - run: |- - eval "$SHELL_SETUP" - jq <<<"$RESULT" # assert parseable (and print) - # check some values: - assertEquals "$(jsonGet 'unicode_escapes' <<<"$RESULT")" 'a«,»c' - assertEquals "$(jsonGet 'colon:in:Key' <<<"$RESULT")" 'c' - # assert size >= 10: - test "$(jq --raw-output 'keys | length' <<<"$RESULT")" -ge 10 + RESULT_JSON: ${{steps.all-json.outputs.json}} + RESULT_GETTER: getEnvJsonValue + run: *validateAllImpl + ############################################################ - name: 'test: query multiple properties as JSON action output' id: multiple-json uses: ./ with: file: test/resources/test.properties - keys: 'colon:in:Key colonInValue unicode_escapes' + keys: '${{env.SELECTED_KEYS}}' resultType: json - name: 'validate "test: query multiple properties as JSON action output"' env: - RESULT: ${{steps.multiple-json.outputs.json}} - run: |- - eval "$SHELL_SETUP" - assertEquals "$(jsonGet 'unicode_escapes' <<<"$RESULT")" 'a«,»c' - assertEquals "$(jsonGet 'colonInValue' <<<"$RESULT")" 'cv:' - assertEquals "$(jsonGet 'colon:in:Key' <<<"$RESULT")" 'c' - # assert size 3 (i.e. no additional results): - assertEquals "$(jq --raw-output 'keys | length' <<<"$RESULT")" 3 + RESULT_JSON: ${{steps.multiple-json.outputs.json}} + RESULT_GETTER: getEnvJsonValue + run: *validateMultiImpl + + ############################################################ + - name: 'test: query all properties as JSON file' + uses: ./ + with: + file: test/resources/test.properties + resultType: 'json-file:${{env.JSON_OUTPUT_FILE}}' + - name: 'validate "test: query all properties as JSON file"' + env: + RESULT_GETTER: getFileJsonValue + run: *validateAllImpl + ############################################################ - name: 'test: query multiple properties as JSON file' uses: ./ with: file: test/resources/test.properties - keys: 'colon:in:Key colonInValue unicode_escapes' - resultType: json-file:tmp/multi.json + keys: '${{env.SELECTED_KEYS}}' + resultType: 'json-file:${{env.JSON_OUTPUT_FILE}}' - name: 'validate "test: query multiple properties as JSON file"' - run: |- - eval "$SHELL_SETUP" - result=$( Date: Wed, 13 May 2026 17:40:54 +0200 Subject: [PATCH 25/69] fix NullPointerException on missing keys input --- Action.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Action.java b/Action.java index 9429838..85d6e6f 100644 --- a/Action.java +++ b/Action.java @@ -166,6 +166,9 @@ public static ResultWriter of(String inputName) { private static void writeNamedImpl(Properties props, Config config, GitHubOutputFile gitHubOutputFile) throws IOException { String[] selectedKeys = config.selectedKeys(); + if (selectedKeys == null) { + throw new IllegalArgumentException("invalid use of resultType " + config.resultType() + " (missing keys)"); + } String[] resultNames = Util.splitArray(config.requiredResultTypeArg(), config.resultNameSeparator(), null); if (resultNames.length != selectedKeys.length && resultNames.length != 1) { throw new IllegalArgumentException("resultType " + config.resultTypeWithArg() + " has " + resultNames.length From 0dac66e01d1e25980d5673b15910da7174b6232f Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Wed, 13 May 2026 17:52:57 +0200 Subject: [PATCH 26/69] logging (result for type "json-file") --- .github/workflows/test.yml | 1 + Action.java | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bbfe1d9..356658c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -90,6 +90,7 @@ env: } getFileJsonValue() { + debugPrintf 'getFileJsonValue("%s") with file contents "%s"\n' "$*" "$(<"$JSON_OUTPUT_FILE")" getJsonValue "$@" <"$JSON_OUTPUT_FILE" } diff --git a/Action.java b/Action.java index 85d6e6f..f832bfb 100644 --- a/Action.java +++ b/Action.java @@ -140,7 +140,9 @@ public void write(Properties props, Config config) throws IOException { String outputFile = config.requiredResultTypeArg(); Files.createDirectories((Paths.get(outputFile).getParent())); try (Writer writer = Util.openFile(outputFile, StandardOpenOption.CREATE)) { - writer.write(Util.toJson(props)); + String jsonResult = Util.toJson(props); + System.err.format("writing to %s: %s\n", outputFile, jsonResult); + writer.write(jsonResult); writer.write('\n'); writer.flush(); } From 5a8bcaf9dac2dbc0746d4f05d4b71a2c047d65c8 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Wed, 13 May 2026 19:01:31 +0200 Subject: [PATCH 27/69] fix test workflow: do not ignore result getter return status --- .github/workflows/test.yml | 224 +++++++++++++++++++-------------- test/resources/test.properties | 4 +- 2 files changed, 130 insertions(+), 98 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 356658c..bbba533 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,6 +18,17 @@ env: printf "::debug::${pattern}" "$@" >&2 } + # Reads stdin and replaces one linefeed at the end of the input with "¶". Exits with non-zero status if that LF is + # missing. + # Piping a command to this function works around the removal of trailing LFs in command substitution + # [https://stackoverflow.com/a/15184414] and preserves meaningful LFs. + # For example, when the command is `jq --raw-output …` and the result is a string with ends with LF, that LF is + # preserved, and the LF added by jq (like line-based commands generally do) is replaced. + # This enables precise comparisons. + replaceLastLF() { + perl -e '$/ = undef; $_ = <>; ($r = $_) =~ s{\n$}{}; die "missing trailing LF\n" if $r eq $_; printf "%s¶", $r' + } + assertEquals() { debugPrintf 'assertEquals("%s", "%s")\n' "$1" "$2" if [ "$1" = "$2" ]; then @@ -29,7 +40,22 @@ env: } # Queries a value by calling command $1 with the remaining args and expects it to exit with status $NOT_FOUND_STATUS. - assertUndefined() { + getAndAssertEquals() { + local getterFunction=$1; shift + local key=$1; shift + local expectedValue=$1; shift + local value st + # Invocation of $getterFunction must not ignore its return status. This is why tests use this function in the form + # getAndAssertEquals $RESULT_GETTER "$key" "$expectedValue" + # instead of + # assertEquals "$($RESULT_GETTER "$key")" "$expectedValue" + value="$(set -o pipefail; "$getterFunction" "$key" | replaceLastLF)" + expectedValue="$(printf '%s\n' "$expectedValue" | replaceLastLF)" + assertEquals "$value" "$expectedValue" + } + + # Queries a value by calling command $1 with the remaining args and expects it to exit with status $NOT_FOUND_STATUS. + getAndAssertUndefined() { local getterFunction=$1; shift local value st if value="$("$getterFunction" "$@")"; then @@ -210,33 +236,33 @@ jobs: RESULT_GETTER: encodeKeyAndGetEnvJsonValue run: &validateAllImpl |- eval "$SHELL_SETUP" - assertEquals "$($RESULT_GETTER sourceJavaVersion )" '21' - assertEquals "$($RESULT_GETTER targetJavaVersion )" '17' - assertEquals "$($RESULT_GETTER org.gradle.jvmargs)" '-ea -showversion' - assertEquals "$($RESULT_GETTER colon:in:Key )" 'c' - assertEquals "$($RESULT_GETTER colonInValue )" 'cv:' - assertEquals "$($RESULT_GETTER dash-in-Key )" 'd' - assertEquals "$($RESULT_GETTER dashInValue )" 'd-' - assertEquals "$($RESULT_GETTER empty )" '' - assertEquals "$($RESULT_GETTER null )" 'null' - # Expect additional linefeed (the usual output of commands, e.g. jq); - # add a trailing dummy char to preserve the otherwise trailing linefeed in bash command substitution [https://stackoverflow.com/a/15184414]: - assertEquals "$($RESULT_GETTER whitespace_escapes && printf '|')" $'A\tB\fC C\nD\n\n|' - assertEquals "$($RESULT_GETTER unicode_escapes )" 'a«,»c' - assertEquals "$($RESULT_GETTER dash_1 )" '-' - assertEquals "$($RESULT_GETTER dash_2 )" '--' - assertEquals "$($RESULT_GETTER dash_3 )" '---' - assertEquals "$($RESULT_GETTER dash_4 )" '----' - assertEquals "$($RESULT_GETTER dash_5 )" '-----' - assertEquals "$($RESULT_GETTER dash_6 )" '------' - assertEquals "$($RESULT_GETTER dash_7 )" '-------' - assertEquals "$($RESULT_GETTER dash_8 )" '--------' - assertEquals "$($RESULT_GETTER dash_9 )" '---------' - assertEquals "$($RESULT_GETTER dash_10 )" '----------' - assertEquals "$($RESULT_GETTER dash_11 )" '-----------' - assertEquals "$($RESULT_GETTER dash_12 )" '------------' - assertEquals "$($RESULT_GETTER dash_3_4_1 )" $'---\n----\n-' - assertEquals "$($RESULT_GETTER dash_3_4_8_1 )" $'---\n----\n--------\n-' + getAndAssertEquals "$RESULT_GETTER" sourceJavaVersion '21' + getAndAssertEquals "$RESULT_GETTER" targetJavaVersion '17' + getAndAssertEquals "$RESULT_GETTER" org.gradle.jvmargs '-ea -showversion' + getAndAssertEquals "$RESULT_GETTER" colon:in:Key 'c' + getAndAssertEquals "$RESULT_GETTER" colonInValue 'cv:' + getAndAssertEquals "$RESULT_GETTER" dash-in-Key 'd' + getAndAssertEquals "$RESULT_GETTER" dashInValue 'd-' + getAndAssertEquals "$RESULT_GETTER" empty '' + getAndAssertEquals "$RESULT_GETTER" null 'null' + getAndAssertEquals "$RESULT_GETTER" unicode_escapes 'a«,»c' + getAndAssertEquals "$RESULT_GETTER" whitespace_escapes $'A\tB\fC C\r\n\nD' + getAndAssertEquals "$RESULT_GETTER" trailing_linefeed_1 $'one trailing LF\n' + getAndAssertEquals "$RESULT_GETTER" trailing_linefeed_2 $'two trailing LFs\n\n' + getAndAssertEquals "$RESULT_GETTER" dash_1 '-' + getAndAssertEquals "$RESULT_GETTER" dash_2 '--' + getAndAssertEquals "$RESULT_GETTER" dash_3 '---' + getAndAssertEquals "$RESULT_GETTER" dash_4 '----' + getAndAssertEquals "$RESULT_GETTER" dash_5 '-----' + getAndAssertEquals "$RESULT_GETTER" dash_6 '------' + getAndAssertEquals "$RESULT_GETTER" dash_7 '-------' + getAndAssertEquals "$RESULT_GETTER" dash_8 '--------' + getAndAssertEquals "$RESULT_GETTER" dash_9 '---------' + getAndAssertEquals "$RESULT_GETTER" dash_10 '----------' + getAndAssertEquals "$RESULT_GETTER" dash_11 '-----------' + getAndAssertEquals "$RESULT_GETTER" dash_12 '------------' + getAndAssertEquals "$RESULT_GETTER" dash_3_4_1 $'---\n----\n-' + getAndAssertEquals "$RESULT_GETTER" dash_3_4_8_1 $'---\n----\n--------\n-' ############################################################ - name: 'test: query multiple properties as action outputs' @@ -251,32 +277,34 @@ jobs: RESULT_GETTER: encodeKeyAndGetEnvJsonValue run: &validateMultiImpl |- eval "$SHELL_SETUP" - assertEquals "$($RESULT_GETTER sourceJavaVersion )" '21' - assertEquals "$($RESULT_GETTER org.gradle.jvmargs)" '-ea -showversion' - assertEquals "$($RESULT_GETTER colon:in:Key )" 'c' - assertEquals "$($RESULT_GETTER unicode_escapes )" 'a«,»c' - - assertUndefined $RESULT_GETTER targetJavaVersion - assertUndefined $RESULT_GETTER colonInValue - assertUndefined $RESULT_GETTER dash-in-Key - assertUndefined $RESULT_GETTER dashInValue - assertUndefined $RESULT_GETTER empty - assertUndefined $RESULT_GETTER null - assertUndefined $RESULT_GETTER whitespace_escapes - assertUndefined $RESULT_GETTER dash_1 - assertUndefined $RESULT_GETTER dash_2 - assertUndefined $RESULT_GETTER dash_3 - assertUndefined $RESULT_GETTER dash_4 - assertUndefined $RESULT_GETTER dash_5 - assertUndefined $RESULT_GETTER dash_6 - assertUndefined $RESULT_GETTER dash_7 - assertUndefined $RESULT_GETTER dash_8 - assertUndefined $RESULT_GETTER dash_9 - assertUndefined $RESULT_GETTER dash_10 - assertUndefined $RESULT_GETTER dash_11 - assertUndefined $RESULT_GETTER dash_12 - assertUndefined $RESULT_GETTER dash_3_4_1 - assertUndefined $RESULT_GETTER dash_3_4_8_1 + getAndAssertEquals "$RESULT_GETTER" sourceJavaVersion '21' + getAndAssertEquals "$RESULT_GETTER" org.gradle.jvmargs '-ea -showversion' + getAndAssertEquals "$RESULT_GETTER" colon:in:Key 'c' + getAndAssertEquals "$RESULT_GETTER" unicode_escapes 'a«,»c' + + getAndAssertUndefined "$RESULT_GETTER" targetJavaVersion + getAndAssertUndefined "$RESULT_GETTER" colonInValue + getAndAssertUndefined "$RESULT_GETTER" dash-in-Key + getAndAssertUndefined "$RESULT_GETTER" dashInValue + getAndAssertUndefined "$RESULT_GETTER" empty + getAndAssertUndefined "$RESULT_GETTER" null + getAndAssertUndefined "$RESULT_GETTER" whitespace_escapes + getAndAssertUndefined "$RESULT_GETTER" trailing_linefeed_1 + getAndAssertUndefined "$RESULT_GETTER" trailing_linefeed_2 + getAndAssertUndefined "$RESULT_GETTER" dash_1 + getAndAssertUndefined "$RESULT_GETTER" dash_2 + getAndAssertUndefined "$RESULT_GETTER" dash_3 + getAndAssertUndefined "$RESULT_GETTER" dash_4 + getAndAssertUndefined "$RESULT_GETTER" dash_5 + getAndAssertUndefined "$RESULT_GETTER" dash_6 + getAndAssertUndefined "$RESULT_GETTER" dash_7 + getAndAssertUndefined "$RESULT_GETTER" dash_8 + getAndAssertUndefined "$RESULT_GETTER" dash_9 + getAndAssertUndefined "$RESULT_GETTER" dash_10 + getAndAssertUndefined "$RESULT_GETTER" dash_11 + getAndAssertUndefined "$RESULT_GETTER" dash_12 + getAndAssertUndefined "$RESULT_GETTER" dash_3_4_1 + getAndAssertUndefined "$RESULT_GETTER" dash_3_4_8_1 ############################################################ - name: 'test: query multiple properties as action outputs of given names' @@ -293,20 +321,20 @@ jobs: RESULT_GETTER: getEnvJsonValue run: &validateMultiNamedImpl |- eval "$SHELL_SETUP" - assertEquals "$($RESULT_GETTER java-version)" '21' - assertEquals "$($RESULT_GETTER jvm_args )" '-ea -showversion' - assertEquals "$($RESULT_GETTER colon )" 'c' - assertEquals "$($RESULT_GETTER unicode )" 'a«,»c' + getAndAssertEquals "$RESULT_GETTER" java-version '21' + getAndAssertEquals "$RESULT_GETTER" jvm_args '-ea -showversion' + getAndAssertEquals "$RESULT_GETTER" colon 'c' + getAndAssertEquals "$RESULT_GETTER" unicode 'a«,»c' # The outputs with the default names are not set: - assertUndefined $RESULT_GETTER sourceJavaVersion - assertUndefined $RESULT_GETTER org.gradle.jvmargs - assertUndefined $RESULT_GETTER colon:in:Key - assertUndefined $RESULT_GETTER unicode_escapes + getAndAssertUndefined "$RESULT_GETTER" sourceJavaVersion + getAndAssertUndefined "$RESULT_GETTER" org.gradle.jvmargs + getAndAssertUndefined "$RESULT_GETTER" colon:in:Key + getAndAssertUndefined "$RESULT_GETTER" unicode_escapes # Outputs for non-selected keys are not set, for example: - assertUndefined $RESULT_GETTER colonInValue - assertUndefined $RESULT_GETTER dash_1 + getAndAssertUndefined "$RESULT_GETTER" colonInValue + getAndAssertUndefined "$RESULT_GETTER" dash_1 ############################################################ - name: 'test: query multiple properties as action outputs of given name' @@ -323,17 +351,17 @@ jobs: RESULT_GETTER: getEnvJsonValue run: &validateMultiNamed1Impl |- eval "$SHELL_SETUP" - assertEquals "$($RESULT_GETTER my-prop)" 'c' + getAndAssertEquals "$RESULT_GETTER" my-prop 'c' # The outputs with the default names are not set: - assertUndefined $RESULT_GETTER sourceJavaVersion - assertUndefined $RESULT_GETTER org.gradle.jvmargs - assertUndefined $RESULT_GETTER colon:in:Key - assertUndefined $RESULT_GETTER unicode_escapes + getAndAssertUndefined "$RESULT_GETTER" sourceJavaVersion + getAndAssertUndefined "$RESULT_GETTER" org.gradle.jvmargs + getAndAssertUndefined "$RESULT_GETTER" colon:in:Key + getAndAssertUndefined "$RESULT_GETTER" unicode_escapes # Outputs for non-selected keys are not set, for example: - assertUndefined $RESULT_GETTER colonInValue - assertUndefined $RESULT_GETTER dash_1 + getAndAssertUndefined "$RESULT_GETTER" colonInValue + getAndAssertUndefined "$RESULT_GETTER" dash_1 ############################################################ - name: 'test: query single property as action outputs' @@ -348,32 +376,34 @@ jobs: RESULT_GETTER: encodeKeyAndGetEnvJsonValue run: &validateSingleImpl |- eval "$SHELL_SETUP" - assertEquals "$($RESULT_GETTER org.gradle.jvmargs)" '-ea -showversion' - - assertUndefined $RESULT_GETTER sourceJavaVersion - assertUndefined $RESULT_GETTER targetJavaVersion - assertUndefined $RESULT_GETTER colon:in:Key - assertUndefined $RESULT_GETTER colonInValue - assertUndefined $RESULT_GETTER dash-in-Key - assertUndefined $RESULT_GETTER dashInValue - assertUndefined $RESULT_GETTER empty - assertUndefined $RESULT_GETTER null - assertUndefined $RESULT_GETTER whitespace_escapes - assertUndefined $RESULT_GETTER unicode_escapes - assertUndefined $RESULT_GETTER dash_1 - assertUndefined $RESULT_GETTER dash_2 - assertUndefined $RESULT_GETTER dash_3 - assertUndefined $RESULT_GETTER dash_4 - assertUndefined $RESULT_GETTER dash_5 - assertUndefined $RESULT_GETTER dash_6 - assertUndefined $RESULT_GETTER dash_7 - assertUndefined $RESULT_GETTER dash_8 - assertUndefined $RESULT_GETTER dash_9 - assertUndefined $RESULT_GETTER dash_10 - assertUndefined $RESULT_GETTER dash_11 - assertUndefined $RESULT_GETTER dash_12 - assertUndefined $RESULT_GETTER dash_3_4_1 - assertUndefined $RESULT_GETTER dash_3_4_8_1 + getAndAssertEquals "$RESULT_GETTER" org.gradle.jvmargs '-ea -showversion' + + getAndAssertUndefined "$RESULT_GETTER" sourceJavaVersion + getAndAssertUndefined "$RESULT_GETTER" targetJavaVersion + getAndAssertUndefined "$RESULT_GETTER" colon:in:Key + getAndAssertUndefined "$RESULT_GETTER" colonInValue + getAndAssertUndefined "$RESULT_GETTER" dash-in-Key + getAndAssertUndefined "$RESULT_GETTER" dashInValue + getAndAssertUndefined "$RESULT_GETTER" empty + getAndAssertUndefined "$RESULT_GETTER" null + getAndAssertUndefined "$RESULT_GETTER" whitespace_escapes + getAndAssertUndefined "$RESULT_GETTER" trailing_linefeed_1 + getAndAssertUndefined "$RESULT_GETTER" trailing_linefeed_2 + getAndAssertUndefined "$RESULT_GETTER" unicode_escapes + getAndAssertUndefined "$RESULT_GETTER" dash_1 + getAndAssertUndefined "$RESULT_GETTER" dash_2 + getAndAssertUndefined "$RESULT_GETTER" dash_3 + getAndAssertUndefined "$RESULT_GETTER" dash_4 + getAndAssertUndefined "$RESULT_GETTER" dash_5 + getAndAssertUndefined "$RESULT_GETTER" dash_6 + getAndAssertUndefined "$RESULT_GETTER" dash_7 + getAndAssertUndefined "$RESULT_GETTER" dash_8 + getAndAssertUndefined "$RESULT_GETTER" dash_9 + getAndAssertUndefined "$RESULT_GETTER" dash_10 + getAndAssertUndefined "$RESULT_GETTER" dash_11 + getAndAssertUndefined "$RESULT_GETTER" dash_12 + getAndAssertUndefined "$RESULT_GETTER" dash_3_4_1 + getAndAssertUndefined "$RESULT_GETTER" dash_3_4_8_1 ############################################################ - name: 'test: query all properties as JSON action output' diff --git a/test/resources/test.properties b/test/resources/test.properties index 06bd6b4..64bd76c 100644 --- a/test/resources/test.properties +++ b/test/resources/test.properties @@ -9,8 +9,10 @@ dashInValue: d- empty: null: null -whitespace_escapes = A\tB\fC C\nD\n unicode_escapes = a\u00AB,\u00BBc +whitespace_escapes = A\tB\fC C\r\n\nD +trailing_linefeed_1 = one trailing LF\n +trailing_linefeed_2 = two trailing LFs\n\n # For white box tests: When writing to $GITHUB_OUTPUT or $GITHUB_ENV, "----" is the default delimiter used in this action. # Whenever the value contains a line "----", another delimiter must be chosen. From c226f197446111e61145e6856c669d7d064757ad Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Wed, 13 May 2026 19:41:44 +0200 Subject: [PATCH 28/69] fix resultType "json-file": truncate output file --- Action.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Action.java b/Action.java index f832bfb..d6fcc00 100644 --- a/Action.java +++ b/Action.java @@ -139,7 +139,7 @@ public void write(Properties props, Config config) throws IOException { public void write(Properties props, Config config) throws IOException { String outputFile = config.requiredResultTypeArg(); Files.createDirectories((Paths.get(outputFile).getParent())); - try (Writer writer = Util.openFile(outputFile, StandardOpenOption.CREATE)) { + try (Writer writer = Util.openFile(outputFile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { String jsonResult = Util.toJson(props); System.err.format("writing to %s: %s\n", outputFile, jsonResult); writer.write(jsonResult); @@ -211,7 +211,7 @@ class GitHubVariableWriter implements AutoCloseable { public GitHubVariableWriter(String description, String fileName) throws IOException { this.description = description; - this.writer = Util.openFile(fileName, StandardOpenOption.APPEND); + this.writer = Util.openFile(fileName, StandardOpenOption.CREATE, StandardOpenOption.APPEND); } public void write(String key, String value) throws IOException { From 617ea128516d91f976a1302131a36548cf569e5e Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Fri, 15 May 2026 10:00:02 +0200 Subject: [PATCH 29/69] linting --- Action.java | 60 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/Action.java b/Action.java index d6fcc00..dbdf27d 100644 --- a/Action.java +++ b/Action.java @@ -21,6 +21,8 @@ public class Action { + private Action() {} + static void main(String[] args) throws Exception { Config config = Config.fromEnv(); ResultWriter resultWriter = ResultWriter.of(config.resultType()); @@ -33,7 +35,7 @@ static void main(String[] args) throws Exception { } -record Config(String[] selectedKeys, String keySeparator, String resultTypeWithArg, String resultType, String resultTypeArg, +record Config(List selectedKeys, String keySeparator, String resultTypeWithArg, String resultType, String resultTypeArg, String resultNameSeparator, String outputPrefix) { public static Config fromEnv() { // In order to keep the defaults DRY (in action.yml), the environment variables are all mandatory. @@ -41,7 +43,7 @@ public static Config fromEnv() { // System.getenv("KEYS") == null if set to empty string?! So this cannot be checked to be set if we want to allow empty // string: String keysStr = System.getenv().getOrDefault("KEYS", ""); - String[] keys = Util.splitArray(keysStr, keySeparator, null); + Optional keys = Util.splitArray(keysStr, keySeparator); String resultNameSeparator = Util.getRequiredEnv("RESULT_NAME_SEPARATOR"); // RESULT_TYPE format: "[:]" @@ -53,7 +55,8 @@ public static Config fromEnv() { String resultType = matcher.group(1); String resultTypeArg = Optional.ofNullable(matcher.group(2)).orElse(""); String outputPrefix = Util.getRequiredEnv("OUTPUT_PREFIX"); - return new Config(keys, keySeparator, resultTypeWithArg, resultType, resultTypeArg, resultNameSeparator, outputPrefix); + return new Config(keys.map(List::of).orElse(null), keySeparator, resultTypeWithArg, resultType, resultTypeArg, + resultNameSeparator, outputPrefix); } public String requiredResultTypeArg() { @@ -141,7 +144,7 @@ public void write(Properties props, Config config) throws IOException { Files.createDirectories((Paths.get(outputFile).getParent())); try (Writer writer = Util.openFile(outputFile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { String jsonResult = Util.toJson(props); - System.err.format("writing to %s: %s\n", outputFile, jsonResult); + System.err.format("writing to %s: %s%n", outputFile, jsonResult); writer.write(jsonResult); writer.write('\n'); writer.flush(); @@ -167,19 +170,21 @@ public static ResultWriter of(String inputName) { public abstract void write(Properties props, Config config) throws IOException; private static void writeNamedImpl(Properties props, Config config, GitHubOutputFile gitHubOutputFile) throws IOException { - String[] selectedKeys = config.selectedKeys(); + List selectedKeys = config.selectedKeys(); if (selectedKeys == null) { throw new IllegalArgumentException("invalid use of resultType " + config.resultType() + " (missing keys)"); } - String[] resultNames = Util.splitArray(config.requiredResultTypeArg(), config.resultNameSeparator(), null); - if (resultNames.length != selectedKeys.length && resultNames.length != 1) { + @SuppressWarnings("java:S3655") // Sonar rule: "Optional value should only be accessed after calling isPresent()". + // requiredResultTypeArg() is not empty, so splitArray returns non-empty + String[] resultNames = Util.splitArray(config.requiredResultTypeArg(), config.resultNameSeparator()).get(); + if (resultNames.length != selectedKeys.size() && resultNames.length != 1) { throw new IllegalArgumentException("resultType " + config.resultTypeWithArg() + " has " + resultNames.length - + " arguments, but " + selectedKeys.length + " keys are selected"); + + " arguments, but " + selectedKeys.size() + " keys are selected"); } try (GitHubVariableWriter writer = gitHubOutputFile.open()) { - for (int i = 0; i < selectedKeys.length; i++) { + for (int i = 0; i < selectedKeys.size(); i++) { String name = resultNames[resultNames.length == 1 ? 0 : i]; - String value = props.getProperty(selectedKeys[i]); + String value = props.getProperty(selectedKeys.get(i)); writer.write(name, value); } } @@ -215,7 +220,7 @@ public GitHubVariableWriter(String description, String fileName) throws IOExcept } public void write(String key, String value) throws IOException { - System.err.format("%s %s\t:= \"%s\"\n", description, key, value); + System.err.format("%s %s\t:= \"%s\"%n", description, key, value); // write (very) simple values in format "=": if (SIMPLE_VALUE.matcher(value).matches()) { @@ -223,7 +228,6 @@ public void write(String key, String value) throws IOException { this.writer.write('='); this.writer.write(value); this.writer.write('\n'); - return; } else { writeMultiLine(key, value); } @@ -235,13 +239,7 @@ public void write(String key, String value) throws IOException { * format. */ private void writeMultiLine(String key, String value) throws IOException { - // determine separator line which does not occur in value: - Set valueLines = Set.of(value.split("(?s)\n")); - String separatorPart = "----"; - String separator = separatorPart; - while (valueLines.contains(separator)) { - separator = separator + separatorPart; - } + String separator = computeSeparator(value); this.writer.write(key); this.writer.write("<<"); @@ -253,6 +251,22 @@ private void writeMultiLine(String key, String value) throws IOException { this.writer.write('\n'); } + /** + * Computes a separator line which does not occur in value. + */ + @SuppressWarnings("java:S1643") // Sonar rule: "Strings should not be concatenated using '+' in a loop". + // False positive: We would need that StringBuilder's toString for each iteration for the contains check anyway. + private static String computeSeparator(String value) { + Set valueLines = Set.of(value.split("(?s)\n")); + String separatorPart = "----"; + @SuppressWarnings("java:S1643") + String separator = separatorPart; + while (valueLines.contains(separator)) { + separator = separator + separatorPart; + } + return separator; + } + @Override public void close() throws IOException { this.writer.close(); @@ -271,8 +285,8 @@ public static String getRequiredEnv(String varName) { return value; } - public static String[] splitArray(String arrayStr, String separator, String[] defaultResults) { - return arrayStr.isEmpty() ? defaultResults : arrayStr.split(Pattern.quote(separator), -1); + public static Optional splitArray(String arrayStr, String separator) { + return arrayStr.isEmpty() ? Optional.empty() : Optional.of(arrayStr.split(Pattern.quote(separator), -1)); } public static Writer openFile(String name, OpenOption... options) throws IOException { @@ -287,8 +301,8 @@ public static Properties readProperties(String file) throws IOException { return allProps; } - public static Properties selectProperties(Properties allProps, String[] selectedKeys, String file) { - Set selectedKeysSet = new LinkedHashSet<>(List.of(selectedKeys)); + public static Properties selectProperties(Properties allProps, List selectedKeys, String file) { + Set selectedKeysSet = new LinkedHashSet<>(selectedKeys); Properties props = new Properties(); for (Map.Entry entry : stringEntries(allProps)) { if (selectedKeysSet.contains(entry.getKey())) { From 29acfed48b9482c6931f0d196d182cef0bc7b1ca Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Fri, 15 May 2026 15:37:00 +0200 Subject: [PATCH 30/69] test truncating (resultType "json-file") explicitely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Was already tested, because "query multiple…" & "query all…" used the same file, and all was first. - Because we need arrange steps now, rename steps with clearer prefixes. --- .github/workflows/test.yml | 139 +++++++++++++++++++++++-------------- 1 file changed, 88 insertions(+), 51 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bbba533..114aeed 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,7 +5,6 @@ defaults: shell: bash env: ENV_VAR_PREFIX: PROP_TEST__ - JSON_OUTPUT_FILE: tmp/out.json SELECTED_SINGLE_KEY: 'org.gradle.jvmargs' SELECTED_KEYS: 'sourceJavaVersion org.gradle.jvmargs unicode_escapes colon:in:Key' SELECTED_SINGLE_NAME: 'my-prop' @@ -72,7 +71,8 @@ env: # The following helper functions to get a value for a given property key from the result have a uniform # API (property key as sole argument) so that the actual test code can be identical (and thereby reusable # with YAML anchors) for different result types. This leads to some assumptions which might seem - # a little odd, like the JSON result file must be called $JSON_OUTPUT_FILE. + # a little odd, like variable JSON_OUTPUT_FILE must contain the name of the JSON result instead of passing + # that name as an argument. # Expects JSON as stdin and extracts the value for key $1. getJsonValue() { @@ -133,20 +133,20 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} ############################################################ - - name: 'test (should fail): invalid input: resultType' + - name: '[act] test (should fail): invalid input: resultType' id: error-input-resultType continue-on-error: true uses: ./ with: file: test/resources/test.properties resultType: rt1 - - name: 'validate "test (should fail): invalid input: resultType"' + - name: '[assert] test (should fail): invalid input: resultType' run: |- eval "$SHELL_SETUP" assertEquals '${{steps.error-input-resultType.outcome}}' failure ############################################################ - - name: 'test (should fail): resultType "output-named" without names' + - name: '[act] test (should fail): resultType "output-named" without names' id: error-resultType-output-without-names continue-on-error: true uses: ./ @@ -154,13 +154,13 @@ jobs: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: 'output-named:' - - name: 'validate "test (should fail): resultType "output-named" without names"' + - name: '[assert] test (should fail): resultType "output-named" without names' run: |- eval "$SHELL_SETUP" assertEquals '${{steps.error-resultType-output-without-names.outcome}}' failure ############################################################ - - name: 'test (should fail): resultType "env-named" without names' + - name: '[act] test (should fail): resultType "env-named" without names' id: error-resultType-env-without-names continue-on-error: true uses: ./ @@ -168,33 +168,33 @@ jobs: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: 'env-named:' - - name: 'validate "test (should fail): resultType "env-named" without names"' + - name: '[assert] test (should fail): resultType "env-named" without names' run: |- eval "$SHELL_SETUP" assertEquals '${{steps.error-resultType-env-without-names.outcome}}' failure ############################################################ - - name: 'test (should fail): resultType "output-named" without keys' + - name: '[act] test (should fail): resultType "output-named" without keys' id: error-resultType-output-without-keys continue-on-error: true uses: ./ with: file: test/resources/test.properties resultType: 'output-named:a b' - - name: 'validate "test (should fail): resultType "output-named" without keys"' + - name: '[assert] test (should fail): resultType "output-named" without keys' run: |- eval "$SHELL_SETUP" assertEquals '${{steps.error-resultType-output-without-keys.outcome}}' failure ############################################################ - - name: 'test (should fail): resultType "env-named" without keys' + - name: '[act] test (should fail): resultType "env-named" without keys' id: error-resultType-env-without-keys continue-on-error: true uses: ./ with: file: test/resources/test.properties resultType: 'env-named:a b' - - name: 'validate "test (should fail): resultType "env-named" without keys"' + - name: '[assert] test (should fail): resultType "env-named" without keys' run: |- eval "$SHELL_SETUP" assertEquals '${{steps.error-resultType-env-without-keys.outcome}}' failure @@ -203,13 +203,13 @@ jobs: # This tests a subset of test case multi-output, but with hard-coded encoding from property key to output # name (instead of re-implemented). # It also demonstrates how you'd usually use outputs with one environment variable per output. - - name: 'test: query multiple properties as action outputs (simple)' + - name: '[act] test: query multiple properties as action outputs (simple)' id: multi-output-simple uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}} dash-in-Key' - - name: 'validate "test: query multiple properties as action outputs (simple)"' + - name: '[assert] test: query multiple properties as action outputs (simple)' env: RESULT_SJV: ${{steps.multi-output-simple.outputs._sourceJavaVersion}} RESULT_JVMARGS: ${{steps.multi-output-simple.outputs._org-002Egradle-002Ejvmargs}} @@ -225,12 +225,12 @@ jobs: assertEquals "$RESULT_UNI" 'a«,»c' ############################################################ - - name: 'test: query all properties as action outputs' + - name: '[act] test: query all properties as action outputs' id: all-output uses: ./ with: file: test/resources/test.properties - - name: 'validate "test: query all properties as action outputs"' + - name: '[assert] test: query all properties as action outputs' env: RESULT_JSON: ${{toJSON(steps.all-output.outputs)}} RESULT_GETTER: encodeKeyAndGetEnvJsonValue @@ -265,13 +265,13 @@ jobs: getAndAssertEquals "$RESULT_GETTER" dash_3_4_8_1 $'---\n----\n--------\n-' ############################################################ - - name: 'test: query multiple properties as action outputs' + - name: '[act] test: query multiple properties as action outputs' id: multi-output uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' - - name: 'validate "test: query multiple properties as action outputs"' + - name: '[assert] test: query multiple properties as action outputs' env: RESULT_JSON: ${{toJSON(steps.multi-output.outputs)}} RESULT_GETTER: encodeKeyAndGetEnvJsonValue @@ -307,14 +307,14 @@ jobs: getAndAssertUndefined "$RESULT_GETTER" dash_3_4_8_1 ############################################################ - - name: 'test: query multiple properties as action outputs of given names' + - name: '[act] test: query multiple properties as action outputs of given names' id: multi-output-named uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: 'output-named:${{env.SELECTED_NAMES}}' - - name: 'validate "test: query multiple properties as action outputs of given names"' + - name: '[assert] test: query multiple properties as action outputs of given names' env: RESULT_JSON: ${{toJSON(steps.multi-output-named.outputs)}} # Output names specified as input are not subject to encoding (prefix "_", replace punctuation except "_"): @@ -337,14 +337,14 @@ jobs: getAndAssertUndefined "$RESULT_GETTER" dash_1 ############################################################ - - name: 'test: query multiple properties as action outputs of given name' + - name: '[act] test: query multiple properties as action outputs of given name' id: multi-output-named1 uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: 'output-named:${{env.SELECTED_SINGLE_NAME}}' - - name: 'validate "test: query multiple properties as action outputs of given name"' + - name: '[assert] test: query multiple properties as action outputs of given name' env: RESULT_JSON: ${{toJSON(steps.multi-output-named1.outputs)}} # Output names specified as input are not subject to encoding (prefix "_", replace punctuation except "_"): @@ -364,13 +364,13 @@ jobs: getAndAssertUndefined "$RESULT_GETTER" dash_1 ############################################################ - - name: 'test: query single property as action outputs' + - name: '[act] test: query single property as action outputs' id: single-output uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_SINGLE_KEY}}' - - name: 'validate "test: query multiple properties as action outputs"' + - name: '[assert] test: query multiple properties as action outputs' env: RESULT_JSON: ${{toJSON(steps.single-output.outputs)}} RESULT_GETTER: encodeKeyAndGetEnvJsonValue @@ -406,55 +406,92 @@ jobs: getAndAssertUndefined "$RESULT_GETTER" dash_3_4_8_1 ############################################################ - - name: 'test: query all properties as JSON action output' + - name: '[act] test: query all properties as JSON action output' id: all-json uses: ./ with: file: test/resources/test.properties resultType: json - - name: 'validate "test: query all properties as JSON action output"' + - name: '[assert] test: query all properties as JSON action output' env: RESULT_JSON: ${{steps.all-json.outputs.json}} RESULT_GETTER: getEnvJsonValue run: *validateAllImpl ############################################################ - - name: 'test: query multiple properties as JSON action output' + - name: '[act] test: query multiple properties as JSON action output' id: multiple-json uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: json - - name: 'validate "test: query multiple properties as JSON action output"' + - name: '[assert] test: query multiple properties as JSON action output' env: RESULT_JSON: ${{steps.multiple-json.outputs.json}} RESULT_GETTER: getEnvJsonValue run: *validateMultiImpl ############################################################ - - name: 'test: query all properties as JSON file' + - name: '[arrange] test: query multiple properties as JSON file (creating file)' + id: multiple-json-file-create-arrange + run: |- + parentDir=$(mktemp --directory --dry-run --tmpdir=. test_json-file_multi_create_XXXXXX) + file=$parentDir/out/result.json + printf 'file=%s\n' "$file" | tee -- "$GITHUB_OUTPUT" + # ⇒ action must create at least two parent dirs + - name: '[act] test: query multiple properties as JSON file (creating file)' uses: ./ with: file: test/resources/test.properties - resultType: 'json-file:${{env.JSON_OUTPUT_FILE}}' - - name: 'validate "test: query all properties as JSON file"' + keys: '${{env.SELECTED_KEYS}}' + resultType: 'json-file:${{steps.multiple-json-file-create-arrange.outputs.file}}' + - name: '[assert] test: query multiple properties as JSON file (creating file)' env: + JSON_OUTPUT_FILE: ${{steps.multiple-json-file-create-arrange.outputs.file}} RESULT_GETTER: getFileJsonValue - run: *validateAllImpl + run: *validateMultiImpl ############################################################ - - name: 'test: query multiple properties as JSON file' + - name: '[arrange] test: query multiple properties as JSON file (overwriting file)' + id: multiple-json-file-overwrite-arrange + run: |- + file=$(mktemp --tmpdir=. test_json-file_multi_overwrite_XXXXXX.json) + # Write longer contents than the expected output. ⇒ If the action does not truncate, there will be + # non-parseable remains and parsing in function getFileJsonValue will fail. + for ((i=0; i<100; i++)); do printf 'This is ¬ JS}ON. '; done >"$file" + ls -lAF -- "$file" + printf 'file=%s\n' "$file" | tee -- "$GITHUB_OUTPUT" + - name: '[act] test: query multiple properties as JSON file (overwriting file)' uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' - resultType: 'json-file:${{env.JSON_OUTPUT_FILE}}' - - name: 'validate "test: query multiple properties as JSON file"' + resultType: 'json-file:${{steps.multiple-json-file-overwrite-arrange.outputs.file}}' + - name: '[assert] test: query multiple properties as JSON file (overwriting file)' env: + JSON_OUTPUT_FILE: ${{steps.multiple-json-file-overwrite-arrange.outputs.file}} RESULT_GETTER: getFileJsonValue run: *validateMultiImpl + ############################################################ + - name: '[arrange] test: query all properties as JSON file' + id: all-json-file-arrange + # Distinction between creating and overwriting need not be tested again. + # This test just uses an existing empty file. + run: |- + printf 'file=%s\n' "$(mktemp --tmpdir=. test_json-file_all_XXXXXX.json)" | tee -- "$GITHUB_OUTPUT" + - name: '[act] test: query all properties as JSON file' + uses: ./ + with: + file: test/resources/test.properties + resultType: 'json-file:${{steps.all-json-file-arrange.outputs.file}}' + - name: '[assert] test: query all properties as JSON file' + env: + JSON_OUTPUT_FILE: ${{steps.all-json-file-arrange.outputs.file}} + RESULT_GETTER: getFileJsonValue + run: *validateAllImpl + # Each test which sets environment variables is a separate job to be independant of other tests. ############################################################ @@ -462,12 +499,12 @@ jobs: runs-on: ubuntu-latest steps: - *checkoutStep - - name: 'test: query all properties as environment variables' + - name: '[act] test: query all properties as environment variables' uses: ./ with: file: test/resources/test.properties resultType: env - - name: 'validate "test: query all properties as environment variables"' + - name: '[assert] test: query all properties as environment variables' env: RESULT_GETTER: getEnv run: *validateAllImpl @@ -477,13 +514,13 @@ jobs: runs-on: ubuntu-latest steps: - *checkoutStep - - name: 'test: query multiple properties as environment variables' + - name: '[act] test: query multiple properties as environment variables' uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: env - - name: 'validate "test: query multiple properties as environment variables"' + - name: '[assert] test: query multiple properties as environment variables' env: RESULT_GETTER: getEnv run: *validateMultiImpl @@ -493,13 +530,13 @@ jobs: runs-on: ubuntu-latest steps: - *checkoutStep - - name: 'test: query multiple properties as environment variables of given names' + - name: '[act] test: query multiple properties as environment variables of given names' uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: 'env-named:${{env.SELECTED_NAMES}}' - - name: 'validate "test: query multiple properties as environment variables of given names"' + - name: '[assert] test: query multiple properties as environment variables of given names' env: RESULT_GETTER: getEnv run: *validateMultiNamedImpl @@ -509,13 +546,13 @@ jobs: runs-on: ubuntu-latest steps: - *checkoutStep - - name: 'test: query multiple properties as environment variables of given names' + - name: '[act] test: query multiple properties as environment variables of given names' uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: 'env-named:${{env.SELECTED_SINGLE_NAME}}' - - name: 'validate "test: query multiple properties as environment variables of given names"' + - name: '[assert] test: query multiple properties as environment variables of given names' env: RESULT_GETTER: getEnv run: *validateMultiNamed1Impl @@ -525,13 +562,13 @@ jobs: runs-on: ubuntu-latest steps: - *checkoutStep - - name: 'test: query single property as environment variable' + - name: '[act] test: query single property as environment variable' uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_SINGLE_KEY}}' resultType: env - - name: 'validate "test: query single property as environment variable"' + - name: '[assert] test: query single property as environment variable' env: RESULT_GETTER: getEnv run: *validateSingleImpl @@ -541,12 +578,12 @@ jobs: runs-on: ubuntu-latest steps: - *checkoutStep - - name: 'test: query all properties as environment variables with prefix' + - name: '[act] test: query all properties as environment variables with prefix' uses: ./ with: file: test/resources/test.properties resultType: 'env:${{env.ENV_VAR_PREFIX}}' - - name: 'validate "test: query all properties as environment variables"' + - name: '[assert] test: query all properties as environment variables' env: RESULT_GETTER: getEnvWithPrefix run: *validateAllImpl @@ -556,13 +593,13 @@ jobs: runs-on: ubuntu-latest steps: - *checkoutStep - - name: 'test: query multiple properties as environment variables with prefix' + - name: '[act] test: query multiple properties as environment variables with prefix' uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: 'env:${{env.ENV_VAR_PREFIX}}' - - name: 'validate "test: query multiple properties as environment variables with prefix"' + - name: '[assert] test: query multiple properties as environment variables with prefix' env: RESULT_GETTER: getEnvWithPrefix run: *validateMultiImpl @@ -572,13 +609,13 @@ jobs: runs-on: ubuntu-latest steps: - *checkoutStep - - name: 'test: query single property as environment variable with prefix' + - name: '[act] test: query single property as environment variable with prefix' uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_SINGLE_KEY}}' resultType: 'env:${{env.ENV_VAR_PREFIX}}' - - name: 'validate "test: query single property as environment variable with prefix"' + - name: '[assert] test: query single property as environment variable with prefix' env: RESULT_GETTER: getEnvWithPrefix run: *validateSingleImpl From 6e12b15df3ef100549b2b76149704c20c2e316fa Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Fri, 15 May 2026 17:40:28 +0200 Subject: [PATCH 31/69] refactor tests: replace env variable assumptions in helper function with arguments --- .github/workflows/test.yml | 313 +++++++++++++++++++------------------ 1 file changed, 157 insertions(+), 156 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 114aeed..75ffe72 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,7 +4,6 @@ defaults: run: shell: bash env: - ENV_VAR_PREFIX: PROP_TEST__ SELECTED_SINGLE_KEY: 'org.gradle.jvmargs' SELECTED_KEYS: 'sourceJavaVersion org.gradle.jvmargs unicode_escapes colon:in:Key' SELECTED_SINGLE_NAME: 'my-prop' @@ -13,8 +12,7 @@ env: NOT_FOUND_STATUS: 54 SHELL_SETUP: |- debugPrintf() { - local pattern=$1; shift - printf "::debug::${pattern}" "$@" >&2 + printf "$@" | sed 's/^/::debug::/g' >&2 } # Reads stdin and replaces one linefeed at the end of the input with "¶". Exits with non-zero status if that LF is @@ -38,26 +36,42 @@ env: fi } - # Queries a value by calling command $1 with the remaining args and expects it to exit with status $NOT_FOUND_STATUS. - getAndAssertEquals() { + # The last argument is the expected result. All other arguments are treated as a command (with arguments) + # to be evaluated which outputs the actual result. Both results are then asserted to be equal. + # + # Also, unlike directly using the form + # assertEquals "$(eval "$RESULT_GETTER 'key with spaces')" 'expected value' + # this function does not swallow a non-zero exit status in $RESULT_GETTER (but returns with that error + # status and skips the assertion). + # + # Of the arguments which form the command, only the first is evaled, the rest is treated literally. + # That odd behavior is useful for how the steps for similar test cases are written: They require + # different ways to get a result; for example, one needs `getJsonValue "$RESULT"` and another one + # `getJsonValue "$(<"$RESULT_FILE")`; this difference is moved to environment variables in order to keep + # the script identical, so it can be reused with a YAML anchor. + # Example usage: evalAndAssertEquals "$RESULT_GETTER" 'key with spaces' 'expected value' + # with RESULT_GETTER set to 'getJsonValue "$RESULT"'. + evalAndAssertEquals() { local getterFunction=$1; shift - local key=$1; shift - local expectedValue=$1; shift - local value st - # Invocation of $getterFunction must not ignore its return status. This is why tests use this function in the form - # getAndAssertEquals $RESULT_GETTER "$key" "$expectedValue" - # instead of - # assertEquals "$($RESULT_GETTER "$key")" "$expectedValue" - value="$(set -o pipefail; "$getterFunction" "$key" | replaceLastLF)" + local args=("$@") + local expectedValue="${args[@]:${#args[@]}-1}" + args=("${args[@]:0:${#args[@]}-1}") + + local quotedArgs value + quotedArgs=$(printf ' %q' "${args[@]}") + eval "value=\$(set -o pipefail; ${getterFunction}${quotedArgs} | replaceLastLF)" expectedValue="$(printf '%s\n' "$expectedValue" | replaceLastLF)" assertEquals "$value" "$expectedValue" } # Queries a value by calling command $1 with the remaining args and expects it to exit with status $NOT_FOUND_STATUS. - getAndAssertUndefined() { + # The command is handled like in →evalAndAssertEquals (except that there is no expected value as last argument which + # is not passed to the command). + evalAndAssertUndefined() { local getterFunction=$1; shift - local value st - if value="$("$getterFunction" "$@")"; then + local quotedArgs value st + quotedArgs=$(printf ' %q' "$@") + if eval "value=\$(${getterFunction}${quotedArgs})"; then printf 'assertion error: %s == "%s", expected undefined\n' "$*" "$value" >&2 return 1 elif [ ${st-$?} -eq "$NOT_FOUND_STATUS" ]; then @@ -68,30 +82,21 @@ env: fi } - # The following helper functions to get a value for a given property key from the result have a uniform - # API (property key as sole argument) so that the actual test code can be identical (and thereby reusable - # with YAML anchors) for different result types. This leads to some assumptions which might seem - # a little odd, like variable JSON_OUTPUT_FILE must contain the name of the JSON result instead of passing - # that name as an argument. - - # Expects JSON as stdin and extracts the value for key $1. + # Extracts the value for key $2 from JSON $1. getJsonValue() { + local json=$1; shift local key=$1; shift - debugPrintf 'getJsonValue("%s")\n' "$key" - jq --arg k "$key" --raw-output '.[$k] // ("" | halt_error(env.NOT_FOUND_STATUS | tonumber))' + debugPrintf 'getJsonValue("%s", "%s")\n' "$json" "$key" + jq --arg k "$key" --raw-output '.[$k] // ("" | halt_error(env.NOT_FOUND_STATUS | tonumber))' <<<"$json" } - # Expects JSON in variable RESULT_JSON and extracts the value for key $1. - getEnvJsonValue() { - getJsonValue "$@" <<<"$RESULT_JSON" - } - - # Expects JSON in variable RESULT_JSON and extracts the value for key . - encodeKeyAndGetEnvJsonValue() { + # Extracts the value for key from JSON $2. + encodeKeyAndGetJsonValue() { + local json=$1; shift local key=$1; shift local encodedKey encodedKey="_$(perl -pe 's=((?!_)[[:punct:]])= sprintf("-%04X", ord($1)) =ge' <<<"$key")" - getEnvJsonValue "$encodedKey" "$@" + getJsonValue "$json" "$encodedKey" "$@" } # Prints the value of the environment variable set for key $1 (when not using a prefix). @@ -109,15 +114,11 @@ env: fi } - # Prints the value of the environment variable set for key $1 when using a prefix. + # Prints the value of the environment variable set for key $2 when using a prefix $1. getEnvWithPrefix() { + local prefix=$1; shift local key=$1; shift - getEnv "${ENV_VAR_PREFIX}${key}" "$@" - } - - getFileJsonValue() { - debugPrintf 'getFileJsonValue("%s") with file contents "%s"\n' "$*" "$(<"$JSON_OUTPUT_FILE")" - getJsonValue "$@" <"$JSON_OUTPUT_FILE" + getEnv "${prefix}${key}" "$@" } jobs: @@ -233,36 +234,36 @@ jobs: - name: '[assert] test: query all properties as action outputs' env: RESULT_JSON: ${{toJSON(steps.all-output.outputs)}} - RESULT_GETTER: encodeKeyAndGetEnvJsonValue + RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' run: &validateAllImpl |- eval "$SHELL_SETUP" - getAndAssertEquals "$RESULT_GETTER" sourceJavaVersion '21' - getAndAssertEquals "$RESULT_GETTER" targetJavaVersion '17' - getAndAssertEquals "$RESULT_GETTER" org.gradle.jvmargs '-ea -showversion' - getAndAssertEquals "$RESULT_GETTER" colon:in:Key 'c' - getAndAssertEquals "$RESULT_GETTER" colonInValue 'cv:' - getAndAssertEquals "$RESULT_GETTER" dash-in-Key 'd' - getAndAssertEquals "$RESULT_GETTER" dashInValue 'd-' - getAndAssertEquals "$RESULT_GETTER" empty '' - getAndAssertEquals "$RESULT_GETTER" null 'null' - getAndAssertEquals "$RESULT_GETTER" unicode_escapes 'a«,»c' - getAndAssertEquals "$RESULT_GETTER" whitespace_escapes $'A\tB\fC C\r\n\nD' - getAndAssertEquals "$RESULT_GETTER" trailing_linefeed_1 $'one trailing LF\n' - getAndAssertEquals "$RESULT_GETTER" trailing_linefeed_2 $'two trailing LFs\n\n' - getAndAssertEquals "$RESULT_GETTER" dash_1 '-' - getAndAssertEquals "$RESULT_GETTER" dash_2 '--' - getAndAssertEquals "$RESULT_GETTER" dash_3 '---' - getAndAssertEquals "$RESULT_GETTER" dash_4 '----' - getAndAssertEquals "$RESULT_GETTER" dash_5 '-----' - getAndAssertEquals "$RESULT_GETTER" dash_6 '------' - getAndAssertEquals "$RESULT_GETTER" dash_7 '-------' - getAndAssertEquals "$RESULT_GETTER" dash_8 '--------' - getAndAssertEquals "$RESULT_GETTER" dash_9 '---------' - getAndAssertEquals "$RESULT_GETTER" dash_10 '----------' - getAndAssertEquals "$RESULT_GETTER" dash_11 '-----------' - getAndAssertEquals "$RESULT_GETTER" dash_12 '------------' - getAndAssertEquals "$RESULT_GETTER" dash_3_4_1 $'---\n----\n-' - getAndAssertEquals "$RESULT_GETTER" dash_3_4_8_1 $'---\n----\n--------\n-' + evalAndAssertEquals "$RESULT_GETTER" sourceJavaVersion '21' + evalAndAssertEquals "$RESULT_GETTER" targetJavaVersion '17' + evalAndAssertEquals "$RESULT_GETTER" org.gradle.jvmargs '-ea -showversion' + evalAndAssertEquals "$RESULT_GETTER" colon:in:Key 'c' + evalAndAssertEquals "$RESULT_GETTER" colonInValue 'cv:' + evalAndAssertEquals "$RESULT_GETTER" dash-in-Key 'd' + evalAndAssertEquals "$RESULT_GETTER" dashInValue 'd-' + evalAndAssertEquals "$RESULT_GETTER" empty '' + evalAndAssertEquals "$RESULT_GETTER" null 'null' + evalAndAssertEquals "$RESULT_GETTER" unicode_escapes 'a«,»c' + evalAndAssertEquals "$RESULT_GETTER" whitespace_escapes $'A\tB\fC C\r\n\nD' + evalAndAssertEquals "$RESULT_GETTER" trailing_linefeed_1 $'one trailing LF\n' + evalAndAssertEquals "$RESULT_GETTER" trailing_linefeed_2 $'two trailing LFs\n\n' + evalAndAssertEquals "$RESULT_GETTER" dash_1 '-' + evalAndAssertEquals "$RESULT_GETTER" dash_2 '--' + evalAndAssertEquals "$RESULT_GETTER" dash_3 '---' + evalAndAssertEquals "$RESULT_GETTER" dash_4 '----' + evalAndAssertEquals "$RESULT_GETTER" dash_5 '-----' + evalAndAssertEquals "$RESULT_GETTER" dash_6 '------' + evalAndAssertEquals "$RESULT_GETTER" dash_7 '-------' + evalAndAssertEquals "$RESULT_GETTER" dash_8 '--------' + evalAndAssertEquals "$RESULT_GETTER" dash_9 '---------' + evalAndAssertEquals "$RESULT_GETTER" dash_10 '----------' + evalAndAssertEquals "$RESULT_GETTER" dash_11 '-----------' + evalAndAssertEquals "$RESULT_GETTER" dash_12 '------------' + evalAndAssertEquals "$RESULT_GETTER" dash_3_4_1 $'---\n----\n-' + evalAndAssertEquals "$RESULT_GETTER" dash_3_4_8_1 $'---\n----\n--------\n-' ############################################################ - name: '[act] test: query multiple properties as action outputs' @@ -274,37 +275,37 @@ jobs: - name: '[assert] test: query multiple properties as action outputs' env: RESULT_JSON: ${{toJSON(steps.multi-output.outputs)}} - RESULT_GETTER: encodeKeyAndGetEnvJsonValue + RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' run: &validateMultiImpl |- eval "$SHELL_SETUP" - getAndAssertEquals "$RESULT_GETTER" sourceJavaVersion '21' - getAndAssertEquals "$RESULT_GETTER" org.gradle.jvmargs '-ea -showversion' - getAndAssertEquals "$RESULT_GETTER" colon:in:Key 'c' - getAndAssertEquals "$RESULT_GETTER" unicode_escapes 'a«,»c' - - getAndAssertUndefined "$RESULT_GETTER" targetJavaVersion - getAndAssertUndefined "$RESULT_GETTER" colonInValue - getAndAssertUndefined "$RESULT_GETTER" dash-in-Key - getAndAssertUndefined "$RESULT_GETTER" dashInValue - getAndAssertUndefined "$RESULT_GETTER" empty - getAndAssertUndefined "$RESULT_GETTER" null - getAndAssertUndefined "$RESULT_GETTER" whitespace_escapes - getAndAssertUndefined "$RESULT_GETTER" trailing_linefeed_1 - getAndAssertUndefined "$RESULT_GETTER" trailing_linefeed_2 - getAndAssertUndefined "$RESULT_GETTER" dash_1 - getAndAssertUndefined "$RESULT_GETTER" dash_2 - getAndAssertUndefined "$RESULT_GETTER" dash_3 - getAndAssertUndefined "$RESULT_GETTER" dash_4 - getAndAssertUndefined "$RESULT_GETTER" dash_5 - getAndAssertUndefined "$RESULT_GETTER" dash_6 - getAndAssertUndefined "$RESULT_GETTER" dash_7 - getAndAssertUndefined "$RESULT_GETTER" dash_8 - getAndAssertUndefined "$RESULT_GETTER" dash_9 - getAndAssertUndefined "$RESULT_GETTER" dash_10 - getAndAssertUndefined "$RESULT_GETTER" dash_11 - getAndAssertUndefined "$RESULT_GETTER" dash_12 - getAndAssertUndefined "$RESULT_GETTER" dash_3_4_1 - getAndAssertUndefined "$RESULT_GETTER" dash_3_4_8_1 + evalAndAssertEquals "$RESULT_GETTER" sourceJavaVersion '21' + evalAndAssertEquals "$RESULT_GETTER" org.gradle.jvmargs '-ea -showversion' + evalAndAssertEquals "$RESULT_GETTER" colon:in:Key 'c' + evalAndAssertEquals "$RESULT_GETTER" unicode_escapes 'a«,»c' + + evalAndAssertUndefined "$RESULT_GETTER" targetJavaVersion + evalAndAssertUndefined "$RESULT_GETTER" colonInValue + evalAndAssertUndefined "$RESULT_GETTER" dash-in-Key + evalAndAssertUndefined "$RESULT_GETTER" dashInValue + evalAndAssertUndefined "$RESULT_GETTER" empty + evalAndAssertUndefined "$RESULT_GETTER" null + evalAndAssertUndefined "$RESULT_GETTER" whitespace_escapes + evalAndAssertUndefined "$RESULT_GETTER" trailing_linefeed_1 + evalAndAssertUndefined "$RESULT_GETTER" trailing_linefeed_2 + evalAndAssertUndefined "$RESULT_GETTER" dash_1 + evalAndAssertUndefined "$RESULT_GETTER" dash_2 + evalAndAssertUndefined "$RESULT_GETTER" dash_3 + evalAndAssertUndefined "$RESULT_GETTER" dash_4 + evalAndAssertUndefined "$RESULT_GETTER" dash_5 + evalAndAssertUndefined "$RESULT_GETTER" dash_6 + evalAndAssertUndefined "$RESULT_GETTER" dash_7 + evalAndAssertUndefined "$RESULT_GETTER" dash_8 + evalAndAssertUndefined "$RESULT_GETTER" dash_9 + evalAndAssertUndefined "$RESULT_GETTER" dash_10 + evalAndAssertUndefined "$RESULT_GETTER" dash_11 + evalAndAssertUndefined "$RESULT_GETTER" dash_12 + evalAndAssertUndefined "$RESULT_GETTER" dash_3_4_1 + evalAndAssertUndefined "$RESULT_GETTER" dash_3_4_8_1 ############################################################ - name: '[act] test: query multiple properties as action outputs of given names' @@ -318,23 +319,23 @@ jobs: env: RESULT_JSON: ${{toJSON(steps.multi-output-named.outputs)}} # Output names specified as input are not subject to encoding (prefix "_", replace punctuation except "_"): - RESULT_GETTER: getEnvJsonValue + RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' run: &validateMultiNamedImpl |- eval "$SHELL_SETUP" - getAndAssertEquals "$RESULT_GETTER" java-version '21' - getAndAssertEquals "$RESULT_GETTER" jvm_args '-ea -showversion' - getAndAssertEquals "$RESULT_GETTER" colon 'c' - getAndAssertEquals "$RESULT_GETTER" unicode 'a«,»c' + evalAndAssertEquals "$RESULT_GETTER" java-version '21' + evalAndAssertEquals "$RESULT_GETTER" jvm_args '-ea -showversion' + evalAndAssertEquals "$RESULT_GETTER" colon 'c' + evalAndAssertEquals "$RESULT_GETTER" unicode 'a«,»c' # The outputs with the default names are not set: - getAndAssertUndefined "$RESULT_GETTER" sourceJavaVersion - getAndAssertUndefined "$RESULT_GETTER" org.gradle.jvmargs - getAndAssertUndefined "$RESULT_GETTER" colon:in:Key - getAndAssertUndefined "$RESULT_GETTER" unicode_escapes + evalAndAssertUndefined "$RESULT_GETTER" sourceJavaVersion + evalAndAssertUndefined "$RESULT_GETTER" org.gradle.jvmargs + evalAndAssertUndefined "$RESULT_GETTER" colon:in:Key + evalAndAssertUndefined "$RESULT_GETTER" unicode_escapes # Outputs for non-selected keys are not set, for example: - getAndAssertUndefined "$RESULT_GETTER" colonInValue - getAndAssertUndefined "$RESULT_GETTER" dash_1 + evalAndAssertUndefined "$RESULT_GETTER" colonInValue + evalAndAssertUndefined "$RESULT_GETTER" dash_1 ############################################################ - name: '[act] test: query multiple properties as action outputs of given name' @@ -348,20 +349,20 @@ jobs: env: RESULT_JSON: ${{toJSON(steps.multi-output-named1.outputs)}} # Output names specified as input are not subject to encoding (prefix "_", replace punctuation except "_"): - RESULT_GETTER: getEnvJsonValue + RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' run: &validateMultiNamed1Impl |- eval "$SHELL_SETUP" - getAndAssertEquals "$RESULT_GETTER" my-prop 'c' + evalAndAssertEquals "$RESULT_GETTER" my-prop 'c' # The outputs with the default names are not set: - getAndAssertUndefined "$RESULT_GETTER" sourceJavaVersion - getAndAssertUndefined "$RESULT_GETTER" org.gradle.jvmargs - getAndAssertUndefined "$RESULT_GETTER" colon:in:Key - getAndAssertUndefined "$RESULT_GETTER" unicode_escapes + evalAndAssertUndefined "$RESULT_GETTER" sourceJavaVersion + evalAndAssertUndefined "$RESULT_GETTER" org.gradle.jvmargs + evalAndAssertUndefined "$RESULT_GETTER" colon:in:Key + evalAndAssertUndefined "$RESULT_GETTER" unicode_escapes # Outputs for non-selected keys are not set, for example: - getAndAssertUndefined "$RESULT_GETTER" colonInValue - getAndAssertUndefined "$RESULT_GETTER" dash_1 + evalAndAssertUndefined "$RESULT_GETTER" colonInValue + evalAndAssertUndefined "$RESULT_GETTER" dash_1 ############################################################ - name: '[act] test: query single property as action outputs' @@ -373,37 +374,37 @@ jobs: - name: '[assert] test: query multiple properties as action outputs' env: RESULT_JSON: ${{toJSON(steps.single-output.outputs)}} - RESULT_GETTER: encodeKeyAndGetEnvJsonValue + RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' run: &validateSingleImpl |- eval "$SHELL_SETUP" - getAndAssertEquals "$RESULT_GETTER" org.gradle.jvmargs '-ea -showversion' - - getAndAssertUndefined "$RESULT_GETTER" sourceJavaVersion - getAndAssertUndefined "$RESULT_GETTER" targetJavaVersion - getAndAssertUndefined "$RESULT_GETTER" colon:in:Key - getAndAssertUndefined "$RESULT_GETTER" colonInValue - getAndAssertUndefined "$RESULT_GETTER" dash-in-Key - getAndAssertUndefined "$RESULT_GETTER" dashInValue - getAndAssertUndefined "$RESULT_GETTER" empty - getAndAssertUndefined "$RESULT_GETTER" null - getAndAssertUndefined "$RESULT_GETTER" whitespace_escapes - getAndAssertUndefined "$RESULT_GETTER" trailing_linefeed_1 - getAndAssertUndefined "$RESULT_GETTER" trailing_linefeed_2 - getAndAssertUndefined "$RESULT_GETTER" unicode_escapes - getAndAssertUndefined "$RESULT_GETTER" dash_1 - getAndAssertUndefined "$RESULT_GETTER" dash_2 - getAndAssertUndefined "$RESULT_GETTER" dash_3 - getAndAssertUndefined "$RESULT_GETTER" dash_4 - getAndAssertUndefined "$RESULT_GETTER" dash_5 - getAndAssertUndefined "$RESULT_GETTER" dash_6 - getAndAssertUndefined "$RESULT_GETTER" dash_7 - getAndAssertUndefined "$RESULT_GETTER" dash_8 - getAndAssertUndefined "$RESULT_GETTER" dash_9 - getAndAssertUndefined "$RESULT_GETTER" dash_10 - getAndAssertUndefined "$RESULT_GETTER" dash_11 - getAndAssertUndefined "$RESULT_GETTER" dash_12 - getAndAssertUndefined "$RESULT_GETTER" dash_3_4_1 - getAndAssertUndefined "$RESULT_GETTER" dash_3_4_8_1 + evalAndAssertEquals "$RESULT_GETTER" org.gradle.jvmargs '-ea -showversion' + + evalAndAssertUndefined "$RESULT_GETTER" sourceJavaVersion + evalAndAssertUndefined "$RESULT_GETTER" targetJavaVersion + evalAndAssertUndefined "$RESULT_GETTER" colon:in:Key + evalAndAssertUndefined "$RESULT_GETTER" colonInValue + evalAndAssertUndefined "$RESULT_GETTER" dash-in-Key + evalAndAssertUndefined "$RESULT_GETTER" dashInValue + evalAndAssertUndefined "$RESULT_GETTER" empty + evalAndAssertUndefined "$RESULT_GETTER" null + evalAndAssertUndefined "$RESULT_GETTER" whitespace_escapes + evalAndAssertUndefined "$RESULT_GETTER" trailing_linefeed_1 + evalAndAssertUndefined "$RESULT_GETTER" trailing_linefeed_2 + evalAndAssertUndefined "$RESULT_GETTER" unicode_escapes + evalAndAssertUndefined "$RESULT_GETTER" dash_1 + evalAndAssertUndefined "$RESULT_GETTER" dash_2 + evalAndAssertUndefined "$RESULT_GETTER" dash_3 + evalAndAssertUndefined "$RESULT_GETTER" dash_4 + evalAndAssertUndefined "$RESULT_GETTER" dash_5 + evalAndAssertUndefined "$RESULT_GETTER" dash_6 + evalAndAssertUndefined "$RESULT_GETTER" dash_7 + evalAndAssertUndefined "$RESULT_GETTER" dash_8 + evalAndAssertUndefined "$RESULT_GETTER" dash_9 + evalAndAssertUndefined "$RESULT_GETTER" dash_10 + evalAndAssertUndefined "$RESULT_GETTER" dash_11 + evalAndAssertUndefined "$RESULT_GETTER" dash_12 + evalAndAssertUndefined "$RESULT_GETTER" dash_3_4_1 + evalAndAssertUndefined "$RESULT_GETTER" dash_3_4_8_1 ############################################################ - name: '[act] test: query all properties as JSON action output' @@ -415,7 +416,7 @@ jobs: - name: '[assert] test: query all properties as JSON action output' env: RESULT_JSON: ${{steps.all-json.outputs.json}} - RESULT_GETTER: getEnvJsonValue + RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' run: *validateAllImpl ############################################################ @@ -429,7 +430,7 @@ jobs: - name: '[assert] test: query multiple properties as JSON action output' env: RESULT_JSON: ${{steps.multiple-json.outputs.json}} - RESULT_GETTER: getEnvJsonValue + RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' run: *validateMultiImpl ############################################################ @@ -449,7 +450,7 @@ jobs: - name: '[assert] test: query multiple properties as JSON file (creating file)' env: JSON_OUTPUT_FILE: ${{steps.multiple-json-file-create-arrange.outputs.file}} - RESULT_GETTER: getFileJsonValue + RESULT_GETTER: 'getJsonValue "$(<"$JSON_OUTPUT_FILE")"' run: *validateMultiImpl ############################################################ @@ -471,7 +472,7 @@ jobs: - name: '[assert] test: query multiple properties as JSON file (overwriting file)' env: JSON_OUTPUT_FILE: ${{steps.multiple-json-file-overwrite-arrange.outputs.file}} - RESULT_GETTER: getFileJsonValue + RESULT_GETTER: 'getJsonValue "$(<"$JSON_OUTPUT_FILE")"' run: *validateMultiImpl ############################################################ @@ -489,7 +490,7 @@ jobs: - name: '[assert] test: query all properties as JSON file' env: JSON_OUTPUT_FILE: ${{steps.all-json-file-arrange.outputs.file}} - RESULT_GETTER: getFileJsonValue + RESULT_GETTER: 'getJsonValue "$(<"$JSON_OUTPUT_FILE")"' run: *validateAllImpl @@ -582,10 +583,10 @@ jobs: uses: ./ with: file: test/resources/test.properties - resultType: 'env:${{env.ENV_VAR_PREFIX}}' + resultType: 'env:ALL_PROPS__' - name: '[assert] test: query all properties as environment variables' env: - RESULT_GETTER: getEnvWithPrefix + RESULT_GETTER: 'getEnvWithPrefix ALL_PROPS__' run: *validateAllImpl ############################################################ @@ -598,10 +599,10 @@ jobs: with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' - resultType: 'env:${{env.ENV_VAR_PREFIX}}' + resultType: 'env:MULTI_PROPS__' - name: '[assert] test: query multiple properties as environment variables with prefix' env: - RESULT_GETTER: getEnvWithPrefix + RESULT_GETTER: 'getEnvWithPrefix MULTI_PROPS__' run: *validateMultiImpl ############################################################ @@ -614,8 +615,8 @@ jobs: with: file: test/resources/test.properties keys: '${{env.SELECTED_SINGLE_KEY}}' - resultType: 'env:${{env.ENV_VAR_PREFIX}}' + resultType: 'env:PROP__' - name: '[assert] test: query single property as environment variable with prefix' env: - RESULT_GETTER: getEnvWithPrefix + RESULT_GETTER: 'getEnvWithPrefix PROP__' run: *validateSingleImpl From 9654c880a6b3e5ee3cecaea9d6c10688baefc75e Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Fri, 15 May 2026 18:19:01 +0200 Subject: [PATCH 32/69] test: add property with space in name --- .github/workflows/test.yml | 3 +++ test/resources/test.properties | 1 + 2 files changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 75ffe72..f8c8a7c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -240,6 +240,7 @@ jobs: evalAndAssertEquals "$RESULT_GETTER" sourceJavaVersion '21' evalAndAssertEquals "$RESULT_GETTER" targetJavaVersion '17' evalAndAssertEquals "$RESULT_GETTER" org.gradle.jvmargs '-ea -showversion' + evalAndAssertEquals "$RESULT_GETTER" 'space in Key' 'sp' evalAndAssertEquals "$RESULT_GETTER" colon:in:Key 'c' evalAndAssertEquals "$RESULT_GETTER" colonInValue 'cv:' evalAndAssertEquals "$RESULT_GETTER" dash-in-Key 'd' @@ -284,6 +285,7 @@ jobs: evalAndAssertEquals "$RESULT_GETTER" unicode_escapes 'a«,»c' evalAndAssertUndefined "$RESULT_GETTER" targetJavaVersion + evalAndAssertUndefined "$RESULT_GETTER" 'space in Key' evalAndAssertUndefined "$RESULT_GETTER" colonInValue evalAndAssertUndefined "$RESULT_GETTER" dash-in-Key evalAndAssertUndefined "$RESULT_GETTER" dashInValue @@ -382,6 +384,7 @@ jobs: evalAndAssertUndefined "$RESULT_GETTER" sourceJavaVersion evalAndAssertUndefined "$RESULT_GETTER" targetJavaVersion evalAndAssertUndefined "$RESULT_GETTER" colon:in:Key + evalAndAssertUndefined "$RESULT_GETTER" 'space in Key' evalAndAssertUndefined "$RESULT_GETTER" colonInValue evalAndAssertUndefined "$RESULT_GETTER" dash-in-Key evalAndAssertUndefined "$RESULT_GETTER" dashInValue diff --git a/test/resources/test.properties b/test/resources/test.properties index 64bd76c..9bb4b82 100644 --- a/test/resources/test.properties +++ b/test/resources/test.properties @@ -2,6 +2,7 @@ sourceJavaVersion : 21 targetJavaVersion : 17 org.gradle.jvmargs: -ea -showversion +space\ in\ Key: sp colon\:in\:Key: c colonInValue: cv: dash-in-Key: d From f49ad5c8b7a2905ebf1f89ba1b430d89a97a5f4e Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Fri, 15 May 2026 19:03:11 +0200 Subject: [PATCH 33/69] cosmetic: write space char unescaped in JSON values --- Action.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Action.java b/Action.java index dbdf27d..f9d6066 100644 --- a/Action.java +++ b/Action.java @@ -348,7 +348,9 @@ public static String toJson(Properties props) { private static void appendJsonString(StringBuilder buffer, String s) { for (char c : s.toCharArray()) { - if (c == '"') { + if (c == ' ') { + buffer.append(c); + } else if (c == '"') { buffer.append('\\').append('"'); } else if (c == '\t') { buffer.append('\\').append('t'); From f5b019192c0f7341934e347ecc12fcc0616e547a Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Fri, 15 May 2026 19:56:43 +0200 Subject: [PATCH 34/69] add output "error" --- .github/workflows/test.yml | 30 ++++++++++--- Action.java | 91 ++++++++++++++++++++++++++++++-------- action.yml | 1 + 3 files changed, 98 insertions(+), 24 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f8c8a7c..bd69ed8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -142,9 +142,13 @@ jobs: file: test/resources/test.properties resultType: rt1 - name: '[assert] test (should fail): invalid input: resultType' + env: + STATUS: ${{steps.error-input-resultType.outcome}} + ERROR: ${{steps.error-input-resultType.outputs.error}} run: |- eval "$SHELL_SETUP" - assertEquals '${{steps.error-input-resultType.outcome}}' failure + assertEquals "$STATUS" failure + assertEquals "$ERROR" 'invalid resultType: rt1' ############################################################ - name: '[act] test (should fail): resultType "output-named" without names' @@ -156,9 +160,13 @@ jobs: keys: '${{env.SELECTED_KEYS}}' resultType: 'output-named:' - name: '[assert] test (should fail): resultType "output-named" without names' + env: + STATUS: ${{steps.error-resultType-output-without-names.outcome}} + ERROR: ${{steps.error-resultType-output-without-names.outputs.error}} run: |- eval "$SHELL_SETUP" - assertEquals '${{steps.error-resultType-output-without-names.outcome}}' failure + assertEquals "$STATUS" failure + assertEquals "$ERROR" 'invalid resultType output-named: (missing argument)' ############################################################ - name: '[act] test (should fail): resultType "env-named" without names' @@ -170,9 +178,13 @@ jobs: keys: '${{env.SELECTED_KEYS}}' resultType: 'env-named:' - name: '[assert] test (should fail): resultType "env-named" without names' + env: + STATUS: ${{steps.error-resultType-env-without-names.outcome}} + ERROR: ${{steps.error-resultType-env-without-names.outputs.error}} run: |- eval "$SHELL_SETUP" - assertEquals '${{steps.error-resultType-env-without-names.outcome}}' failure + assertEquals "$STATUS" failure + assertEquals "$ERROR" 'invalid resultType env-named: (missing argument)' ############################################################ - name: '[act] test (should fail): resultType "output-named" without keys' @@ -183,9 +195,13 @@ jobs: file: test/resources/test.properties resultType: 'output-named:a b' - name: '[assert] test (should fail): resultType "output-named" without keys' + env: + STATUS: ${{steps.error-resultType-output-without-keys.outcome}} + ERROR: ${{steps.error-resultType-output-without-keys.outputs.error}} run: |- eval "$SHELL_SETUP" - assertEquals '${{steps.error-resultType-output-without-keys.outcome}}' failure + assertEquals "$STATUS" failure + assertEquals "$ERROR" 'invalid use of resultType output-named (missing keys)' ############################################################ - name: '[act] test (should fail): resultType "env-named" without keys' @@ -196,9 +212,13 @@ jobs: file: test/resources/test.properties resultType: 'env-named:a b' - name: '[assert] test (should fail): resultType "env-named" without keys' + env: + STATUS: ${{steps.error-resultType-env-without-keys.outcome}} + ERROR: ${{steps.error-resultType-env-without-keys.outputs.error}} run: |- eval "$SHELL_SETUP" - assertEquals '${{steps.error-resultType-env-without-keys.outcome}}' failure + assertEquals "$STATUS" failure + assertEquals "$ERROR" 'invalid use of resultType env-named (missing keys)' ############################################################ # This tests a subset of test case multi-output, but with hard-coded encoding from property key to output diff --git a/Action.java b/Action.java index f9d6066..94e1091 100644 --- a/Action.java +++ b/Action.java @@ -24,37 +24,83 @@ public class Action { private Action() {} static void main(String[] args) throws Exception { - Config config = Config.fromEnv(); - ResultWriter resultWriter = ResultWriter.of(config.resultType()); - for (String file : args) { - Properties properties = config.selectedKeys() == null ? Util.readProperties(file) - : Util.selectProperties(Util.readProperties(file), config.selectedKeys(), file); - resultWriter.write(properties, config); + try { + Config config = Config.fromEnv(); + ResultWriter resultWriter = ResultWriter.of(config.resultType()); + for (String file : args) { + Properties properties = config.selectedKeys() == null ? Util.readProperties(file) + : Util.selectProperties(Util.readProperties(file), config.selectedKeys(), file); + resultWriter.write(properties, config); + } + } catch (OutputException e) { + try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { + // This output is not documented + writer.write("error", e.getMessage()); + } + throw e; } } } +/** + * An exception for which an "error" output should be set. + */ +class OutputException extends RuntimeException { + public OutputException(String message, Throwable cause) { + super(message, cause); + } + + public static OutputException forIllegalArgument(String message) { + return new OutputException(message, new IllegalArgumentException(message)); + } +} + + +/** + * Configuration environment variable names and their corresponding action input names for error messages. + */ +enum ConfigVariable { + FILE("file"), // + KEYS("keys"), // + RESULT_TYPE("resultType"), // + KEY_SEPARATOR("keySeparator"), // + RESULT_NAME_SEPARATOR("resultNameSeparator"), // + OUTPUT_PREFIX(null); + + public final String inputName; + + private ConfigVariable(String inputName) { + this.inputName = inputName; + } + + @Override + public String toString() { + return inputName; + } +} + + record Config(List selectedKeys, String keySeparator, String resultTypeWithArg, String resultType, String resultTypeArg, String resultNameSeparator, String outputPrefix) { public static Config fromEnv() { // In order to keep the defaults DRY (in action.yml), the environment variables are all mandatory. - String keySeparator = Util.getRequiredEnv("KEY_SEPARATOR"); + String keySeparator = Util.getRequiredEnv(ConfigVariable.KEY_SEPARATOR); // System.getenv("KEYS") == null if set to empty string?! So this cannot be checked to be set if we want to allow empty // string: - String keysStr = System.getenv().getOrDefault("KEYS", ""); + String keysStr = System.getenv().getOrDefault(ConfigVariable.KEYS.name(), ""); Optional keys = Util.splitArray(keysStr, keySeparator); - String resultNameSeparator = Util.getRequiredEnv("RESULT_NAME_SEPARATOR"); + String resultNameSeparator = Util.getRequiredEnv(ConfigVariable.RESULT_NAME_SEPARATOR); // RESULT_TYPE format: "[:]" - String resultTypeWithArg = Util.getRequiredEnv("RESULT_TYPE"); + String resultTypeWithArg = Util.getRequiredEnv(ConfigVariable.RESULT_TYPE); Matcher matcher = Pattern.compile("([^:]+)(?::(.*))?").matcher(resultTypeWithArg); if (!matcher.matches()) { - throw new IllegalArgumentException("invalid resultType: " + resultTypeWithArg); + throw OutputException.forIllegalArgument("invalid " + ConfigVariable.RESULT_TYPE + ": " + resultTypeWithArg); } String resultType = matcher.group(1); String resultTypeArg = Optional.ofNullable(matcher.group(2)).orElse(""); - String outputPrefix = Util.getRequiredEnv("OUTPUT_PREFIX"); + String outputPrefix = Util.getRequiredEnv(ConfigVariable.OUTPUT_PREFIX); return new Config(keys.map(List::of).orElse(null), keySeparator, resultTypeWithArg, resultType, resultTypeArg, resultNameSeparator, outputPrefix); } @@ -62,14 +108,16 @@ public static Config fromEnv() { public String requiredResultTypeArg() { String arg = resultTypeArg(); if ("".equals(arg)) { - throw new IllegalArgumentException("invalid resultType " + resultTypeWithArg() + " (missing argument)"); + throw OutputException.forIllegalArgument( + "invalid " + ConfigVariable.RESULT_TYPE + " " + resultTypeWithArg() + " (missing argument)"); } return arg; } public void requireNoArg() { if (!"".equals(resultTypeArg())) { - throw new IllegalArgumentException("invalid resultType " + resultTypeWithArg() + " (non-empty argument)"); + throw OutputException.forIllegalArgument( + "invalid " + ConfigVariable.RESULT_TYPE + " " + resultTypeWithArg() + " (non-empty argument)"); } } } @@ -164,7 +212,7 @@ public static ResultWriter of(String inputName) { return rw; } } - throw new IllegalArgumentException("invalid result type: " + inputName); + throw OutputException.forIllegalArgument("invalid " + ConfigVariable.RESULT_TYPE + ": " + inputName); } public abstract void write(Properties props, Config config) throws IOException; @@ -172,14 +220,15 @@ public static ResultWriter of(String inputName) { private static void writeNamedImpl(Properties props, Config config, GitHubOutputFile gitHubOutputFile) throws IOException { List selectedKeys = config.selectedKeys(); if (selectedKeys == null) { - throw new IllegalArgumentException("invalid use of resultType " + config.resultType() + " (missing keys)"); + throw OutputException.forIllegalArgument( + "invalid use of " + ConfigVariable.RESULT_TYPE + " " + config.resultType() + " (missing keys)"); } @SuppressWarnings("java:S3655") // Sonar rule: "Optional value should only be accessed after calling isPresent()". // requiredResultTypeArg() is not empty, so splitArray returns non-empty String[] resultNames = Util.splitArray(config.requiredResultTypeArg(), config.resultNameSeparator()).get(); if (resultNames.length != selectedKeys.size() && resultNames.length != 1) { - throw new IllegalArgumentException("resultType " + config.resultTypeWithArg() + " has " + resultNames.length - + " arguments, but " + selectedKeys.size() + " keys are selected"); + throw OutputException.forIllegalArgument(ConfigVariable.RESULT_TYPE + " " + config.resultTypeWithArg() + " has " + + resultNames.length + " arguments, but " + selectedKeys.size() + " keys are selected"); } try (GitHubVariableWriter writer = gitHubOutputFile.open()) { for (int i = 0; i < selectedKeys.size(); i++) { @@ -280,11 +329,15 @@ private Util() {} public static String getRequiredEnv(String varName) { String value = System.getenv(varName); if (value == null || value.isEmpty()) { - throw new IllegalArgumentException("missing or empty environment variable " + varName); + throw OutputException.forIllegalArgument("missing or empty environment variable " + varName); } return value; } + public static String getRequiredEnv(ConfigVariable varName) { + return getRequiredEnv(varName.name()); + } + public static Optional splitArray(String arrayStr, String separator) { return arrayStr.isEmpty() ? Optional.empty() : Optional.of(arrayStr.split(Pattern.quote(separator), -1)); } diff --git a/action.yml b/action.yml index 6b530a1..36386cc 100644 --- a/action.yml +++ b/action.yml @@ -45,6 +45,7 @@ outputs: description: The (plain) property value. Only set in certain modes, see input `resultType`. json: description: All found properties matching the `keys` input as a single-line JSON object. Only set in certain modes, see input `resultType`. + #error: Not documented, because it should only be parsed by our tests. runs: using: docker From 77e2a34a6b9b4731e2ef2dab323401aa1b5d6f6d Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Fri, 15 May 2026 21:55:44 +0200 Subject: [PATCH 35/69] set additional "value" output only when it makes sense --- .github/workflows/test.yml | 44 ++++++++++++++++++++++++++++++++++++++ Action.java | 25 +++++++++++----------- action.yml | 2 +- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bd69ed8..5cd01e4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,7 @@ defaults: env: SELECTED_SINGLE_KEY: 'org.gradle.jvmargs' SELECTED_KEYS: 'sourceJavaVersion org.gradle.jvmargs unicode_escapes colon:in:Key' + SELECTED_KEYS_OTHER_ORDER: 'colon:in:Key sourceJavaVersion org.gradle.jvmargs unicode_escapes' SELECTED_SINGLE_NAME: 'my-prop' SELECTED_NAMES: 'java-version jvm_args unicode colon' @@ -285,6 +286,12 @@ jobs: evalAndAssertEquals "$RESULT_GETTER" dash_12 '------------' evalAndAssertEquals "$RESULT_GETTER" dash_3_4_1 $'---\n----\n-' evalAndAssertEquals "$RESULT_GETTER" dash_3_4_8_1 $'---\n----\n--------\n-' + - name: '[assert] test: query all properties as action outputs (additional "value" output not set)' + env: + RESULT_VALUE: ${{steps.all-output.outputs.value || 'U_N_S_E_T'}} + run: |- + eval "$SHELL_SETUP" + assertEquals "$RESULT_VALUE" U_N_S_E_T ############################################################ - name: '[act] test: query multiple properties as action outputs' @@ -293,6 +300,7 @@ jobs: with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' + - name: '[assert] test: query multiple properties as action outputs' env: RESULT_JSON: ${{toJSON(steps.multi-output.outputs)}} @@ -329,6 +337,32 @@ jobs: evalAndAssertUndefined "$RESULT_GETTER" dash_3_4_1 evalAndAssertUndefined "$RESULT_GETTER" dash_3_4_8_1 + - name: '[assert] test: query multiple properties as action outputs (additional "value" output)' + env: + RESULT_VALUE: ${{steps.multi-output.outputs.value}} + run: |- + eval "$SHELL_SETUP" + assertEquals "$RESULT_VALUE" 'c' # from last key "colon:in:Key" + + ############################################################ + - name: '[act] test: query multiple properties as action outputs (in another keys order)' + id: multi-output-2 + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS_OTHER_ORDER}}' + - name: '[assert] test: query multiple properties as action outputs (in another keys order)' + env: + RESULT_JSON: ${{toJSON(steps.multi-output-2.outputs)}} + RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' + run: *validateMultiImpl + - name: '[assert] test: query multiple properties as action outputs (in another keys order) (additional "value" output)' + env: + RESULT_VALUE: ${{steps.multi-output-2.outputs.value}} + run: |- + eval "$SHELL_SETUP" + assertEquals "$RESULT_VALUE" 'a«,»c' # from last key "unicode_escapes" + ############################################################ - name: '[act] test: query multiple properties as action outputs of given names' id: multi-output-named @@ -337,6 +371,7 @@ jobs: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: 'output-named:${{env.SELECTED_NAMES}}' + - name: '[assert] test: query multiple properties as action outputs of given names' env: RESULT_JSON: ${{toJSON(steps.multi-output-named.outputs)}} @@ -359,6 +394,13 @@ jobs: evalAndAssertUndefined "$RESULT_GETTER" colonInValue evalAndAssertUndefined "$RESULT_GETTER" dash_1 + - name: '[assert] test: query multiple properties as action outputs (additional "value" output not set)' + env: + RESULT_VALUE: ${{steps.multi-output-named.outputs.value || 'U_N_S_E_T'}} + run: |- + eval "$SHELL_SETUP" + assertEquals "$RESULT_VALUE" U_N_S_E_T + ############################################################ - name: '[act] test: query multiple properties as action outputs of given name' id: multi-output-named1 @@ -367,6 +409,7 @@ jobs: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: 'output-named:${{env.SELECTED_SINGLE_NAME}}' + - name: '[assert] test: query multiple properties as action outputs of given name' env: RESULT_JSON: ${{toJSON(steps.multi-output-named1.outputs)}} @@ -393,6 +436,7 @@ jobs: with: file: test/resources/test.properties keys: '${{env.SELECTED_SINGLE_KEY}}' + - name: '[assert] test: query multiple properties as action outputs' env: RESULT_JSON: ${{toJSON(steps.single-output.outputs)}} diff --git a/Action.java b/Action.java index 94e1091..7884ef9 100644 --- a/Action.java +++ b/Action.java @@ -28,9 +28,10 @@ static void main(String[] args) throws Exception { Config config = Config.fromEnv(); ResultWriter resultWriter = ResultWriter.of(config.resultType()); for (String file : args) { - Properties properties = config.selectedKeys() == null ? Util.readProperties(file) - : Util.selectProperties(Util.readProperties(file), config.selectedKeys(), file); - resultWriter.write(properties, config); + Properties properties = Util.readProperties(file); + Properties selectedProperties = config.selectedKeys().map(keys -> Util.selectProperties(properties, keys, file)) + .orElse(properties); + resultWriter.write(selectedProperties, config); } } catch (OutputException e) { try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { @@ -81,8 +82,8 @@ public String toString() { } -record Config(List selectedKeys, String keySeparator, String resultTypeWithArg, String resultType, String resultTypeArg, - String resultNameSeparator, String outputPrefix) { +record Config(Optional> selectedKeys, String keySeparator, String resultTypeWithArg, String resultType, + String resultTypeArg, String resultNameSeparator, String outputPrefix) { public static Config fromEnv() { // In order to keep the defaults DRY (in action.yml), the environment variables are all mandatory. String keySeparator = Util.getRequiredEnv(ConfigVariable.KEY_SEPARATOR); @@ -101,8 +102,8 @@ public static Config fromEnv() { String resultType = matcher.group(1); String resultTypeArg = Optional.ofNullable(matcher.group(2)).orElse(""); String outputPrefix = Util.getRequiredEnv(ConfigVariable.OUTPUT_PREFIX); - return new Config(keys.map(List::of).orElse(null), keySeparator, resultTypeWithArg, resultType, resultTypeArg, - resultNameSeparator, outputPrefix); + return new Config(keys.map(List::of), keySeparator, resultTypeWithArg, resultType, resultTypeArg, resultNameSeparator, + outputPrefix); } public String requiredResultTypeArg() { @@ -137,7 +138,7 @@ public void write(Properties props, Config config) throws IOException { // TODO props must be in order of config.selectedKeys() // Otherwise, this is arbitrary: - if (lastValue != null) { + if (config.selectedKeys().isPresent() && lastValue != null) { writer.write("value", lastValue); } } @@ -218,11 +219,9 @@ public static ResultWriter of(String inputName) { public abstract void write(Properties props, Config config) throws IOException; private static void writeNamedImpl(Properties props, Config config, GitHubOutputFile gitHubOutputFile) throws IOException { - List selectedKeys = config.selectedKeys(); - if (selectedKeys == null) { - throw OutputException.forIllegalArgument( - "invalid use of " + ConfigVariable.RESULT_TYPE + " " + config.resultType() + " (missing keys)"); - } + List selectedKeys = config.selectedKeys().orElseThrow(() -> OutputException.forIllegalArgument("invalid use of " + + ConfigVariable.RESULT_TYPE + " " + config.resultType() + " (missing " + ConfigVariable.KEYS + ")")); + @SuppressWarnings("java:S3655") // Sonar rule: "Optional value should only be accessed after calling isPresent()". // requiredResultTypeArg() is not empty, so splitArray returns non-empty String[] resultNames = Util.splitArray(config.requiredResultTypeArg(), config.resultNameSeparator()).get(); diff --git a/action.yml b/action.yml index 36386cc..412728c 100644 --- a/action.yml +++ b/action.yml @@ -27,7 +27,7 @@ inputs: Encoding replaces all punctuation or whitespace characters except the underscore ("_") with "-" followed by four hex digits of its unicode code point. For example, for a property key "a.b-c_d", resultType "output" sets an output with name "a-002Eb-002Dc_d". - Additionally, set output "value" to the last found value, if any (which is only usefull if `keys` is non-empty, i.e. last given key wins; without keys, the last property in the undefined java.util.Properties iteration order wins). + Additionally, set output "value" to the last found value, unless `keys` is empty. I.e. last given key wins. (Without keys, this output is not set, because it would be arbitrary due to the "random" iteration order of the used Java class java.util.Properties.) - "output-named:": is a `resultNameSeparator`-separated list of output names of the same length as (the `keySeparator`-separated list) `keys`. The value for `keys[i]` is set as output `[i]`. In order not to hide any future builtin outputs of this action, it is recommended to prefix each name with an underscore ("_"). In this mode, `keys` is required. Unlike "output", names are taken as-is. (This is because no official output naming rules seem to exist yet; so maybe a future user will know better how to choose valid characters than we would implement now.) - "output-named:": Special case: A single name is supported even with multiple `keys`. All found values for the keys are set as the same output; the last found key wins. In this mode, `keys` is required. - "env": For each property key , set an environment variable . From 569e2088b9366e9fbd5db260ce460555d431f90f Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Fri, 15 May 2026 21:56:21 +0200 Subject: [PATCH 36/69] refactor: move naming dependencies into one class --- Action.java | 130 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 81 insertions(+), 49 deletions(-) diff --git a/Action.java b/Action.java index 7884ef9..3024912 100644 --- a/Action.java +++ b/Action.java @@ -35,8 +35,7 @@ static void main(String[] args) throws Exception { } } catch (OutputException e) { try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { - // This output is not documented - writer.write("error", e.getMessage()); + writer.write(Ids.OutputName.ERROR, e.getMessage()); } throw e; } @@ -45,39 +44,68 @@ static void main(String[] args) throws Exception { /** - * An exception for which an "error" output should be set. + * Identifiers which must match those specified in the action YAML. */ -class OutputException extends RuntimeException { - public OutputException(String message, Throwable cause) { - super(message, cause); +class Ids { + private Ids() {} + + /** + * Configuration environment variable names and their corresponding action input names for error messages. + */ + enum ConfigVariable { + FILE("file"), // + KEYS("keys"), // + RESULT_TYPE("resultType"), // + KEY_SEPARATOR("keySeparator"), // + RESULT_NAME_SEPARATOR("resultNameSeparator"), // + OUTPUT_PREFIX(null); + + public final String externalName; + + private ConfigVariable(String externalName) { + this.externalName = externalName; + } + + @Override + public String toString() { + return externalName; + } } - public static OutputException forIllegalArgument(String message) { - return new OutputException(message, new IllegalArgumentException(message)); + enum ResultWriterName { + OUTPUT("output"), // + OUTPUT_NAMED("output-named"), // + ENV_NAMED("env-named"), // + ENV("env"), // + JSON("json"), // + JSON_FILE("json-file"); + + public final String externalName; + + private ResultWriterName(String externalName) { + this.externalName = externalName; + } + } + + enum OutputName { + JSON, // + VALUE, // + ERROR; // This output is not documented. + public final String externalName = name().toLowerCase(); } } /** - * Configuration environment variable names and their corresponding action input names for error messages. + * An exception for which an "error" output should be set. */ -enum ConfigVariable { - FILE("file"), // - KEYS("keys"), // - RESULT_TYPE("resultType"), // - KEY_SEPARATOR("keySeparator"), // - RESULT_NAME_SEPARATOR("resultNameSeparator"), // - OUTPUT_PREFIX(null); - - public final String inputName; - - private ConfigVariable(String inputName) { - this.inputName = inputName; +class OutputException extends RuntimeException { + public OutputException(String message, Throwable cause) { + super(message, cause); } - @Override - public String toString() { - return inputName; + public static OutputException forIllegalArgument(String message) { + return new OutputException(message, new IllegalArgumentException(message)); } } @@ -86,22 +114,22 @@ record Config(Optional> selectedKeys, String keySeparator, String r String resultTypeArg, String resultNameSeparator, String outputPrefix) { public static Config fromEnv() { // In order to keep the defaults DRY (in action.yml), the environment variables are all mandatory. - String keySeparator = Util.getRequiredEnv(ConfigVariable.KEY_SEPARATOR); + String keySeparator = Util.getRequiredEnv(Ids.ConfigVariable.KEY_SEPARATOR); // System.getenv("KEYS") == null if set to empty string?! So this cannot be checked to be set if we want to allow empty // string: - String keysStr = System.getenv().getOrDefault(ConfigVariable.KEYS.name(), ""); + String keysStr = System.getenv().getOrDefault(Ids.ConfigVariable.KEYS.name(), ""); Optional keys = Util.splitArray(keysStr, keySeparator); - String resultNameSeparator = Util.getRequiredEnv(ConfigVariable.RESULT_NAME_SEPARATOR); + String resultNameSeparator = Util.getRequiredEnv(Ids.ConfigVariable.RESULT_NAME_SEPARATOR); // RESULT_TYPE format: "[:]" - String resultTypeWithArg = Util.getRequiredEnv(ConfigVariable.RESULT_TYPE); + String resultTypeWithArg = Util.getRequiredEnv(Ids.ConfigVariable.RESULT_TYPE); Matcher matcher = Pattern.compile("([^:]+)(?::(.*))?").matcher(resultTypeWithArg); if (!matcher.matches()) { - throw OutputException.forIllegalArgument("invalid " + ConfigVariable.RESULT_TYPE + ": " + resultTypeWithArg); + throw OutputException.forIllegalArgument("invalid " + Ids.ConfigVariable.RESULT_TYPE + ": " + resultTypeWithArg); } String resultType = matcher.group(1); String resultTypeArg = Optional.ofNullable(matcher.group(2)).orElse(""); - String outputPrefix = Util.getRequiredEnv(ConfigVariable.OUTPUT_PREFIX); + String outputPrefix = Util.getRequiredEnv(Ids.ConfigVariable.OUTPUT_PREFIX); return new Config(keys.map(List::of), keySeparator, resultTypeWithArg, resultType, resultTypeArg, resultNameSeparator, outputPrefix); } @@ -110,7 +138,7 @@ public String requiredResultTypeArg() { String arg = resultTypeArg(); if ("".equals(arg)) { throw OutputException.forIllegalArgument( - "invalid " + ConfigVariable.RESULT_TYPE + " " + resultTypeWithArg() + " (missing argument)"); + "invalid " + Ids.ConfigVariable.RESULT_TYPE + " " + resultTypeWithArg() + " (missing argument)"); } return arg; } @@ -118,14 +146,14 @@ public String requiredResultTypeArg() { public void requireNoArg() { if (!"".equals(resultTypeArg())) { throw OutputException.forIllegalArgument( - "invalid " + ConfigVariable.RESULT_TYPE + " " + resultTypeWithArg() + " (non-empty argument)"); + "invalid " + Ids.ConfigVariable.RESULT_TYPE + " " + resultTypeWithArg() + " (non-empty argument)"); } } } enum ResultWriter { - OUTPUT("output") { + OUTPUT(Ids.ResultWriterName.OUTPUT) { @Override public void write(Properties props, Config config) throws IOException { String lastValue = null; @@ -139,7 +167,7 @@ public void write(Properties props, Config config) throws IOException { // TODO props must be in order of config.selectedKeys() // Otherwise, this is arbitrary: if (config.selectedKeys().isPresent() && lastValue != null) { - writer.write("value", lastValue); + writer.write(Ids.OutputName.VALUE, lastValue); } } } @@ -154,19 +182,19 @@ private static String encodeKey(Config config, String key) { return result.toString(); } }, - OUTPUT_NAMED("output-named") { + OUTPUT_NAMED(Ids.ResultWriterName.OUTPUT_NAMED) { @Override public void write(Properties props, Config config) throws IOException { writeNamedImpl(props, config, GitHubOutputFile.OUTPUT); } }, - ENV_NAMED("env-named") { + ENV_NAMED(Ids.ResultWriterName.ENV_NAMED) { @Override public void write(Properties props, Config config) throws IOException { writeNamedImpl(props, config, GitHubOutputFile.ENV); } }, - ENV("env") { + ENV(Ids.ResultWriterName.ENV) { @Override public void write(Properties props, Config config) throws IOException { String prefix = config.resultTypeArg(); @@ -177,16 +205,16 @@ public void write(Properties props, Config config) throws IOException { } } }, - JSON("json") { + JSON(Ids.ResultWriterName.JSON) { @Override public void write(Properties props, Config config) throws IOException { config.requireNoArg(); try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { - writer.write("json", Util.toJson(props)); + writer.write(Ids.OutputName.JSON, Util.toJson(props)); } } }, - JSON_FILE("json-file") { + JSON_FILE(Ids.ResultWriterName.JSON_FILE) { @Override public void write(Properties props, Config config) throws IOException { String outputFile = config.requiredResultTypeArg(); @@ -201,32 +229,32 @@ public void write(Properties props, Config config) throws IOException { } }; - private final String inputName; + private final String externalName; - private ResultWriter(String inputName) { - this.inputName = inputName; + private ResultWriter(Ids.ResultWriterName externalName) { + this.externalName = externalName.externalName; } - public static ResultWriter of(String inputName) { + public static ResultWriter of(String externalName) { for (ResultWriter rw : ResultWriter.values()) { - if (rw.inputName.equals(inputName)) { + if (rw.externalName.equals(externalName)) { return rw; } } - throw OutputException.forIllegalArgument("invalid " + ConfigVariable.RESULT_TYPE + ": " + inputName); + throw OutputException.forIllegalArgument("invalid " + Ids.ConfigVariable.RESULT_TYPE + ": " + externalName); } public abstract void write(Properties props, Config config) throws IOException; private static void writeNamedImpl(Properties props, Config config, GitHubOutputFile gitHubOutputFile) throws IOException { List selectedKeys = config.selectedKeys().orElseThrow(() -> OutputException.forIllegalArgument("invalid use of " - + ConfigVariable.RESULT_TYPE + " " + config.resultType() + " (missing " + ConfigVariable.KEYS + ")")); + + Ids.ConfigVariable.RESULT_TYPE + " " + config.resultType() + " (missing " + Ids.ConfigVariable.KEYS + ")")); @SuppressWarnings("java:S3655") // Sonar rule: "Optional value should only be accessed after calling isPresent()". // requiredResultTypeArg() is not empty, so splitArray returns non-empty String[] resultNames = Util.splitArray(config.requiredResultTypeArg(), config.resultNameSeparator()).get(); if (resultNames.length != selectedKeys.size() && resultNames.length != 1) { - throw OutputException.forIllegalArgument(ConfigVariable.RESULT_TYPE + " " + config.resultTypeWithArg() + " has " + throw OutputException.forIllegalArgument(Ids.ConfigVariable.RESULT_TYPE + " " + config.resultTypeWithArg() + " has " + resultNames.length + " arguments, but " + selectedKeys.size() + " keys are selected"); } try (GitHubVariableWriter writer = gitHubOutputFile.open()) { @@ -281,6 +309,10 @@ public void write(String key, String value) throws IOException { } } + public void write(Ids.OutputName key, String value) throws IOException { + write(key.externalName, value); + } + /** * Writes a key-value pair in * multiline @@ -333,7 +365,7 @@ public static String getRequiredEnv(String varName) { return value; } - public static String getRequiredEnv(ConfigVariable varName) { + public static String getRequiredEnv(Ids.ConfigVariable varName) { return getRequiredEnv(varName.name()); } From 95a7589d8663056fc0187fd42bb919e0f707a156 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Fri, 15 May 2026 21:58:02 +0200 Subject: [PATCH 37/69] fix resultType "output": obey selected keys order --- Action.java | 77 +++++++++++++++++++++++------------------------------ 1 file changed, 34 insertions(+), 43 deletions(-) diff --git a/Action.java b/Action.java index 3024912..7120520 100644 --- a/Action.java +++ b/Action.java @@ -8,8 +8,7 @@ import java.nio.file.OpenOption; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; -import java.util.AbstractMap; -import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -29,8 +28,9 @@ static void main(String[] args) throws Exception { ResultWriter resultWriter = ResultWriter.of(config.resultType()); for (String file : args) { Properties properties = Util.readProperties(file); - Properties selectedProperties = config.selectedKeys().map(keys -> Util.selectProperties(properties, keys, file)) - .orElse(properties); + Map selectedProperties = config.selectedKeys() + .map(keys -> Util.selectProperties(properties, keys, file)) // + .orElseGet(() -> Util.stringEntries(properties)); resultWriter.write(selectedProperties, config); } } catch (OutputException e) { @@ -155,17 +155,15 @@ public void requireNoArg() { enum ResultWriter { OUTPUT(Ids.ResultWriterName.OUTPUT) { @Override - public void write(Properties props, Config config) throws IOException { + public void write(Map props, Config config) throws IOException { String lastValue = null; try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { - for (Map.Entry entry : Util.stringEntries(props)) { + for (Map.Entry entry : props.entrySet()) { String key = encodeKey(config, config.outputPrefix() + entry.getKey()); lastValue = entry.getValue(); writer.write(key, lastValue); } - // TODO props must be in order of config.selectedKeys() - // Otherwise, this is arbitrary: if (config.selectedKeys().isPresent() && lastValue != null) { writer.write(Ids.OutputName.VALUE, lastValue); } @@ -184,22 +182,22 @@ private static String encodeKey(Config config, String key) { }, OUTPUT_NAMED(Ids.ResultWriterName.OUTPUT_NAMED) { @Override - public void write(Properties props, Config config) throws IOException { + public void write(Map props, Config config) throws IOException { writeNamedImpl(props, config, GitHubOutputFile.OUTPUT); } }, ENV_NAMED(Ids.ResultWriterName.ENV_NAMED) { @Override - public void write(Properties props, Config config) throws IOException { + public void write(Map props, Config config) throws IOException { writeNamedImpl(props, config, GitHubOutputFile.ENV); } }, ENV(Ids.ResultWriterName.ENV) { @Override - public void write(Properties props, Config config) throws IOException { + public void write(Map props, Config config) throws IOException { String prefix = config.resultTypeArg(); try (GitHubVariableWriter writer = GitHubOutputFile.ENV.open()) { - for (Map.Entry entry : Util.stringEntries(props)) { + for (Map.Entry entry : props.entrySet()) { writer.write(prefix + entry.getKey(), entry.getValue()); } } @@ -207,7 +205,7 @@ public void write(Properties props, Config config) throws IOException { }, JSON(Ids.ResultWriterName.JSON) { @Override - public void write(Properties props, Config config) throws IOException { + public void write(Map props, Config config) throws IOException { config.requireNoArg(); try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { writer.write(Ids.OutputName.JSON, Util.toJson(props)); @@ -216,7 +214,7 @@ public void write(Properties props, Config config) throws IOException { }, JSON_FILE(Ids.ResultWriterName.JSON_FILE) { @Override - public void write(Properties props, Config config) throws IOException { + public void write(Map props, Config config) throws IOException { String outputFile = config.requiredResultTypeArg(); Files.createDirectories((Paths.get(outputFile).getParent())); try (Writer writer = Util.openFile(outputFile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { @@ -244,9 +242,10 @@ public static ResultWriter of(String externalName) { throw OutputException.forIllegalArgument("invalid " + Ids.ConfigVariable.RESULT_TYPE + ": " + externalName); } - public abstract void write(Properties props, Config config) throws IOException; + public abstract void write(Map props, Config config) throws IOException; - private static void writeNamedImpl(Properties props, Config config, GitHubOutputFile gitHubOutputFile) throws IOException { + private static void writeNamedImpl(Map props, Config config, GitHubOutputFile gitHubOutputFile) + throws IOException { List selectedKeys = config.selectedKeys().orElseThrow(() -> OutputException.forIllegalArgument("invalid use of " + Ids.ConfigVariable.RESULT_TYPE + " " + config.resultType() + " (missing " + Ids.ConfigVariable.KEYS + ")")); @@ -260,7 +259,7 @@ private static void writeNamedImpl(Properties props, Config config, GitHubOutput try (GitHubVariableWriter writer = gitHubOutputFile.open()) { for (int i = 0; i < selectedKeys.size(); i++) { String name = resultNames[resultNames.length == 1 ? 0 : i]; - String value = props.getProperty(selectedKeys.get(i)); + String value = props.get(selectedKeys.get(i)); writer.write(name, value); } } @@ -385,42 +384,34 @@ public static Properties readProperties(String file) throws IOException { return allProps; } - public static Properties selectProperties(Properties allProps, List selectedKeys, String file) { - Set selectedKeysSet = new LinkedHashSet<>(selectedKeys); - Properties props = new Properties(); - for (Map.Entry entry : stringEntries(allProps)) { - if (selectedKeysSet.contains(entry.getKey())) { - selectedKeysSet.remove(entry.getKey()); - props.setProperty(entry.getKey(), entry.getValue()); + public static Map selectProperties(Properties allProps, List selectedKeys, String file) { + Set unmatchedKeysSet = new LinkedHashSet<>(selectedKeys); + Map results = new LinkedHashMap<>(); + for (String key : selectedKeys) { + String value = allProps.getProperty(key); + if (value != null) { + results.put(key, value); + unmatchedKeysSet.remove(key); } } - for (String key : selectedKeysSet) { + for (String key : unmatchedKeysSet) { System.err.format("Property %s not found in %s%n", key, file); } - return props; + return results; } - public static Iterable> stringEntries(Properties props) { - return () -> new Iterator<>() { - private final Iterator keysIter = props.stringPropertyNames().iterator(); - - @Override - public Map.Entry next() { - String key = keysIter.next(); - return new AbstractMap.SimpleImmutableEntry<>(key, props.getProperty(key)); - } - - @Override - public boolean hasNext() { - return keysIter.hasNext(); - } - }; + public static Map stringEntries(Properties props) { + Map map = new LinkedHashMap<>(); + for (String key : props.stringPropertyNames()) { + map.put(key, props.getProperty(key)); + } + return map; } - public static String toJson(Properties props) { + public static String toJson(Map map) { StringBuilder s = new StringBuilder(50).append('{'); int initialLength = s.length(); - for (Map.Entry entry : stringEntries(props)) { + for (Map.Entry entry : map.entrySet()) { s.append(s.length() == initialLength ? '"' : ", \""); appendJsonString(s, entry.getKey()); s.append("\":\""); From a98e59b50fd3854a3f27448780f5675379b1b110 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Mon, 18 May 2026 14:42:52 +0200 Subject: [PATCH 38/69] docs --- README.md | 63 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 5330f0a..ae11dc9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,10 @@ Query all properties as action outputs: with: file: gradle.properties ``` -⇒ `${{steps.readProp.outputs._sourceJavaVersion}}` == `21`, `${{steps.readProp.outputs._targetJavaVersion}}` == `17`, `${{steps.readProp.outputs._org.gradle.jvmargs}}` == `-ea -showversion`. +⇒ \ + `${{steps.readProp.outputs._sourceJavaVersion}}` == `21`, \ + `${{steps.readProp.outputs._targetJavaVersion}}` == `17`, \ + `${{steps.readProp.outputs._org-002Egradle-002Ejvmargs}}` == `-ea -showversion`. Query multiple properties as action outputs: ``` @@ -21,7 +24,7 @@ Query multiple properties as action outputs: uses: freenet-actions/read-java-properties@v1 with: file: gradle.properties - keys: sourceJavaVersion,targetJavaVersion + keys: 'sourceJavaVersion targetJavaVersion' ``` or with another key separator: ``` @@ -29,10 +32,13 @@ or with another key separator: uses: freenet-actions/read-java-properties@v1 with: file: gradle.properties - keys: "sourceJavaVersion; targetJavaVersion" - keySeparator: "; " + keys: sourceJavaVersion,targetJavaVersion + keySeparator: , ``` -⇒ `${{steps.readProp.outputs._sourceJavaVersion}}` == `21`, `${{steps.readProp.outputs._targetJavaVersion}}` == `17`, `${{steps.readProp.outputs.value}}` == `17`. +⇒ \ + `${{steps.readProp.outputs._sourceJavaVersion}}` == `21`, \ + `${{steps.readProp.outputs._targetJavaVersion}}` == `17`, \ + `${{steps.readProp.outputs.value}}` == `17`. Query multiple properties as action output of which only one is found: ``` @@ -40,9 +46,11 @@ Query multiple properties as action output of which only one is found: uses: freenet-actions/read-java-properties@v1 with: file: gradle.properties - keys: foo,targetJavaVersion,bar + keys: 'foo targetJavaVersion bar' ``` -⇒ `${{steps.readProp.outputs._targetJavaVersion}}` == `17`, `${{steps.readProp.outputs.value}}` == `17`. +⇒ \ + `${{steps.readProp.outputs._targetJavaVersion}}` == `17`, \ + `${{steps.readProp.outputs.value}}` == `17`. Query a single property as action output: ``` @@ -52,7 +60,19 @@ Query a single property as action output: file: gradle.properties keys: org.gradle.jvmargs ``` -⇒ `${{steps.readProp.outputs.org.gradle.jvmargs}}` == `-ea -showversion`, `${{steps.readProp.outputs.value}}` == `-ea -showversion`. +⇒ \ + `${{steps.readProp.outputs.org.gradle.jvmargs}}` == `-ea -showversion`, \ + `${{steps.readProp.outputs.value}}` == `-ea -showversion`. + +Query multiple (alternative) properties as a single action output. In other words, query a property with a fallback to another property. +``` +- uses: freenet-actions/read-java-properties@v1 + with: + file: gradle.properties + keys: sourceJavaVersion,targetJavaVersion + resultType: 'output-named:_javaVersion' +``` +⇒ `${{steps.readProp.outputs._javaVersion}}` == `17`. Query all properties as environment variables of the same names: ``` @@ -61,7 +81,10 @@ Query all properties as environment variables of the same names: file: gradle.properties resultType: env ``` -⇒ Environment variables `sourceJavaVersion` == `21`, `targetJavaVersion` == `17`, `org.gradle.jvmargs` == `-ea -showversion`. +⇒ Environment variables \ + `sourceJavaVersion` == `21`, \ + `targetJavaVersion` == `17`, \ + `org.gradle.jvmargs` == `-ea -showversion`. Query all properties as environment variables with prefix: ``` @@ -70,7 +93,10 @@ Query all properties as environment variables with prefix: file: gradle.properties resultType: "env:GRADLE_PROP_" ``` -⇒ Environment variables `GRADLE_PROP_sourceJavaVersion` == `21`, `GRADLE_PROP_targetJavaVersion` == `17`, `GRADLE_PROP_org.gradle.jvmargs` == `-ea -showversion`. +⇒ Environment variables \ + `GRADLE_PROP_sourceJavaVersion` == `21`, \ + `GRADLE_PROP_targetJavaVersion` == `17`, \ + `GRADLE_PROP_org.gradle.jvmargs` == `-ea -showversion`. Query multiple properties as environment variables: ``` @@ -80,7 +106,9 @@ Query multiple properties as environment variables: keys: sourceJavaVersion,targetJavaVersion resultType: env ``` -⇒ Environment variables `sourceJavaVersion` == `21`, `targetJavaVersion` == `17`. +⇒ Environment variables \ + `sourceJavaVersion` == `21`, \ + `targetJavaVersion` == `17`. Query multiple properties as environment variables of given names: ``` @@ -90,9 +118,11 @@ Query multiple properties as environment variables of given names: keys: sourceJavaVersion,targetJavaVersion resultType: env-named:SOURCE_JAVA_VERSION,TARGET_JAVA_VERSION ``` -⇒ Environment variables `SOURCE_JAVA_VERSION` == `21`, `TARGET_JAVA_VERSION` == `17`. +⇒ Environment variables \ + `SOURCE_JAVA_VERSION` == `21`, \ + `TARGET_JAVA_VERSION` == `17`. -Query multiple (alternative) properties as a single environment variable: +Query multiple (alternative) properties as a single environment variable. In other words, query a property with a fallback to another property. ``` - uses: freenet-actions/read-java-properties@v1 with: @@ -110,7 +140,8 @@ Query all properties as JSON action output: file: gradle.properties resultType: json ``` -⇒ `${{steps.readProp.outputs.json}}` == `{"sourceJavaVersion": "21", "targetJavaVersion": "17", "org.gradle.jvmargs": "-ea -showversion"}` (or similar; order not guaranteed). +⇒ `${{steps.readProp.outputs.json}}` == `{"sourceJavaVersion": "21", "targetJavaVersion": "17", "org.gradle.jvmargs": "-ea -showversion"}` \ +(or similar; order not guaranteed). Query all properties as JSON file: ``` @@ -120,4 +151,6 @@ Query all properties as JSON file: file: gradle.properties resultType: json-file:/tmp/gradle-properties.json ``` -⇒ File /tmp/gradle-properties.json contains `{"sourceJavaVersion": "21", "targetJavaVersion": "17", "org.gradle.jvmargs": "-ea -showversion"}` (or similar; order and formatting not guaranteed). +⇒ File /tmp/gradle-properties.json contains \ + `{"sourceJavaVersion": "21", "targetJavaVersion": "17", "org.gradle.jvmargs": "-ea -showversion"}` \ +(or similar; order and formatting not guaranteed). From ee203245bcb66636ade60c6dd86483e34d3212dd Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Mon, 18 May 2026 15:15:34 +0200 Subject: [PATCH 39/69] test: add tests for non-default keySeparator, resultNameSeparator --- .github/workflows/test.yml | 58 ++++++++++++++++++++++++++++++++++++++ Action.java | 3 ++ 2 files changed, 61 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5cd01e4..8355c3f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -344,6 +344,34 @@ jobs: eval "$SHELL_SETUP" assertEquals "$RESULT_VALUE" 'c' # from last key "colon:in:Key" + ############################################################ + - name: '[act] test: query multiple properties as action outputs (different keySeparator)' + id: multi-output-keySeparator + uses: ./ + with: + file: test/resources/test.properties + keySeparator: '?' # something with a special meaning in regex; should be treated as literal + keys: 'sourceJavaVersion?org.gradle.jvmargs?unicode_escapes?colon:in:Key' + - name: '[assert] test: query multiple properties as action outputs (different keySeparator)' + env: + RESULT_JSON: ${{toJSON(steps.multi-output-keySeparator.outputs)}} + RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' + run: *validateMultiImpl + + ############################################################ + - name: '[act] test: query multiple properties as action outputs (different multi-char keySeparator)' + id: multi-output-keySeparatorMC + uses: ./ + with: + file: test/resources/test.properties + keySeparator: ':/ ' + keys: 'sourceJavaVersion:/ org.gradle.jvmargs:/ unicode_escapes:/ colon:in:Key' + - name: '[assert] test: query multiple properties as action outputs (different multi-char keySeparator)' + env: + RESULT_JSON: ${{toJSON(steps.multi-output-keySeparatorMC.outputs)}} + RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' + run: *validateMultiImpl + ############################################################ - name: '[act] test: query multiple properties as action outputs (in another keys order)' id: multi-output-2 @@ -401,6 +429,36 @@ jobs: eval "$SHELL_SETUP" assertEquals "$RESULT_VALUE" U_N_S_E_T + ############################################################ + - name: '[act] test: query multiple properties as action outputs of given names (different resultNameSeparator)' + id: multi-output-named-resultNameSeparator + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS}}' + resultNameSeparator: '?' # something with a special meaning in regex; should be treated as literal + resultType: 'output-named:java-version?jvm_args?unicode?colon' + - name: '[assert] test: query multiple properties as action outputs of given names (different resultNameSeparator)' + env: + RESULT_JSON: ${{toJSON(steps.multi-output-named-resultNameSeparator.outputs)}} + RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' + run: *validateMultiNamedImpl + + ############################################################ + - name: '[act] test: query multiple properties as action outputs of given names (different multi-char resultNameSeparator)' + id: multi-output-named-resultNameSeparatorMC + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS}}' + resultNameSeparator: '- -' + resultType: 'output-named:java-version- -jvm_args- -unicode- -colon' + - name: '[assert] test: query multiple properties as action outputs of given names (different multi-char resultNameSeparator)' + env: + RESULT_JSON: ${{toJSON(steps.multi-output-named-resultNameSeparatorMC.outputs)}} + RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' + run: *validateMultiNamedImpl + ############################################################ - name: '[act] test: query multiple properties as action outputs of given name' id: multi-output-named1 diff --git a/Action.java b/Action.java index 7120520..c837f72 100644 --- a/Action.java +++ b/Action.java @@ -384,6 +384,9 @@ public static Properties readProperties(String file) throws IOException { return allProps; } + /** + * Selects the properties with the given keys and returns them as a Map with the corresponding iteration order. + */ public static Map selectProperties(Properties allProps, List selectedKeys, String file) { Set unmatchedKeysSet = new LinkedHashSet<>(selectedKeys); Map results = new LinkedHashMap<>(); From b537b22f256c9d64d6104da66134c961cf727b90 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Mon, 18 May 2026 15:40:56 +0200 Subject: [PATCH 40/69] security: remove value logging --- Action.java | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/Action.java b/Action.java index c837f72..063ebfd 100644 --- a/Action.java +++ b/Action.java @@ -219,7 +219,7 @@ public void write(Map props, Config config) throws IOException { Files.createDirectories((Paths.get(outputFile).getParent())); try (Writer writer = Util.openFile(outputFile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { String jsonResult = Util.toJson(props); - System.err.format("writing to %s: %s%n", outputFile, jsonResult); + System.err.format("writing JSON for %s properties to %s%n", props.size(), outputFile); writer.write(jsonResult); writer.write('\n'); writer.flush(); @@ -268,17 +268,19 @@ private static void writeNamedImpl(Map props, Config config, Git enum GitHubOutputFile { - OUTPUT("GITHUB_OUTPUT"), // - ENV("GITHUB_ENV"); + OUTPUT("GITHUB_OUTPUT", "output"), // + ENV("GITHUB_ENV", "environment variable"); - final String fileName; + private final String fileName; + private final String description; - private GitHubOutputFile(String fileNameEnvVar) { + private GitHubOutputFile(String fileNameEnvVar, String description) { this.fileName = Util.getRequiredEnv(fileNameEnvVar); + this.description = description; } public GitHubVariableWriter open() throws IOException { - return new GitHubVariableWriter(this.toString().replaceFirst("^GITHUB_", "").toLowerCase(), fileName); + return new GitHubVariableWriter(description, fileName); } } @@ -295,7 +297,7 @@ public GitHubVariableWriter(String description, String fileName) throws IOExcept } public void write(String key, String value) throws IOException { - System.err.format("%s %s\t:= \"%s\"%n", description, key, value); + System.err.format("setting %s %s%n", description, key); // write (very) simple values in format "=": if (SIMPLE_VALUE.matcher(value).matches()) { From 9d6e2d9da095965956a5151acf0a1142a49473d3 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Mon, 18 May 2026 15:48:38 +0200 Subject: [PATCH 41/69] docs --- action.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/action.yml b/action.yml index 412728c..97e5ed6 100644 --- a/action.yml +++ b/action.yml @@ -31,6 +31,7 @@ inputs: - "output-named:": is a `resultNameSeparator`-separated list of output names of the same length as (the `keySeparator`-separated list) `keys`. The value for `keys[i]` is set as output `[i]`. In order not to hide any future builtin outputs of this action, it is recommended to prefix each name with an underscore ("_"). In this mode, `keys` is required. Unlike "output", names are taken as-is. (This is because no official output naming rules seem to exist yet; so maybe a future user will know better how to choose valid characters than we would implement now.) - "output-named:": Special case: A single name is supported even with multiple `keys`. All found values for the keys are set as the same output; the last found key wins. In this mode, `keys` is required. - "env": For each property key , set an environment variable . + In order not to pollute the environment with hard to understand variables, this should only be used to set some specifically named variables. I.e. you know that the property file can only contain such properties or you are selecting only such properties with `keys`. - "env:": For each property key , set an environment variable . - "env-named:": is a `resultNameSeparator`-separated list of variable names of the same length as (the `keySeparator`-separated list) `keys`. The value for `keys[i]` is set as environment variable `[i]`. In this mode, `keys` is required. - "env-named:": Special case: A single name is supported even with multiple `keys`. All found values for the keys are set as the same environment variable; the last found key wins. In this mode, `keys` is required. From 8fd74374498fb4151e520238d59d402a046b2d19 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Mon, 18 May 2026 17:12:56 +0200 Subject: [PATCH 42/69] add aggregated test job as a GitHub status check --- .github/workflows/test.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8355c3f..c310f9f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -123,7 +123,33 @@ env: } jobs: + # Similar to [https://github.com/orgs/community/discussions/12395#discussioncomment-12970019], this step + # aggregates all job states, so that we need only this job as a status check in the GitHub repository settings. test: + needs: + - test-misc + - test-all-env + - test-multi-env + - test-multi-env-named + - test-multi-env-named1 + - test-single-env + - test-all-env-with-prefix + - test-multi-env-with-prefix + - test-single-env-with-prefix + if: always() + runs-on: ubuntu-latest + steps: + - name: check overall status + env: + JOBS_TO_CHECK_JSON: ${{toJSON(needs)}} + run: |- + printf '$JOBS_TO_CHECK_JSON == "%s"\n' "$JOBS_TO_CHECK_JSON" >&2 + jq --raw-output ' + [to_entries[] | select(.value.result != "success")] + | map((.key + " has status " + (.value.result//"null") + "\n") | halt_error(1))[] + ' <<<"$JOBS_TO_CHECK_JSON" + + test-misc: runs-on: ubuntu-latest steps: - &checkoutStep From 087a30f5648c0462c9e6137bdade473b3cd5b078 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Mon, 18 May 2026 19:01:42 +0200 Subject: [PATCH 43/69] misc fixes (GitHub Copilot review) - fix backslash escaping in JSON - fix misleading StringBuilder initial capacity - fix NPE for missing properties (write empty output / env var) - fix NPE if file is relative (and without leading "./") - JSON formatting: add space before value (as documented) - fix docs: encoded output name --- .github/workflows/test.yml | 8 +++++++- Action.java | 18 ++++++++++++------ README.md | 4 ++-- test/resources/test.properties | 1 + 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c310f9f..58b6793 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -290,6 +290,7 @@ jobs: evalAndAssertEquals "$RESULT_GETTER" 'space in Key' 'sp' evalAndAssertEquals "$RESULT_GETTER" colon:in:Key 'c' evalAndAssertEquals "$RESULT_GETTER" colonInValue 'cv:' + evalAndAssertEquals "$RESULT_GETTER" 'back\slash' 'back\slash\value' evalAndAssertEquals "$RESULT_GETTER" dash-in-Key 'd' evalAndAssertEquals "$RESULT_GETTER" dashInValue 'd-' evalAndAssertEquals "$RESULT_GETTER" empty '' @@ -341,6 +342,7 @@ jobs: evalAndAssertUndefined "$RESULT_GETTER" targetJavaVersion evalAndAssertUndefined "$RESULT_GETTER" 'space in Key' evalAndAssertUndefined "$RESULT_GETTER" colonInValue + evalAndAssertUndefined "$RESULT_GETTER" 'back\slash' evalAndAssertUndefined "$RESULT_GETTER" dash-in-Key evalAndAssertUndefined "$RESULT_GETTER" dashInValue evalAndAssertUndefined "$RESULT_GETTER" empty @@ -534,6 +536,7 @@ jobs: evalAndAssertUndefined "$RESULT_GETTER" colon:in:Key evalAndAssertUndefined "$RESULT_GETTER" 'space in Key' evalAndAssertUndefined "$RESULT_GETTER" colonInValue + evalAndAssertUndefined "$RESULT_GETTER" 'back\slash' evalAndAssertUndefined "$RESULT_GETTER" dash-in-Key evalAndAssertUndefined "$RESULT_GETTER" dashInValue evalAndAssertUndefined "$RESULT_GETTER" empty @@ -632,7 +635,10 @@ jobs: # Distinction between creating and overwriting need not be tested again. # This test just uses an existing empty file. run: |- - printf 'file=%s\n' "$(mktemp --tmpdir=. test_json-file_all_XXXXXX.json)" | tee -- "$GITHUB_OUTPUT" + # Remove leading "./" ⇒ Also test the case where given file has "no parent" (i.e. Java method + # java.nio.file.Path.getParent() returns null). + file=$(mktemp --tmpdir=. test_json-file_all_XXXXXX.json | sed 's=^\./==') + printf 'file=%s\n' "$file" | tee -- "$GITHUB_OUTPUT" - name: '[act] test: query all properties as JSON file' uses: ./ with: diff --git a/Action.java b/Action.java index 063ebfd..d57e716 100644 --- a/Action.java +++ b/Action.java @@ -6,6 +6,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.OpenOption; +import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.util.LinkedHashMap; @@ -159,7 +160,7 @@ public void write(Map props, Config config) throws IOException { String lastValue = null; try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { for (Map.Entry entry : props.entrySet()) { - String key = encodeKey(config, config.outputPrefix() + entry.getKey()); + String key = encodeKey(config.outputPrefix() + entry.getKey()); lastValue = entry.getValue(); writer.write(key, lastValue); } @@ -170,8 +171,8 @@ public void write(Map props, Config config) throws IOException { } } - private static String encodeKey(Config config, String key) { - StringBuilder result = new StringBuilder(key.length() + config.outputPrefix().length() + 4); + private static String encodeKey(String key) { + StringBuilder result = new StringBuilder(key.length() + 4); Matcher matcher = Pattern.compile("([\\p{Punct}&&[^_]])").matcher(key); while (matcher.find()) { matcher.appendReplacement(result, String.format("-%04X", (int) matcher.group(1).charAt(0))); @@ -216,7 +217,10 @@ public void write(Map props, Config config) throws IOException { @Override public void write(Map props, Config config) throws IOException { String outputFile = config.requiredResultTypeArg(); - Files.createDirectories((Paths.get(outputFile).getParent())); + Path parentDir = Paths.get(outputFile).getParent(); + if (parentDir != null) { + Files.createDirectories(parentDir); + } try (Writer writer = Util.openFile(outputFile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { String jsonResult = Util.toJson(props); System.err.format("writing JSON for %s properties to %s%n", props.size(), outputFile); @@ -260,7 +264,7 @@ private static void writeNamedImpl(Map props, Config config, Git for (int i = 0; i < selectedKeys.size(); i++) { String name = resultNames[resultNames.length == 1 ? 0 : i]; String value = props.get(selectedKeys.get(i)); - writer.write(name, value); + writer.write(name, value != null ? value : ""); } } } @@ -419,7 +423,7 @@ public static String toJson(Map map) { for (Map.Entry entry : map.entrySet()) { s.append(s.length() == initialLength ? '"' : ", \""); appendJsonString(s, entry.getKey()); - s.append("\":\""); + s.append("\": \""); appendJsonString(s, entry.getValue()); s.append('"'); } @@ -430,6 +434,8 @@ private static void appendJsonString(StringBuilder buffer, String s) { for (char c : s.toCharArray()) { if (c == ' ') { buffer.append(c); + } else if (c == '\\') { + buffer.append('\\').append('\\'); } else if (c == '"') { buffer.append('\\').append('"'); } else if (c == '\t') { diff --git a/README.md b/README.md index ae11dc9..69281a8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # read-java-properties -Github Action to read a Java .properties file and output one, multiple, or all properties as plain strings or JSON. +GitHub Action to read a Java .properties file and output one, multiple, or all properties as plain strings or JSON. ## Usage example: @@ -61,7 +61,7 @@ Query a single property as action output: keys: org.gradle.jvmargs ``` ⇒ \ - `${{steps.readProp.outputs.org.gradle.jvmargs}}` == `-ea -showversion`, \ + `${{steps.readProp.outputs.org-002Egradle-002Ejvmargs}}` == `-ea -showversion`, \ `${{steps.readProp.outputs.value}}` == `-ea -showversion`. Query multiple (alternative) properties as a single action output. In other words, query a property with a fallback to another property. diff --git a/test/resources/test.properties b/test/resources/test.properties index 9bb4b82..3933a50 100644 --- a/test/resources/test.properties +++ b/test/resources/test.properties @@ -5,6 +5,7 @@ org.gradle.jvmargs: -ea -showversion space\ in\ Key: sp colon\:in\:Key: c colonInValue: cv: +back\\slash: back\\slash\\value dash-in-Key: d dashInValue: d- empty: From 1e3908d7ccfa8a5db8d9b3bc5865970c01e75331 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Tue, 19 May 2026 16:27:46 +0200 Subject: [PATCH 44/69] test: split into more jobs for readability --- .github/workflows/test.yml | 140 ++++++++++++++++++++++++------------- 1 file changed, 91 insertions(+), 49 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 58b6793..c1ffb57 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -123,19 +123,25 @@ env: } jobs: - # Similar to [https://github.com/orgs/community/discussions/12395#discussioncomment-12970019], this step + ######################################################################### + # Similar to [https://github.com/orgs/community/discussions/12395#discussioncomment-12970019], this # aggregates all job states, so that we need only this job as a status check in the GitHub repository settings. test: needs: - - test-misc - - test-all-env - - test-multi-env - - test-multi-env-named - - test-multi-env-named1 - - test-single-env - - test-all-env-with-prefix - - test-multi-env-with-prefix - - test-single-env-with-prefix + - test_invalid-usage + - test_default + - test_output + - test_output-named + - test_json + - test_json-file + - test_env_all + - test_env_multi + - test_env-named_multi + - test_env-named_multi-to-one + - test_env_single + - test_env-with-prefix_all + - test_env-with-prefix_multi + - test_env-with-prefix_single if: always() runs-on: ubuntu-latest steps: @@ -149,7 +155,8 @@ jobs: | map((.key + " has status " + (.value.result//"null") + "\n") | halt_error(1))[] ' <<<"$JOBS_TO_CHECK_JSON" - test-misc: + ######################################################################### + test_invalid-usage: runs-on: ubuntu-latest steps: - &checkoutStep @@ -160,7 +167,7 @@ jobs: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - ############################################################ + # ===================================================================== - name: '[act] test (should fail): invalid input: resultType' id: error-input-resultType continue-on-error: true @@ -177,7 +184,7 @@ jobs: assertEquals "$STATUS" failure assertEquals "$ERROR" 'invalid resultType: rt1' - ############################################################ + # ===================================================================== - name: '[act] test (should fail): resultType "output-named" without names' id: error-resultType-output-without-names continue-on-error: true @@ -195,7 +202,7 @@ jobs: assertEquals "$STATUS" failure assertEquals "$ERROR" 'invalid resultType output-named: (missing argument)' - ############################################################ + # ===================================================================== - name: '[act] test (should fail): resultType "env-named" without names' id: error-resultType-env-without-names continue-on-error: true @@ -213,7 +220,7 @@ jobs: assertEquals "$STATUS" failure assertEquals "$ERROR" 'invalid resultType env-named: (missing argument)' - ############################################################ + # ===================================================================== - name: '[act] test (should fail): resultType "output-named" without keys' id: error-resultType-output-without-keys continue-on-error: true @@ -230,7 +237,7 @@ jobs: assertEquals "$STATUS" failure assertEquals "$ERROR" 'invalid use of resultType output-named (missing keys)' - ############################################################ + # ===================================================================== - name: '[act] test (should fail): resultType "env-named" without keys' id: error-resultType-env-without-keys continue-on-error: true @@ -247,7 +254,12 @@ jobs: assertEquals "$STATUS" failure assertEquals "$ERROR" 'invalid use of resultType env-named (missing keys)' - ############################################################ + ######################################################################### + test_output: + runs-on: ubuntu-latest + steps: + - *checkoutStep + # ===================================================================== # This tests a subset of test case multi-output, but with hard-coded encoding from property key to output # name (instead of re-implemented). # It also demonstrates how you'd usually use outputs with one environment variable per output. @@ -257,6 +269,7 @@ jobs: with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}} dash-in-Key' + resultType: output - name: '[assert] test: query multiple properties as action outputs (simple)' env: RESULT_SJV: ${{steps.multi-output-simple.outputs._sourceJavaVersion}} @@ -272,12 +285,13 @@ jobs: assertEquals "$RESULT_DASHK" 'd' assertEquals "$RESULT_UNI" 'a«,»c' - ############################################################ + # ===================================================================== - name: '[act] test: query all properties as action outputs' id: all-output uses: ./ with: file: test/resources/test.properties + resultType: output - name: '[assert] test: query all properties as action outputs' env: RESULT_JSON: ${{toJSON(steps.all-output.outputs)}} @@ -320,13 +334,14 @@ jobs: eval "$SHELL_SETUP" assertEquals "$RESULT_VALUE" U_N_S_E_T - ############################################################ + # ===================================================================== - name: '[act] test: query multiple properties as action outputs' id: multi-output uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' + resultType: output - name: '[assert] test: query multiple properties as action outputs' env: @@ -372,7 +387,7 @@ jobs: eval "$SHELL_SETUP" assertEquals "$RESULT_VALUE" 'c' # from last key "colon:in:Key" - ############################################################ + # ===================================================================== - name: '[act] test: query multiple properties as action outputs (different keySeparator)' id: multi-output-keySeparator uses: ./ @@ -380,13 +395,14 @@ jobs: file: test/resources/test.properties keySeparator: '?' # something with a special meaning in regex; should be treated as literal keys: 'sourceJavaVersion?org.gradle.jvmargs?unicode_escapes?colon:in:Key' + resultType: output - name: '[assert] test: query multiple properties as action outputs (different keySeparator)' env: RESULT_JSON: ${{toJSON(steps.multi-output-keySeparator.outputs)}} RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' run: *validateMultiImpl - ############################################################ + # ===================================================================== - name: '[act] test: query multiple properties as action outputs (different multi-char keySeparator)' id: multi-output-keySeparatorMC uses: ./ @@ -394,19 +410,21 @@ jobs: file: test/resources/test.properties keySeparator: ':/ ' keys: 'sourceJavaVersion:/ org.gradle.jvmargs:/ unicode_escapes:/ colon:in:Key' + resultType: output - name: '[assert] test: query multiple properties as action outputs (different multi-char keySeparator)' env: RESULT_JSON: ${{toJSON(steps.multi-output-keySeparatorMC.outputs)}} RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' run: *validateMultiImpl - ############################################################ + # ===================================================================== - name: '[act] test: query multiple properties as action outputs (in another keys order)' id: multi-output-2 uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS_OTHER_ORDER}}' + resultType: output - name: '[assert] test: query multiple properties as action outputs (in another keys order)' env: RESULT_JSON: ${{toJSON(steps.multi-output-2.outputs)}} @@ -419,7 +437,12 @@ jobs: eval "$SHELL_SETUP" assertEquals "$RESULT_VALUE" 'a«,»c' # from last key "unicode_escapes" - ############################################################ + ######################################################################### + test_output-named: + runs-on: ubuntu-latest + steps: + - *checkoutStep + # ===================================================================== - name: '[act] test: query multiple properties as action outputs of given names' id: multi-output-named uses: ./ @@ -457,7 +480,7 @@ jobs: eval "$SHELL_SETUP" assertEquals "$RESULT_VALUE" U_N_S_E_T - ############################################################ + # ===================================================================== - name: '[act] test: query multiple properties as action outputs of given names (different resultNameSeparator)' id: multi-output-named-resultNameSeparator uses: ./ @@ -472,7 +495,7 @@ jobs: RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' run: *validateMultiNamedImpl - ############################################################ + # ===================================================================== - name: '[act] test: query multiple properties as action outputs of given names (different multi-char resultNameSeparator)' id: multi-output-named-resultNameSeparatorMC uses: ./ @@ -487,7 +510,7 @@ jobs: RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' run: *validateMultiNamedImpl - ############################################################ + # ===================================================================== - name: '[act] test: query multiple properties as action outputs of given name' id: multi-output-named1 uses: ./ @@ -503,7 +526,7 @@ jobs: RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' run: &validateMultiNamed1Impl |- eval "$SHELL_SETUP" - evalAndAssertEquals "$RESULT_GETTER" my-prop 'c' + evalAndAssertEquals "$RESULT_GETTER" "$SELECTED_SINGLE_NAME" 'c' # The outputs with the default names are not set: evalAndAssertUndefined "$RESULT_GETTER" sourceJavaVersion @@ -515,13 +538,14 @@ jobs: evalAndAssertUndefined "$RESULT_GETTER" colonInValue evalAndAssertUndefined "$RESULT_GETTER" dash_1 - ############################################################ + # ===================================================================== - name: '[act] test: query single property as action outputs' id: single-output uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_SINGLE_KEY}}' + resultType: output - name: '[assert] test: query multiple properties as action outputs' env: @@ -560,7 +584,12 @@ jobs: evalAndAssertUndefined "$RESULT_GETTER" dash_3_4_1 evalAndAssertUndefined "$RESULT_GETTER" dash_3_4_8_1 - ############################################################ + ######################################################################### + test_json: + runs-on: ubuntu-latest + steps: + - *checkoutStep + # ===================================================================== - name: '[act] test: query all properties as JSON action output' id: all-json uses: ./ @@ -573,7 +602,7 @@ jobs: RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' run: *validateAllImpl - ############################################################ + # ===================================================================== - name: '[act] test: query multiple properties as JSON action output' id: multiple-json uses: ./ @@ -587,7 +616,12 @@ jobs: RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' run: *validateMultiImpl - ############################################################ + ######################################################################### + test_json-file: + runs-on: ubuntu-latest + steps: + - *checkoutStep + # ===================================================================== - name: '[arrange] test: query multiple properties as JSON file (creating file)' id: multiple-json-file-create-arrange run: |- @@ -607,7 +641,7 @@ jobs: RESULT_GETTER: 'getJsonValue "$(<"$JSON_OUTPUT_FILE")"' run: *validateMultiImpl - ############################################################ + # ===================================================================== - name: '[arrange] test: query multiple properties as JSON file (overwriting file)' id: multiple-json-file-overwrite-arrange run: |- @@ -629,7 +663,7 @@ jobs: RESULT_GETTER: 'getJsonValue "$(<"$JSON_OUTPUT_FILE")"' run: *validateMultiImpl - ############################################################ + # ===================================================================== - name: '[arrange] test: query all properties as JSON file' id: all-json-file-arrange # Distinction between creating and overwriting need not be tested again. @@ -652,11 +686,12 @@ jobs: # Each test which sets environment variables is a separate job to be independant of other tests. - ############################################################ - test-all-env: + ######################################################################### + test_env_all: runs-on: ubuntu-latest steps: - *checkoutStep + # ===================================================================== - name: '[act] test: query all properties as environment variables' uses: ./ with: @@ -667,11 +702,12 @@ jobs: RESULT_GETTER: getEnv run: *validateAllImpl - ############################################################ - test-multi-env: + ######################################################################### + test_env_multi: runs-on: ubuntu-latest steps: - *checkoutStep + # ===================================================================== - name: '[act] test: query multiple properties as environment variables' uses: ./ with: @@ -683,11 +719,12 @@ jobs: RESULT_GETTER: getEnv run: *validateMultiImpl - ############################################################ - test-multi-env-named: + ######################################################################### + test_env-named_multi: runs-on: ubuntu-latest steps: - *checkoutStep + # ===================================================================== - name: '[act] test: query multiple properties as environment variables of given names' uses: ./ with: @@ -699,11 +736,12 @@ jobs: RESULT_GETTER: getEnv run: *validateMultiNamedImpl - ############################################################ - test-multi-env-named1: + ######################################################################### + test_env-named_multi-to-one: runs-on: ubuntu-latest steps: - *checkoutStep + # ===================================================================== - name: '[act] test: query multiple properties as environment variables of given names' uses: ./ with: @@ -715,11 +753,12 @@ jobs: RESULT_GETTER: getEnv run: *validateMultiNamed1Impl - ############################################################ - test-single-env: + ######################################################################### + test_env_single: runs-on: ubuntu-latest steps: - *checkoutStep + # ===================================================================== - name: '[act] test: query single property as environment variable' uses: ./ with: @@ -731,11 +770,12 @@ jobs: RESULT_GETTER: getEnv run: *validateSingleImpl - ############################################################ - test-all-env-with-prefix: + ######################################################################### + test_env-with-prefix_all: runs-on: ubuntu-latest steps: - *checkoutStep + # ===================================================================== - name: '[act] test: query all properties as environment variables with prefix' uses: ./ with: @@ -746,11 +786,12 @@ jobs: RESULT_GETTER: 'getEnvWithPrefix ALL_PROPS__' run: *validateAllImpl - ############################################################ - test-multi-env-with-prefix: + ######################################################################### + test_env-with-prefix_multi: runs-on: ubuntu-latest steps: - *checkoutStep + # ===================================================================== - name: '[act] test: query multiple properties as environment variables with prefix' uses: ./ with: @@ -762,11 +803,12 @@ jobs: RESULT_GETTER: 'getEnvWithPrefix MULTI_PROPS__' run: *validateMultiImpl - ############################################################ - test-single-env-with-prefix: + ######################################################################### + test_env-with-prefix_single: runs-on: ubuntu-latest steps: - *checkoutStep + # ===================================================================== - name: '[act] test: query single property as environment variable with prefix' uses: ./ with: From 8f0e1ca274bb18a656127047c9626501c47b68d5 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Tue, 19 May 2026 17:17:48 +0200 Subject: [PATCH 45/69] add test for default resultType; shorten step names --- .github/workflows/test.yml | 148 +++++++++++++++++++++---------------- 1 file changed, 83 insertions(+), 65 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c1ffb57..f9b07a3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -168,14 +168,14 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} # ===================================================================== - - name: '[act] test (should fail): invalid input: resultType' + - name: '[act] (should fail): invalid input: resultType' id: error-input-resultType continue-on-error: true uses: ./ with: file: test/resources/test.properties resultType: rt1 - - name: '[assert] test (should fail): invalid input: resultType' + - name: '[assert] (should fail): invalid input: resultType' env: STATUS: ${{steps.error-input-resultType.outcome}} ERROR: ${{steps.error-input-resultType.outputs.error}} @@ -185,7 +185,7 @@ jobs: assertEquals "$ERROR" 'invalid resultType: rt1' # ===================================================================== - - name: '[act] test (should fail): resultType "output-named" without names' + - name: '[act] (should fail): resultType "output-named" without names' id: error-resultType-output-without-names continue-on-error: true uses: ./ @@ -193,7 +193,7 @@ jobs: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: 'output-named:' - - name: '[assert] test (should fail): resultType "output-named" without names' + - name: '[assert] (should fail): resultType "output-named" without names' env: STATUS: ${{steps.error-resultType-output-without-names.outcome}} ERROR: ${{steps.error-resultType-output-without-names.outputs.error}} @@ -203,7 +203,7 @@ jobs: assertEquals "$ERROR" 'invalid resultType output-named: (missing argument)' # ===================================================================== - - name: '[act] test (should fail): resultType "env-named" without names' + - name: '[act] (should fail): resultType "env-named" without names' id: error-resultType-env-without-names continue-on-error: true uses: ./ @@ -211,7 +211,7 @@ jobs: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: 'env-named:' - - name: '[assert] test (should fail): resultType "env-named" without names' + - name: '[assert] (should fail): resultType "env-named" without names' env: STATUS: ${{steps.error-resultType-env-without-names.outcome}} ERROR: ${{steps.error-resultType-env-without-names.outputs.error}} @@ -221,14 +221,14 @@ jobs: assertEquals "$ERROR" 'invalid resultType env-named: (missing argument)' # ===================================================================== - - name: '[act] test (should fail): resultType "output-named" without keys' + - name: '[act] (should fail): resultType "output-named" without keys' id: error-resultType-output-without-keys continue-on-error: true uses: ./ with: file: test/resources/test.properties resultType: 'output-named:a b' - - name: '[assert] test (should fail): resultType "output-named" without keys' + - name: '[assert] (should fail): resultType "output-named" without keys' env: STATUS: ${{steps.error-resultType-output-without-keys.outcome}} ERROR: ${{steps.error-resultType-output-without-keys.outputs.error}} @@ -238,14 +238,14 @@ jobs: assertEquals "$ERROR" 'invalid use of resultType output-named (missing keys)' # ===================================================================== - - name: '[act] test (should fail): resultType "env-named" without keys' + - name: '[act] (should fail): resultType "env-named" without keys' id: error-resultType-env-without-keys continue-on-error: true uses: ./ with: file: test/resources/test.properties resultType: 'env-named:a b' - - name: '[assert] test (should fail): resultType "env-named" without keys' + - name: '[assert] (should fail): resultType "env-named" without keys' env: STATUS: ${{steps.error-resultType-env-without-keys.outcome}} ERROR: ${{steps.error-resultType-env-without-keys.outputs.error}} @@ -255,6 +255,24 @@ jobs: assertEquals "$ERROR" 'invalid use of resultType env-named (missing keys)' ######################################################################### + test_default: + runs-on: ubuntu-latest + steps: + - *checkoutStep + # ===================================================================== + - name: '[act] query single property with default resultType' + id: single-default + uses: ./ + with: + file: test/resources/test.properties + keys: sourceJavaVersion + - name: '[assert] query single property with default resultType' + env: + RESULT: ${{steps.single-default.outputs._sourceJavaVersion}} + run: |- + eval "$SHELL_SETUP" + assertEquals "$RESULT" '21' + test_output: runs-on: ubuntu-latest steps: @@ -263,14 +281,14 @@ jobs: # This tests a subset of test case multi-output, but with hard-coded encoding from property key to output # name (instead of re-implemented). # It also demonstrates how you'd usually use outputs with one environment variable per output. - - name: '[act] test: query multiple properties as action outputs (simple)' + - name: '[act] query multiple properties as action outputs (simple)' id: multi-output-simple uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}} dash-in-Key' resultType: output - - name: '[assert] test: query multiple properties as action outputs (simple)' + - name: '[assert] query multiple properties as action outputs (simple)' env: RESULT_SJV: ${{steps.multi-output-simple.outputs._sourceJavaVersion}} RESULT_JVMARGS: ${{steps.multi-output-simple.outputs._org-002Egradle-002Ejvmargs}} @@ -286,13 +304,13 @@ jobs: assertEquals "$RESULT_UNI" 'a«,»c' # ===================================================================== - - name: '[act] test: query all properties as action outputs' + - name: '[act] query all properties as action outputs' id: all-output uses: ./ with: file: test/resources/test.properties resultType: output - - name: '[assert] test: query all properties as action outputs' + - name: '[assert] query all properties as action outputs' env: RESULT_JSON: ${{toJSON(steps.all-output.outputs)}} RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' @@ -327,7 +345,7 @@ jobs: evalAndAssertEquals "$RESULT_GETTER" dash_12 '------------' evalAndAssertEquals "$RESULT_GETTER" dash_3_4_1 $'---\n----\n-' evalAndAssertEquals "$RESULT_GETTER" dash_3_4_8_1 $'---\n----\n--------\n-' - - name: '[assert] test: query all properties as action outputs (additional "value" output not set)' + - name: '[assert] query all properties as action outputs (additional "value" output not set)' env: RESULT_VALUE: ${{steps.all-output.outputs.value || 'U_N_S_E_T'}} run: |- @@ -335,7 +353,7 @@ jobs: assertEquals "$RESULT_VALUE" U_N_S_E_T # ===================================================================== - - name: '[act] test: query multiple properties as action outputs' + - name: '[act] query multiple properties as action outputs' id: multi-output uses: ./ with: @@ -343,7 +361,7 @@ jobs: keys: '${{env.SELECTED_KEYS}}' resultType: output - - name: '[assert] test: query multiple properties as action outputs' + - name: '[assert] query multiple properties as action outputs' env: RESULT_JSON: ${{toJSON(steps.multi-output.outputs)}} RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' @@ -380,7 +398,7 @@ jobs: evalAndAssertUndefined "$RESULT_GETTER" dash_3_4_1 evalAndAssertUndefined "$RESULT_GETTER" dash_3_4_8_1 - - name: '[assert] test: query multiple properties as action outputs (additional "value" output)' + - name: '[assert] query multiple properties as action outputs (additional "value" output)' env: RESULT_VALUE: ${{steps.multi-output.outputs.value}} run: |- @@ -388,7 +406,7 @@ jobs: assertEquals "$RESULT_VALUE" 'c' # from last key "colon:in:Key" # ===================================================================== - - name: '[act] test: query multiple properties as action outputs (different keySeparator)' + - name: '[act] query multiple properties as action outputs (different keySeparator)' id: multi-output-keySeparator uses: ./ with: @@ -396,14 +414,14 @@ jobs: keySeparator: '?' # something with a special meaning in regex; should be treated as literal keys: 'sourceJavaVersion?org.gradle.jvmargs?unicode_escapes?colon:in:Key' resultType: output - - name: '[assert] test: query multiple properties as action outputs (different keySeparator)' + - name: '[assert] query multiple properties as action outputs (different keySeparator)' env: RESULT_JSON: ${{toJSON(steps.multi-output-keySeparator.outputs)}} RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' run: *validateMultiImpl # ===================================================================== - - name: '[act] test: query multiple properties as action outputs (different multi-char keySeparator)' + - name: '[act] query multiple properties as action outputs (different multi-char keySeparator)' id: multi-output-keySeparatorMC uses: ./ with: @@ -411,26 +429,26 @@ jobs: keySeparator: ':/ ' keys: 'sourceJavaVersion:/ org.gradle.jvmargs:/ unicode_escapes:/ colon:in:Key' resultType: output - - name: '[assert] test: query multiple properties as action outputs (different multi-char keySeparator)' + - name: '[assert] query multiple properties as action outputs (different multi-char keySeparator)' env: RESULT_JSON: ${{toJSON(steps.multi-output-keySeparatorMC.outputs)}} RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' run: *validateMultiImpl # ===================================================================== - - name: '[act] test: query multiple properties as action outputs (in another keys order)' + - name: '[act] query multiple properties as action outputs (in another keys order)' id: multi-output-2 uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS_OTHER_ORDER}}' resultType: output - - name: '[assert] test: query multiple properties as action outputs (in another keys order)' + - name: '[assert] query multiple properties as action outputs (in another keys order)' env: RESULT_JSON: ${{toJSON(steps.multi-output-2.outputs)}} RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' run: *validateMultiImpl - - name: '[assert] test: query multiple properties as action outputs (in another keys order) (additional "value" output)' + - name: '[assert] query multiple properties as action outputs (in another keys order) (additional "value" output)' env: RESULT_VALUE: ${{steps.multi-output-2.outputs.value}} run: |- @@ -443,7 +461,7 @@ jobs: steps: - *checkoutStep # ===================================================================== - - name: '[act] test: query multiple properties as action outputs of given names' + - name: '[act] query multiple properties as action outputs of given names' id: multi-output-named uses: ./ with: @@ -451,7 +469,7 @@ jobs: keys: '${{env.SELECTED_KEYS}}' resultType: 'output-named:${{env.SELECTED_NAMES}}' - - name: '[assert] test: query multiple properties as action outputs of given names' + - name: '[assert] query multiple properties as action outputs of given names' env: RESULT_JSON: ${{toJSON(steps.multi-output-named.outputs)}} # Output names specified as input are not subject to encoding (prefix "_", replace punctuation except "_"): @@ -473,7 +491,7 @@ jobs: evalAndAssertUndefined "$RESULT_GETTER" colonInValue evalAndAssertUndefined "$RESULT_GETTER" dash_1 - - name: '[assert] test: query multiple properties as action outputs (additional "value" output not set)' + - name: '[assert] query multiple properties as action outputs (additional "value" output not set)' env: RESULT_VALUE: ${{steps.multi-output-named.outputs.value || 'U_N_S_E_T'}} run: |- @@ -481,7 +499,7 @@ jobs: assertEquals "$RESULT_VALUE" U_N_S_E_T # ===================================================================== - - name: '[act] test: query multiple properties as action outputs of given names (different resultNameSeparator)' + - name: '[act] query multiple properties as action outputs of given names (different resultNameSeparator)' id: multi-output-named-resultNameSeparator uses: ./ with: @@ -489,14 +507,14 @@ jobs: keys: '${{env.SELECTED_KEYS}}' resultNameSeparator: '?' # something with a special meaning in regex; should be treated as literal resultType: 'output-named:java-version?jvm_args?unicode?colon' - - name: '[assert] test: query multiple properties as action outputs of given names (different resultNameSeparator)' + - name: '[assert] query multiple properties as action outputs of given names (different resultNameSeparator)' env: RESULT_JSON: ${{toJSON(steps.multi-output-named-resultNameSeparator.outputs)}} RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' run: *validateMultiNamedImpl # ===================================================================== - - name: '[act] test: query multiple properties as action outputs of given names (different multi-char resultNameSeparator)' + - name: '[act] query multiple properties as action outputs of given names (different multi-char resultNameSeparator)' id: multi-output-named-resultNameSeparatorMC uses: ./ with: @@ -504,14 +522,14 @@ jobs: keys: '${{env.SELECTED_KEYS}}' resultNameSeparator: '- -' resultType: 'output-named:java-version- -jvm_args- -unicode- -colon' - - name: '[assert] test: query multiple properties as action outputs of given names (different multi-char resultNameSeparator)' + - name: '[assert] query multiple properties as action outputs of given names (different multi-char resultNameSeparator)' env: RESULT_JSON: ${{toJSON(steps.multi-output-named-resultNameSeparatorMC.outputs)}} RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' run: *validateMultiNamedImpl # ===================================================================== - - name: '[act] test: query multiple properties as action outputs of given name' + - name: '[act] query multiple properties as action outputs of given name' id: multi-output-named1 uses: ./ with: @@ -519,7 +537,7 @@ jobs: keys: '${{env.SELECTED_KEYS}}' resultType: 'output-named:${{env.SELECTED_SINGLE_NAME}}' - - name: '[assert] test: query multiple properties as action outputs of given name' + - name: '[assert] query multiple properties as action outputs of given name' env: RESULT_JSON: ${{toJSON(steps.multi-output-named1.outputs)}} # Output names specified as input are not subject to encoding (prefix "_", replace punctuation except "_"): @@ -539,7 +557,7 @@ jobs: evalAndAssertUndefined "$RESULT_GETTER" dash_1 # ===================================================================== - - name: '[act] test: query single property as action outputs' + - name: '[act] query single property as action outputs' id: single-output uses: ./ with: @@ -547,7 +565,7 @@ jobs: keys: '${{env.SELECTED_SINGLE_KEY}}' resultType: output - - name: '[assert] test: query multiple properties as action outputs' + - name: '[assert] query multiple properties as action outputs' env: RESULT_JSON: ${{toJSON(steps.single-output.outputs)}} RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' @@ -590,27 +608,27 @@ jobs: steps: - *checkoutStep # ===================================================================== - - name: '[act] test: query all properties as JSON action output' + - name: '[act] query all properties as JSON action output' id: all-json uses: ./ with: file: test/resources/test.properties resultType: json - - name: '[assert] test: query all properties as JSON action output' + - name: '[assert] query all properties as JSON action output' env: RESULT_JSON: ${{steps.all-json.outputs.json}} RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' run: *validateAllImpl # ===================================================================== - - name: '[act] test: query multiple properties as JSON action output' + - name: '[act] query multiple properties as JSON action output' id: multiple-json uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: json - - name: '[assert] test: query multiple properties as JSON action output' + - name: '[assert] query multiple properties as JSON action output' env: RESULT_JSON: ${{steps.multiple-json.outputs.json}} RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' @@ -622,27 +640,27 @@ jobs: steps: - *checkoutStep # ===================================================================== - - name: '[arrange] test: query multiple properties as JSON file (creating file)' + - name: '[arrange] query multiple properties as JSON file (creating file)' id: multiple-json-file-create-arrange run: |- parentDir=$(mktemp --directory --dry-run --tmpdir=. test_json-file_multi_create_XXXXXX) file=$parentDir/out/result.json printf 'file=%s\n' "$file" | tee -- "$GITHUB_OUTPUT" # ⇒ action must create at least two parent dirs - - name: '[act] test: query multiple properties as JSON file (creating file)' + - name: '[act] query multiple properties as JSON file (creating file)' uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: 'json-file:${{steps.multiple-json-file-create-arrange.outputs.file}}' - - name: '[assert] test: query multiple properties as JSON file (creating file)' + - name: '[assert] query multiple properties as JSON file (creating file)' env: JSON_OUTPUT_FILE: ${{steps.multiple-json-file-create-arrange.outputs.file}} RESULT_GETTER: 'getJsonValue "$(<"$JSON_OUTPUT_FILE")"' run: *validateMultiImpl # ===================================================================== - - name: '[arrange] test: query multiple properties as JSON file (overwriting file)' + - name: '[arrange] query multiple properties as JSON file (overwriting file)' id: multiple-json-file-overwrite-arrange run: |- file=$(mktemp --tmpdir=. test_json-file_multi_overwrite_XXXXXX.json) @@ -651,20 +669,20 @@ jobs: for ((i=0; i<100; i++)); do printf 'This is ¬ JS}ON. '; done >"$file" ls -lAF -- "$file" printf 'file=%s\n' "$file" | tee -- "$GITHUB_OUTPUT" - - name: '[act] test: query multiple properties as JSON file (overwriting file)' + - name: '[act] query multiple properties as JSON file (overwriting file)' uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: 'json-file:${{steps.multiple-json-file-overwrite-arrange.outputs.file}}' - - name: '[assert] test: query multiple properties as JSON file (overwriting file)' + - name: '[assert] query multiple properties as JSON file (overwriting file)' env: JSON_OUTPUT_FILE: ${{steps.multiple-json-file-overwrite-arrange.outputs.file}} RESULT_GETTER: 'getJsonValue "$(<"$JSON_OUTPUT_FILE")"' run: *validateMultiImpl # ===================================================================== - - name: '[arrange] test: query all properties as JSON file' + - name: '[arrange] query all properties as JSON file' id: all-json-file-arrange # Distinction between creating and overwriting need not be tested again. # This test just uses an existing empty file. @@ -673,12 +691,12 @@ jobs: # java.nio.file.Path.getParent() returns null). file=$(mktemp --tmpdir=. test_json-file_all_XXXXXX.json | sed 's=^\./==') printf 'file=%s\n' "$file" | tee -- "$GITHUB_OUTPUT" - - name: '[act] test: query all properties as JSON file' + - name: '[act] query all properties as JSON file' uses: ./ with: file: test/resources/test.properties resultType: 'json-file:${{steps.all-json-file-arrange.outputs.file}}' - - name: '[assert] test: query all properties as JSON file' + - name: '[assert] query all properties as JSON file' env: JSON_OUTPUT_FILE: ${{steps.all-json-file-arrange.outputs.file}} RESULT_GETTER: 'getJsonValue "$(<"$JSON_OUTPUT_FILE")"' @@ -692,12 +710,12 @@ jobs: steps: - *checkoutStep # ===================================================================== - - name: '[act] test: query all properties as environment variables' + - name: '[act] query all properties as environment variables' uses: ./ with: file: test/resources/test.properties resultType: env - - name: '[assert] test: query all properties as environment variables' + - name: '[assert] query all properties as environment variables' env: RESULT_GETTER: getEnv run: *validateAllImpl @@ -708,13 +726,13 @@ jobs: steps: - *checkoutStep # ===================================================================== - - name: '[act] test: query multiple properties as environment variables' + - name: '[act] query multiple properties as environment variables' uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: env - - name: '[assert] test: query multiple properties as environment variables' + - name: '[assert] query multiple properties as environment variables' env: RESULT_GETTER: getEnv run: *validateMultiImpl @@ -725,13 +743,13 @@ jobs: steps: - *checkoutStep # ===================================================================== - - name: '[act] test: query multiple properties as environment variables of given names' + - name: '[act] query multiple properties as environment variables of given names' uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: 'env-named:${{env.SELECTED_NAMES}}' - - name: '[assert] test: query multiple properties as environment variables of given names' + - name: '[assert] query multiple properties as environment variables of given names' env: RESULT_GETTER: getEnv run: *validateMultiNamedImpl @@ -742,13 +760,13 @@ jobs: steps: - *checkoutStep # ===================================================================== - - name: '[act] test: query multiple properties as environment variables of given names' + - name: '[act] query multiple properties as environment variables of given names' uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: 'env-named:${{env.SELECTED_SINGLE_NAME}}' - - name: '[assert] test: query multiple properties as environment variables of given names' + - name: '[assert] query multiple properties as environment variables of given names' env: RESULT_GETTER: getEnv run: *validateMultiNamed1Impl @@ -759,13 +777,13 @@ jobs: steps: - *checkoutStep # ===================================================================== - - name: '[act] test: query single property as environment variable' + - name: '[act] query single property as environment variable' uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_SINGLE_KEY}}' resultType: env - - name: '[assert] test: query single property as environment variable' + - name: '[assert] query single property as environment variable' env: RESULT_GETTER: getEnv run: *validateSingleImpl @@ -776,12 +794,12 @@ jobs: steps: - *checkoutStep # ===================================================================== - - name: '[act] test: query all properties as environment variables with prefix' + - name: '[act] query all properties as environment variables with prefix' uses: ./ with: file: test/resources/test.properties resultType: 'env:ALL_PROPS__' - - name: '[assert] test: query all properties as environment variables' + - name: '[assert] query all properties as environment variables' env: RESULT_GETTER: 'getEnvWithPrefix ALL_PROPS__' run: *validateAllImpl @@ -792,13 +810,13 @@ jobs: steps: - *checkoutStep # ===================================================================== - - name: '[act] test: query multiple properties as environment variables with prefix' + - name: '[act] query multiple properties as environment variables with prefix' uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: 'env:MULTI_PROPS__' - - name: '[assert] test: query multiple properties as environment variables with prefix' + - name: '[assert] query multiple properties as environment variables with prefix' env: RESULT_GETTER: 'getEnvWithPrefix MULTI_PROPS__' run: *validateMultiImpl @@ -809,13 +827,13 @@ jobs: steps: - *checkoutStep # ===================================================================== - - name: '[act] test: query single property as environment variable with prefix' + - name: '[act] query single property as environment variable with prefix' uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_SINGLE_KEY}}' resultType: 'env:PROP__' - - name: '[assert] test: query single property as environment variable with prefix' + - name: '[assert] query single property as environment variable with prefix' env: RESULT_GETTER: 'getEnvWithPrefix PROP__' run: *validateSingleImpl From 53fd99c8759aeac6578f1d89f61c39061473a613 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Wed, 20 May 2026 14:45:59 +0200 Subject: [PATCH 46/69] better missing property handling - outputs: set to empty - environment variables: do not set - json: set key with value null --- .github/workflows/test.yml | 498 +++++++++++++++++++++++++++---------- Action.java | 146 +++++++---- action.yml | 12 +- 3 files changed, 470 insertions(+), 186 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f9b07a3..580a1fa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,10 +4,12 @@ defaults: run: shell: bash env: - SELECTED_SINGLE_KEY: 'org.gradle.jvmargs' + SELECTED_KEY: 'sourceJavaVersion' SELECTED_KEYS: 'sourceJavaVersion org.gradle.jvmargs unicode_escapes colon:in:Key' SELECTED_KEYS_OTHER_ORDER: 'colon:in:Key sourceJavaVersion org.gradle.jvmargs unicode_escapes' - SELECTED_SINGLE_NAME: 'my-prop' + SELECTED_NON_EX_KEY: 'nonExistingKey1' + SELECTED_NON_EX_KEY2: 'nonExistingKey2' + SELECTED_NAME: 'my-prop' SELECTED_NAMES: 'java-version jvm_args unicode colon' NOT_FOUND_STATUS: 54 @@ -27,6 +29,29 @@ env: perl -e '$/ = undef; $_ = <>; ($r = $_) =~ s{\n$}{}; die "missing trailing LF\n" if $r eq $_; printf "%s¶", $r' } + assertVariableUnset() { + local varName=$1; shift + if [ "${!varName+defined}" = defined ]; then + printf 'assertion error: %s (== "%s") is set\n' "$varName" "${!varName}" >&2 + return 1 + else + printf 'ok: %s is not set\n' "$varName" >&2 + fi + } + # test this function: + ( + set +e + unset x; assertVariableUnset x >&/dev/null; statusUnset=$? + x=''; assertVariableUnset x >&/dev/null; statusEmpty=$? + x=foo; assertVariableUnset x >&/dev/null; statusNonEmpty1=$? + x=defined; assertVariableUnset x >&/dev/null; statusNonEmpty2=$? + set -e + [ $statusUnset -eq 0 ] + [ $statusEmpty -ne 0 ] + [ $statusNonEmpty1 -ne 0 ] + [ $statusNonEmpty2 -ne 0 ] + ) + assertEquals() { debugPrintf 'assertEquals("%s", "%s")\n' "$1" "$2" if [ "$1" = "$2" ]; then @@ -60,7 +85,13 @@ env: local quotedArgs value quotedArgs=$(printf ' %q' "${args[@]}") + + # Call $getterFunction once to check its status only before the call to capture its output. + # Otherwise, we'd get a misleading error from replaceLastLF if $getterFunction prints nothing and exits + # with $NOT_FOUND_STATUS. + eval "${getterFunction}${quotedArgs}" >/dev/null eval "value=\$(set -o pipefail; ${getterFunction}${quotedArgs} | replaceLastLF)" + expectedValue="$(printf '%s\n' "$expectedValue" | replaceLastLF)" assertEquals "$value" "$expectedValue" } @@ -88,7 +119,39 @@ env: local json=$1; shift local key=$1; shift debugPrintf 'getJsonValue("%s", "%s")\n' "$json" "$key" - jq --arg k "$key" --raw-output '.[$k] // ("" | halt_error(env.NOT_FOUND_STATUS | tonumber))' <<<"$json" + jq --arg k "$key" --raw-output ' + .[$k] as $v + | if $v != null then + $v + elif (keys | index($k) != null) then #contains key $k (with a null or false value) + "" + else + (("key " + $k + " not found\n") | halt_error(env.NOT_FOUND_STATUS | tonumber)) + end' <<<"$json" + } + + assertJsonDoesNotContainKey() { + assertJsonContainsKeyEquals "$@" false + } + + assertJsonContainsKey() { + assertJsonContainsKeyEquals "$@" true + } + + assertJsonContainsKeyEquals() { + local json=$1; shift + local key=$1; shift + local expected=$1; shift + debugPrintf 'assertJsonContainsKeyEquals("%s", "%s", "%s")\n' "$json" "$key" "$expected" + local contains + contains=$(jq --arg k "$key" --raw-output 'keys | index($k) != null' <<<"$json") + assertEquals "$contains" "$expected" + } + + # Encodes a key $1 in the output-style encoding (property key to output name). + encodeKey() { + local key=$1; shift + perl -pe 's=((?!_)[[:punct:]])= sprintf("-%04X", ord($1)) =ge; s=^=_=;' <<<"$key" } # Extracts the value for key from JSON $2. @@ -96,7 +159,7 @@ env: local json=$1; shift local key=$1; shift local encodedKey - encodedKey="_$(perl -pe 's=((?!_)[[:punct:]])= sprintf("-%04X", ord($1)) =ge' <<<"$key")" + encodedKey=$(encodeKey "$key") getJsonValue "$json" "$encodedKey" "$@" } @@ -108,6 +171,7 @@ env: if printenv -- "$key"; then : # OK elif [ ${st-$?} -eq 1 ]; then + printf 'environment variable %s not set\n' "$key" >&2 return $NOT_FOUND_STATUS else # no error message, assuming printenv has already written one @@ -137,7 +201,7 @@ jobs: - test_env_all - test_env_multi - test_env-named_multi - - test_env-named_multi-to-one + - test_env-named_multi-to-single - test_env_single - test_env-with-prefix_all - test_env-with-prefix_multi @@ -169,7 +233,7 @@ jobs: # ===================================================================== - name: '[act] (should fail): invalid input: resultType' - id: error-input-resultType + id: input-error_resultType continue-on-error: true uses: ./ with: @@ -177,8 +241,8 @@ jobs: resultType: rt1 - name: '[assert] (should fail): invalid input: resultType' env: - STATUS: ${{steps.error-input-resultType.outcome}} - ERROR: ${{steps.error-input-resultType.outputs.error}} + STATUS: ${{steps.input-error_resultType.outcome}} + ERROR: ${{steps.input-error_resultType.outputs.error}} run: |- eval "$SHELL_SETUP" assertEquals "$STATUS" failure @@ -186,7 +250,7 @@ jobs: # ===================================================================== - name: '[act] (should fail): resultType "output-named" without names' - id: error-resultType-output-without-names + id: input-error_resultType-output-without-names continue-on-error: true uses: ./ with: @@ -195,8 +259,8 @@ jobs: resultType: 'output-named:' - name: '[assert] (should fail): resultType "output-named" without names' env: - STATUS: ${{steps.error-resultType-output-without-names.outcome}} - ERROR: ${{steps.error-resultType-output-without-names.outputs.error}} + STATUS: ${{steps.input-error_resultType-output-without-names.outcome}} + ERROR: ${{steps.input-error_resultType-output-without-names.outputs.error}} run: |- eval "$SHELL_SETUP" assertEquals "$STATUS" failure @@ -204,7 +268,7 @@ jobs: # ===================================================================== - name: '[act] (should fail): resultType "env-named" without names' - id: error-resultType-env-without-names + id: input-error_resultType-env-without-names continue-on-error: true uses: ./ with: @@ -213,8 +277,8 @@ jobs: resultType: 'env-named:' - name: '[assert] (should fail): resultType "env-named" without names' env: - STATUS: ${{steps.error-resultType-env-without-names.outcome}} - ERROR: ${{steps.error-resultType-env-without-names.outputs.error}} + STATUS: ${{steps.input-error_resultType-env-without-names.outcome}} + ERROR: ${{steps.input-error_resultType-env-without-names.outputs.error}} run: |- eval "$SHELL_SETUP" assertEquals "$STATUS" failure @@ -222,7 +286,7 @@ jobs: # ===================================================================== - name: '[act] (should fail): resultType "output-named" without keys' - id: error-resultType-output-without-keys + id: input-error_resultType-output-without-keys continue-on-error: true uses: ./ with: @@ -230,8 +294,8 @@ jobs: resultType: 'output-named:a b' - name: '[assert] (should fail): resultType "output-named" without keys' env: - STATUS: ${{steps.error-resultType-output-without-keys.outcome}} - ERROR: ${{steps.error-resultType-output-without-keys.outputs.error}} + STATUS: ${{steps.input-error_resultType-output-without-keys.outcome}} + ERROR: ${{steps.input-error_resultType-output-without-keys.outputs.error}} run: |- eval "$SHELL_SETUP" assertEquals "$STATUS" failure @@ -239,7 +303,7 @@ jobs: # ===================================================================== - name: '[act] (should fail): resultType "env-named" without keys' - id: error-resultType-env-without-keys + id: input-error_resultType-env-without-keys continue-on-error: true uses: ./ with: @@ -247,8 +311,8 @@ jobs: resultType: 'env-named:a b' - name: '[assert] (should fail): resultType "env-named" without keys' env: - STATUS: ${{steps.error-resultType-env-without-keys.outcome}} - ERROR: ${{steps.error-resultType-env-without-keys.outputs.error}} + STATUS: ${{steps.input-error_resultType-env-without-keys.outcome}} + ERROR: ${{steps.input-error_resultType-env-without-keys.outputs.error}} run: |- eval "$SHELL_SETUP" assertEquals "$STATUS" failure @@ -261,14 +325,14 @@ jobs: - *checkoutStep # ===================================================================== - name: '[act] query single property with default resultType' - id: single-default + id: default_single uses: ./ with: file: test/resources/test.properties keys: sourceJavaVersion - name: '[assert] query single property with default resultType' env: - RESULT: ${{steps.single-default.outputs._sourceJavaVersion}} + RESULT: ${{steps.default_single.outputs._sourceJavaVersion}} run: |- eval "$SHELL_SETUP" assertEquals "$RESULT" '21' @@ -282,7 +346,7 @@ jobs: # name (instead of re-implemented). # It also demonstrates how you'd usually use outputs with one environment variable per output. - name: '[act] query multiple properties as action outputs (simple)' - id: multi-output-simple + id: output_multi_simple uses: ./ with: file: test/resources/test.properties @@ -290,11 +354,11 @@ jobs: resultType: output - name: '[assert] query multiple properties as action outputs (simple)' env: - RESULT_SJV: ${{steps.multi-output-simple.outputs._sourceJavaVersion}} - RESULT_JVMARGS: ${{steps.multi-output-simple.outputs._org-002Egradle-002Ejvmargs}} - RESULT_COLONK: ${{steps.multi-output-simple.outputs._colon-003Ain-003AKey}} - RESULT_DASHK: ${{steps.multi-output-simple.outputs._dash-002Din-002DKey}} - RESULT_UNI: ${{steps.multi-output-simple.outputs._unicode_escapes}} + RESULT_SJV: ${{steps.output_multi_simple.outputs._sourceJavaVersion}} + RESULT_JVMARGS: ${{steps.output_multi_simple.outputs._org-002Egradle-002Ejvmargs}} + RESULT_COLONK: ${{steps.output_multi_simple.outputs._colon-003Ain-003AKey}} + RESULT_DASHK: ${{steps.output_multi_simple.outputs._dash-002Din-002DKey}} + RESULT_UNI: ${{steps.output_multi_simple.outputs._unicode_escapes}} run: |- eval "$SHELL_SETUP" assertEquals "$RESULT_SJV" '21' @@ -305,14 +369,14 @@ jobs: # ===================================================================== - name: '[act] query all properties as action outputs' - id: all-output + id: output_all uses: ./ with: file: test/resources/test.properties resultType: output - name: '[assert] query all properties as action outputs' env: - RESULT_JSON: ${{toJSON(steps.all-output.outputs)}} + RESULT_JSON: ${{toJSON(steps.output_all.outputs)}} RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' run: &validateAllImpl |- eval "$SHELL_SETUP" @@ -347,14 +411,14 @@ jobs: evalAndAssertEquals "$RESULT_GETTER" dash_3_4_8_1 $'---\n----\n--------\n-' - name: '[assert] query all properties as action outputs (additional "value" output not set)' env: - RESULT_VALUE: ${{steps.all-output.outputs.value || 'U_N_S_E_T'}} + RESULT_VALUE: ${{steps.output_all.outputs.value || 'U_N_S_E_T'}} run: |- eval "$SHELL_SETUP" assertEquals "$RESULT_VALUE" U_N_S_E_T # ===================================================================== - name: '[act] query multiple properties as action outputs' - id: multi-output + id: output_multi uses: ./ with: file: test/resources/test.properties @@ -363,7 +427,7 @@ jobs: - name: '[assert] query multiple properties as action outputs' env: - RESULT_JSON: ${{toJSON(steps.multi-output.outputs)}} + RESULT_JSON: ${{toJSON(steps.output_multi.outputs)}} RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' run: &validateMultiImpl |- eval "$SHELL_SETUP" @@ -400,14 +464,14 @@ jobs: - name: '[assert] query multiple properties as action outputs (additional "value" output)' env: - RESULT_VALUE: ${{steps.multi-output.outputs.value}} + RESULT_VALUE: ${{steps.output_multi.outputs.value}} run: |- eval "$SHELL_SETUP" assertEquals "$RESULT_VALUE" 'c' # from last key "colon:in:Key" # ===================================================================== - name: '[act] query multiple properties as action outputs (different keySeparator)' - id: multi-output-keySeparator + id: output_multi_keySeparator uses: ./ with: file: test/resources/test.properties @@ -416,13 +480,13 @@ jobs: resultType: output - name: '[assert] query multiple properties as action outputs (different keySeparator)' env: - RESULT_JSON: ${{toJSON(steps.multi-output-keySeparator.outputs)}} + RESULT_JSON: ${{toJSON(steps.output_multi_keySeparator.outputs)}} RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' run: *validateMultiImpl # ===================================================================== - name: '[act] query multiple properties as action outputs (different multi-char keySeparator)' - id: multi-output-keySeparatorMC + id: output_multi_keySeparatorMC uses: ./ with: file: test/resources/test.properties @@ -431,13 +495,13 @@ jobs: resultType: output - name: '[assert] query multiple properties as action outputs (different multi-char keySeparator)' env: - RESULT_JSON: ${{toJSON(steps.multi-output-keySeparatorMC.outputs)}} + RESULT_JSON: ${{toJSON(steps.output_multi_keySeparatorMC.outputs)}} RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' run: *validateMultiImpl # ===================================================================== - name: '[act] query multiple properties as action outputs (in another keys order)' - id: multi-output-2 + id: output_multi2 uses: ./ with: file: test/resources/test.properties @@ -445,16 +509,109 @@ jobs: resultType: output - name: '[assert] query multiple properties as action outputs (in another keys order)' env: - RESULT_JSON: ${{toJSON(steps.multi-output-2.outputs)}} + RESULT_JSON: ${{toJSON(steps.output_multi2.outputs)}} RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' run: *validateMultiImpl - name: '[assert] query multiple properties as action outputs (in another keys order) (additional "value" output)' env: - RESULT_VALUE: ${{steps.multi-output-2.outputs.value}} + RESULT_VALUE: ${{steps.output_multi2.outputs.value}} run: |- eval "$SHELL_SETUP" assertEquals "$RESULT_VALUE" 'a«,»c' # from last key "unicode_escapes" + # ===================================================================== + - name: '[act] query single property as action output' + id: output_single + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEY}}' + resultType: output + + - name: '[assert] query single property as action output' + env: + RESULT_JSON: ${{toJSON(steps.output_single.outputs)}} + RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' + run: &validateSingleImpl |- + eval "$SHELL_SETUP" + evalAndAssertEquals "$RESULT_GETTER" "$SELECTED_KEY" '21' + + evalAndAssertUndefined "$RESULT_GETTER" targetJavaVersion + evalAndAssertUndefined "$RESULT_GETTER" org.gradle.jvmargs + evalAndAssertUndefined "$RESULT_GETTER" colon:in:Key + evalAndAssertUndefined "$RESULT_GETTER" 'space in Key' + evalAndAssertUndefined "$RESULT_GETTER" colonInValue + evalAndAssertUndefined "$RESULT_GETTER" 'back\slash' + evalAndAssertUndefined "$RESULT_GETTER" dash-in-Key + evalAndAssertUndefined "$RESULT_GETTER" dashInValue + evalAndAssertUndefined "$RESULT_GETTER" empty + evalAndAssertUndefined "$RESULT_GETTER" null + evalAndAssertUndefined "$RESULT_GETTER" whitespace_escapes + evalAndAssertUndefined "$RESULT_GETTER" trailing_linefeed_1 + evalAndAssertUndefined "$RESULT_GETTER" trailing_linefeed_2 + evalAndAssertUndefined "$RESULT_GETTER" unicode_escapes + evalAndAssertUndefined "$RESULT_GETTER" dash_1 + evalAndAssertUndefined "$RESULT_GETTER" dash_2 + evalAndAssertUndefined "$RESULT_GETTER" dash_3 + evalAndAssertUndefined "$RESULT_GETTER" dash_4 + evalAndAssertUndefined "$RESULT_GETTER" dash_5 + evalAndAssertUndefined "$RESULT_GETTER" dash_6 + evalAndAssertUndefined "$RESULT_GETTER" dash_7 + evalAndAssertUndefined "$RESULT_GETTER" dash_8 + evalAndAssertUndefined "$RESULT_GETTER" dash_9 + evalAndAssertUndefined "$RESULT_GETTER" dash_10 + evalAndAssertUndefined "$RESULT_GETTER" dash_11 + evalAndAssertUndefined "$RESULT_GETTER" dash_12 + evalAndAssertUndefined "$RESULT_GETTER" dash_3_4_1 + evalAndAssertUndefined "$RESULT_GETTER" dash_3_4_8_1 + + # ===================================================================== + - name: '[act] query single missing property as action output' + id: output_single_missing + uses: ./ + with: + file: test/resources/test.properties + keys: ${{env.SELECTED_NON_EX_KEY}} + resultType: output + - name: '[assert] query single missing property as action output' + env: + RESULT: ${{steps.output_single_missing.outputs._nonExistingKey1}} + RESULT_VALUE: ${{steps.output_single_missing.outputs.value}} + RESULT_JSON: ${{toJSON(steps.output_single_missing.outputs)}} + run: |- + eval "$SHELL_SETUP" + # Like this, the expected empty value is not distinguishable from unset: + assertEquals "$RESULT" '' + assertEquals "$RESULT_VALUE" '' + # Assert set: + assertJsonContainsKey "$RESULT_JSON" "$(encodeKey "$SELECTED_NON_EX_KEY")" + assertJsonContainsKey "$RESULT_JSON" value + + # ===================================================================== + - name: '[act] query multiple missing properties as action outputs' + id: output_multi_missing + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_NON_EX_KEY}} ${{env.SELECTED_NON_EX_KEY2}}' + resultType: output + - name: '[assert] query multiple missing properties as action outputs' + env: + RESULT1: ${{steps.output_multi_missing.outputs._nonExistingKey1}} + RESULT2: ${{steps.output_multi_missing.outputs._nonExistingKey2}} + RESULT_VALUE: ${{steps.output_multi_missing.outputs.value}} + RESULT_JSON: ${{toJSON(steps.output_multi_missing.outputs)}} + run: |- + eval "$SHELL_SETUP" + # Like this, the expected empty value is not distinguishable from unset: + assertEquals "$RESULT1" '' + assertEquals "$RESULT2" '' + assertEquals "$RESULT_VALUE" '' + # Assert set: + assertJsonContainsKey "$RESULT_JSON" "$(encodeKey "$SELECTED_NON_EX_KEY")" + assertJsonContainsKey "$RESULT_JSON" "$(encodeKey "$SELECTED_NON_EX_KEY2")" + assertJsonContainsKey "$RESULT_JSON" value + ######################################################################### test_output-named: runs-on: ubuntu-latest @@ -462,7 +619,7 @@ jobs: - *checkoutStep # ===================================================================== - name: '[act] query multiple properties as action outputs of given names' - id: multi-output-named + id: output-named_multi uses: ./ with: file: test/resources/test.properties @@ -471,7 +628,7 @@ jobs: - name: '[assert] query multiple properties as action outputs of given names' env: - RESULT_JSON: ${{toJSON(steps.multi-output-named.outputs)}} + RESULT_JSON: ${{toJSON(steps.output-named_multi.outputs)}} # Output names specified as input are not subject to encoding (prefix "_", replace punctuation except "_"): RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' run: &validateMultiNamedImpl |- @@ -493,14 +650,14 @@ jobs: - name: '[assert] query multiple properties as action outputs (additional "value" output not set)' env: - RESULT_VALUE: ${{steps.multi-output-named.outputs.value || 'U_N_S_E_T'}} + RESULT_VALUE: ${{steps.output-named_multi.outputs.value || 'U_N_S_E_T'}} run: |- eval "$SHELL_SETUP" assertEquals "$RESULT_VALUE" U_N_S_E_T # ===================================================================== - name: '[act] query multiple properties as action outputs of given names (different resultNameSeparator)' - id: multi-output-named-resultNameSeparator + id: output-named_multi_resultNameSeparator uses: ./ with: file: test/resources/test.properties @@ -509,13 +666,13 @@ jobs: resultType: 'output-named:java-version?jvm_args?unicode?colon' - name: '[assert] query multiple properties as action outputs of given names (different resultNameSeparator)' env: - RESULT_JSON: ${{toJSON(steps.multi-output-named-resultNameSeparator.outputs)}} + RESULT_JSON: ${{toJSON(steps.output-named_multi_resultNameSeparator.outputs)}} RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' run: *validateMultiNamedImpl # ===================================================================== - name: '[act] query multiple properties as action outputs of given names (different multi-char resultNameSeparator)' - id: multi-output-named-resultNameSeparatorMC + id: output-named_multi_resultNameSeparatorMC uses: ./ with: file: test/resources/test.properties @@ -524,27 +681,27 @@ jobs: resultType: 'output-named:java-version- -jvm_args- -unicode- -colon' - name: '[assert] query multiple properties as action outputs of given names (different multi-char resultNameSeparator)' env: - RESULT_JSON: ${{toJSON(steps.multi-output-named-resultNameSeparatorMC.outputs)}} + RESULT_JSON: ${{toJSON(steps.output-named_multi_resultNameSeparatorMC.outputs)}} RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' run: *validateMultiNamedImpl # ===================================================================== - name: '[act] query multiple properties as action outputs of given name' - id: multi-output-named1 + id: output-named_multi-to-single uses: ./ with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' - resultType: 'output-named:${{env.SELECTED_SINGLE_NAME}}' + resultType: 'output-named:${{env.SELECTED_NAME}}' - name: '[assert] query multiple properties as action outputs of given name' env: - RESULT_JSON: ${{toJSON(steps.multi-output-named1.outputs)}} + RESULT_JSON: ${{toJSON(steps.output-named_multi-to-single.outputs)}} # Output names specified as input are not subject to encoding (prefix "_", replace punctuation except "_"): RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' run: &validateMultiNamed1Impl |- eval "$SHELL_SETUP" - evalAndAssertEquals "$RESULT_GETTER" "$SELECTED_SINGLE_NAME" 'c' + evalAndAssertEquals "$RESULT_GETTER" "$SELECTED_NAME" 'c' # The outputs with the default names are not set: evalAndAssertUndefined "$RESULT_GETTER" sourceJavaVersion @@ -557,50 +714,42 @@ jobs: evalAndAssertUndefined "$RESULT_GETTER" dash_1 # ===================================================================== - - name: '[act] query single property as action outputs' - id: single-output + - name: '[act] query single missing property as action output of given name' + id: output-named_single_missing uses: ./ with: file: test/resources/test.properties - keys: '${{env.SELECTED_SINGLE_KEY}}' - resultType: output - - - name: '[assert] query multiple properties as action outputs' + keys: ${{env.SELECTED_NON_EX_KEY}} + resultType: 'output-named:${{env.SELECTED_NAME}}' + - name: '[assert] query single missing property as action output of given name' env: - RESULT_JSON: ${{toJSON(steps.single-output.outputs)}} - RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' - run: &validateSingleImpl |- + RESULT: ${{steps.output-named_single_missing.outputs.my-prop}} + RESULT_JSON: ${{toJSON(steps.output-named_single_missing.outputs)}} + run: |- eval "$SHELL_SETUP" - evalAndAssertEquals "$RESULT_GETTER" org.gradle.jvmargs '-ea -showversion' + # Like this, the expected empty value is not distinguishable from unset: + assertEquals "$RESULT" '' + # Assert set: + assertJsonContainsKey "$RESULT_JSON" "$SELECTED_NAME" - evalAndAssertUndefined "$RESULT_GETTER" sourceJavaVersion - evalAndAssertUndefined "$RESULT_GETTER" targetJavaVersion - evalAndAssertUndefined "$RESULT_GETTER" colon:in:Key - evalAndAssertUndefined "$RESULT_GETTER" 'space in Key' - evalAndAssertUndefined "$RESULT_GETTER" colonInValue - evalAndAssertUndefined "$RESULT_GETTER" 'back\slash' - evalAndAssertUndefined "$RESULT_GETTER" dash-in-Key - evalAndAssertUndefined "$RESULT_GETTER" dashInValue - evalAndAssertUndefined "$RESULT_GETTER" empty - evalAndAssertUndefined "$RESULT_GETTER" null - evalAndAssertUndefined "$RESULT_GETTER" whitespace_escapes - evalAndAssertUndefined "$RESULT_GETTER" trailing_linefeed_1 - evalAndAssertUndefined "$RESULT_GETTER" trailing_linefeed_2 - evalAndAssertUndefined "$RESULT_GETTER" unicode_escapes - evalAndAssertUndefined "$RESULT_GETTER" dash_1 - evalAndAssertUndefined "$RESULT_GETTER" dash_2 - evalAndAssertUndefined "$RESULT_GETTER" dash_3 - evalAndAssertUndefined "$RESULT_GETTER" dash_4 - evalAndAssertUndefined "$RESULT_GETTER" dash_5 - evalAndAssertUndefined "$RESULT_GETTER" dash_6 - evalAndAssertUndefined "$RESULT_GETTER" dash_7 - evalAndAssertUndefined "$RESULT_GETTER" dash_8 - evalAndAssertUndefined "$RESULT_GETTER" dash_9 - evalAndAssertUndefined "$RESULT_GETTER" dash_10 - evalAndAssertUndefined "$RESULT_GETTER" dash_11 - evalAndAssertUndefined "$RESULT_GETTER" dash_12 - evalAndAssertUndefined "$RESULT_GETTER" dash_3_4_1 - evalAndAssertUndefined "$RESULT_GETTER" dash_3_4_8_1 + # ===================================================================== + - name: '[act] query multiple missing properties as action outputs of given name' + id: output-named_multi_missing + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_NON_EX_KEY}} ${{env.SELECTED_NON_EX_KEY2}}' + resultType: 'output-named:${{env.SELECTED_NAME}}' + - name: '[assert] query multiple missing properties as action outputs of given names' + env: + RESULT: ${{steps.output-named_multi_missing.outputs.nonExistingKey1}} + RESULT_JSON: ${{toJSON(steps.output-named_multi_missing.outputs)}} + run: |- + eval "$SHELL_SETUP" + # Like this, the expected empty value is not distinguishable from unset: + assertEquals "$RESULT" '' + # Assert set: + assertJsonContainsKey "$RESULT_JSON" "$SELECTED_NAME" ######################################################################### test_json: @@ -609,20 +758,20 @@ jobs: - *checkoutStep # ===================================================================== - name: '[act] query all properties as JSON action output' - id: all-json + id: json_all uses: ./ with: file: test/resources/test.properties resultType: json - name: '[assert] query all properties as JSON action output' env: - RESULT_JSON: ${{steps.all-json.outputs.json}} + RESULT_JSON: ${{steps.json_all.outputs.json}} RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' run: *validateAllImpl # ===================================================================== - name: '[act] query multiple properties as JSON action output' - id: multiple-json + id: json_multi uses: ./ with: file: test/resources/test.properties @@ -630,10 +779,33 @@ jobs: resultType: json - name: '[assert] query multiple properties as JSON action output' env: - RESULT_JSON: ${{steps.multiple-json.outputs.json}} + RESULT_JSON: ${{steps.json_multi.outputs.json}} RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' run: *validateMultiImpl + # ===================================================================== + - name: '[act] query multiple missing properties as JSON action output' + id: json_multi_missing + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_NON_EX_KEY}} ${{env.SELECTED_KEYS}} ${{env.SELECTED_NON_EX_KEY2}}' + resultType: json + - name: '[assert] query multiple missing properties as JSON action output' + env: + RESULT_JSON: ${{steps.json_multi_missing.outputs.json}} + run: |- + eval "$SHELL_SETUP" + resultGetter='getJsonValue "$RESULT_JSON"' + evalAndAssertEquals "$resultGetter" sourceJavaVersion '21' + evalAndAssertEquals "$resultGetter" org.gradle.jvmargs '-ea -showversion' + evalAndAssertEquals "$resultGetter" colon:in:Key 'c' + evalAndAssertEquals "$resultGetter" unicode_escapes 'a«,»c' + evalAndAssertEquals "$resultGetter" "$SELECTED_NON_EX_KEY" '' + evalAndAssertEquals "$resultGetter" "$SELECTED_NON_EX_KEY2" '' + assertJsonContainsKey "$RESULT_JSON" "$SELECTED_NON_EX_KEY" + assertJsonContainsKey "$RESULT_JSON" "$SELECTED_NON_EX_KEY2" + ######################################################################### test_json-file: runs-on: ubuntu-latest @@ -641,7 +813,7 @@ jobs: - *checkoutStep # ===================================================================== - name: '[arrange] query multiple properties as JSON file (creating file)' - id: multiple-json-file-create-arrange + id: json-file_multi_create_arrange run: |- parentDir=$(mktemp --directory --dry-run --tmpdir=. test_json-file_multi_create_XXXXXX) file=$parentDir/out/result.json @@ -652,16 +824,16 @@ jobs: with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' - resultType: 'json-file:${{steps.multiple-json-file-create-arrange.outputs.file}}' + resultType: 'json-file:${{steps.json-file_multi_create_arrange.outputs.file}}' - name: '[assert] query multiple properties as JSON file (creating file)' env: - JSON_OUTPUT_FILE: ${{steps.multiple-json-file-create-arrange.outputs.file}} + JSON_OUTPUT_FILE: ${{steps.json-file_multi_create_arrange.outputs.file}} RESULT_GETTER: 'getJsonValue "$(<"$JSON_OUTPUT_FILE")"' run: *validateMultiImpl # ===================================================================== - name: '[arrange] query multiple properties as JSON file (overwriting file)' - id: multiple-json-file-overwrite-arrange + id: json-file_multi_overwrite_arrange run: |- file=$(mktemp --tmpdir=. test_json-file_multi_overwrite_XXXXXX.json) # Write longer contents than the expected output. ⇒ If the action does not truncate, there will be @@ -674,19 +846,19 @@ jobs: with: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' - resultType: 'json-file:${{steps.multiple-json-file-overwrite-arrange.outputs.file}}' + resultType: 'json-file:${{steps.json-file_multi_overwrite_arrange.outputs.file}}' - name: '[assert] query multiple properties as JSON file (overwriting file)' env: - JSON_OUTPUT_FILE: ${{steps.multiple-json-file-overwrite-arrange.outputs.file}} + JSON_OUTPUT_FILE: ${{steps.json-file_multi_overwrite_arrange.outputs.file}} RESULT_GETTER: 'getJsonValue "$(<"$JSON_OUTPUT_FILE")"' run: *validateMultiImpl # ===================================================================== - name: '[arrange] query all properties as JSON file' - id: all-json-file-arrange + id: json-file_all_arrange # Distinction between creating and overwriting need not be tested again. # This test just uses an existing empty file. - run: |- + run: &createEmptyJsonFile |- # Remove leading "./" ⇒ Also test the case where given file has "no parent" (i.e. Java method # java.nio.file.Path.getParent() returns null). file=$(mktemp --tmpdir=. test_json-file_all_XXXXXX.json | sed 's=^\./==') @@ -695,13 +867,39 @@ jobs: uses: ./ with: file: test/resources/test.properties - resultType: 'json-file:${{steps.all-json-file-arrange.outputs.file}}' + resultType: 'json-file:${{steps.json-file_all_arrange.outputs.file}}' - name: '[assert] query all properties as JSON file' env: - JSON_OUTPUT_FILE: ${{steps.all-json-file-arrange.outputs.file}} + JSON_OUTPUT_FILE: ${{steps.json-file_all_arrange.outputs.file}} RESULT_GETTER: 'getJsonValue "$(<"$JSON_OUTPUT_FILE")"' run: *validateAllImpl + # ===================================================================== + - name: '[arrange] query multiple missing properties as JSON file' + id: json-file_multi_missing_arrange + run: *createEmptyJsonFile + - name: '[act] query multiple missing properties as JSON file' + id: json-file_multi_missing + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_NON_EX_KEY}} ${{env.SELECTED_KEYS}} ${{env.SELECTED_NON_EX_KEY2}}' + resultType: 'json-file:${{steps.json-file_multi_missing_arrange.outputs.file}}' + - name: '[assert] query multiple missing properties as JSON file' + env: + JSON_OUTPUT_FILE: ${{steps.json-file_multi_missing_arrange.outputs.file}} + run: |- + eval "$SHELL_SETUP" + resultJson=$(<"$JSON_OUTPUT_FILE") + resultGetter='getJsonValue "$resultJson"' + evalAndAssertEquals "$resultGetter" sourceJavaVersion '21' + evalAndAssertEquals "$resultGetter" org.gradle.jvmargs '-ea -showversion' + evalAndAssertEquals "$resultGetter" colon:in:Key 'c' + evalAndAssertEquals "$resultGetter" unicode_escapes 'a«,»c' + evalAndAssertEquals "$resultGetter" "$SELECTED_NON_EX_KEY" '' + evalAndAssertEquals "$resultGetter" "$SELECTED_NON_EX_KEY2" '' + assertJsonContainsKey "$resultJson" "$SELECTED_NON_EX_KEY" + assertJsonContainsKey "$resultJson" "$SELECTED_NON_EX_KEY2" # Each test which sets environment variables is a separate job to be independant of other tests. ######################################################################### @@ -738,38 +936,23 @@ jobs: run: *validateMultiImpl ######################################################################### - test_env-named_multi: + test_env_multi_missing: runs-on: ubuntu-latest steps: - *checkoutStep # ===================================================================== - - name: '[act] query multiple properties as environment variables of given names' - uses: ./ - with: - file: test/resources/test.properties - keys: '${{env.SELECTED_KEYS}}' - resultType: 'env-named:${{env.SELECTED_NAMES}}' - - name: '[assert] query multiple properties as environment variables of given names' - env: - RESULT_GETTER: getEnv - run: *validateMultiNamedImpl - - ######################################################################### - test_env-named_multi-to-one: - runs-on: ubuntu-latest - steps: - - *checkoutStep - # ===================================================================== - - name: '[act] query multiple properties as environment variables of given names' + - name: '[act] query multiple missing properties as environment variables' uses: ./ with: file: test/resources/test.properties - keys: '${{env.SELECTED_KEYS}}' - resultType: 'env-named:${{env.SELECTED_SINGLE_NAME}}' - - name: '[assert] query multiple properties as environment variables of given names' - env: - RESULT_GETTER: getEnv - run: *validateMultiNamed1Impl + keys: '${{env.SELECTED_NON_EX_KEY}} ${{env.SELECTED_KEY}} ${{env.SELECTED_NON_EX_KEY2}}' + resultType: env + - name: '[assert] query multiple missing properties as environment variables' + run: |- + eval "$SHELL_SETUP" + assertVariableUnset "$SELECTED_NON_EX_KEY" + assertVariableUnset "$SELECTED_NON_EX_KEY2" + assertEquals "${!SELECTED_KEY}" '21' ######################################################################### test_env_single: @@ -781,7 +964,7 @@ jobs: uses: ./ with: file: test/resources/test.properties - keys: '${{env.SELECTED_SINGLE_KEY}}' + keys: '${{env.SELECTED_KEY}}' resultType: env - name: '[assert] query single property as environment variable' env: @@ -831,9 +1014,62 @@ jobs: uses: ./ with: file: test/resources/test.properties - keys: '${{env.SELECTED_SINGLE_KEY}}' + keys: '${{env.SELECTED_KEY}}' resultType: 'env:PROP__' - name: '[assert] query single property as environment variable with prefix' env: RESULT_GETTER: 'getEnvWithPrefix PROP__' run: *validateSingleImpl + + ######################################################################### + test_env-named_multi: + runs-on: ubuntu-latest + steps: + - *checkoutStep + # ===================================================================== + - name: '[act] query multiple properties as environment variables of given names' + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS}}' + resultType: 'env-named:${{env.SELECTED_NAMES}}' + - name: '[assert] query multiple properties as environment variables of given names' + env: + RESULT_GETTER: getEnv + run: *validateMultiNamedImpl + + ######################################################################### + test_env-named_multi_missing: + runs-on: ubuntu-latest + steps: + - *checkoutStep + # ===================================================================== + - name: '[act] query multiple missing properties as environment variables' + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_NON_EX_KEY}} ${{env.SELECTED_KEY}} ${{env.SELECTED_NON_EX_KEY2}}' + resultType: 'env-named:NE1 E1 NE2' + - name: '[assert] query multiple missing properties as environment variables' + run: |- + eval "$SHELL_SETUP" + assertVariableUnset NE1 + assertVariableUnset NE2 + assertEquals "$E1" '21' + + ######################################################################### + test_env-named_multi-to-single: + runs-on: ubuntu-latest + steps: + - *checkoutStep + # ===================================================================== + - name: '[act] query multiple properties as environment variables of given names' + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS}}' + resultType: 'env-named:${{env.SELECTED_NAME}}' + - name: '[assert] query multiple properties as environment variables of given names' + env: + RESULT_GETTER: getEnv + run: *validateMultiNamed1Impl diff --git a/Action.java b/Action.java index d57e716..4759a0e 100644 --- a/Action.java +++ b/Action.java @@ -16,6 +16,7 @@ import java.util.Optional; import java.util.Properties; import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -29,11 +30,16 @@ static void main(String[] args) throws Exception { ResultWriter resultWriter = ResultWriter.of(config.resultType()); for (String file : args) { Properties properties = Util.readProperties(file); - Map selectedProperties = config.selectedKeys() + // If selectedKeys is set, this Map will have exactly those keys and values may be empty (where selected key is + // not found in properties). + // Otherwise, this Map will have the same keys as the properties file, and "not found" is not possible. + Map> selectedProperties = config.selectedKeys() .map(keys -> Util.selectProperties(properties, keys, file)) // .orElseGet(() -> Util.stringEntries(properties)); resultWriter.write(selectedProperties, config); } + } catch (IoRuntimeException e) { + throw e.getCause(); } catch (OutputException e) { try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { writer.write(Ids.OutputName.ERROR, e.getMessage()); @@ -97,6 +103,18 @@ enum OutputName { } +class IoRuntimeException extends RuntimeException { + public IoRuntimeException(IOException cause) { + super(cause); + } + + @Override + public synchronized IOException getCause() { + return (IOException) super.getCause(); + } +} + + /** * An exception for which an "error" output should be set. */ @@ -156,17 +174,17 @@ public void requireNoArg() { enum ResultWriter { OUTPUT(Ids.ResultWriterName.OUTPUT) { @Override - public void write(Map props, Config config) throws IOException { + public void write(Map> props, Config config) throws IOException { String lastValue = null; try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { - for (Map.Entry entry : props.entrySet()) { + for (Map.Entry> entry : props.entrySet()) { String key = encodeKey(config.outputPrefix() + entry.getKey()); - lastValue = entry.getValue(); + lastValue = entry.getValue().orElse(""); writer.write(key, lastValue); } - if (config.selectedKeys().isPresent() && lastValue != null) { - writer.write(Ids.OutputName.VALUE, lastValue); + if (config.selectedKeys().isPresent()) { + writer.write(Ids.OutputName.VALUE, lastValue != null ? lastValue : ""); } } } @@ -183,48 +201,49 @@ private static String encodeKey(String key) { }, OUTPUT_NAMED(Ids.ResultWriterName.OUTPUT_NAMED) { @Override - public void write(Map props, Config config) throws IOException { - writeNamedImpl(props, config, GitHubOutputFile.OUTPUT); + public void write(Map> props, Config config) throws IOException { + writeNamedImpl(props, config, true, GitHubOutputFile.OUTPUT); } }, ENV_NAMED(Ids.ResultWriterName.ENV_NAMED) { @Override - public void write(Map props, Config config) throws IOException { - writeNamedImpl(props, config, GitHubOutputFile.ENV); + public void write(Map> props, Config config) throws IOException { + writeNamedImpl(props, config, false, GitHubOutputFile.ENV); } }, ENV(Ids.ResultWriterName.ENV) { @Override - public void write(Map props, Config config) throws IOException { + public void write(Map> props, Config config) throws IOException { String prefix = config.resultTypeArg(); try (GitHubVariableWriter writer = GitHubOutputFile.ENV.open()) { - for (Map.Entry entry : props.entrySet()) { - writer.write(prefix + entry.getKey(), entry.getValue()); + for (Map.Entry> entry : props.entrySet()) { + Optional value = entry.getValue(); + value.ifPresent(v -> writer.write(prefix + entry.getKey(), v)); } } } }, JSON(Ids.ResultWriterName.JSON) { @Override - public void write(Map props, Config config) throws IOException { + public void write(Map> props, Config config) throws IOException { config.requireNoArg(); try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { - writer.write(Ids.OutputName.JSON, Util.toJson(props)); + writer.write(Ids.OutputName.JSON, Util.toJson(props).s()); } } }, JSON_FILE(Ids.ResultWriterName.JSON_FILE) { @Override - public void write(Map props, Config config) throws IOException { + public void write(Map> props, Config config) throws IOException { String outputFile = config.requiredResultTypeArg(); Path parentDir = Paths.get(outputFile).getParent(); if (parentDir != null) { Files.createDirectories(parentDir); } try (Writer writer = Util.openFile(outputFile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { - String jsonResult = Util.toJson(props); - System.err.format("writing JSON for %s properties to %s%n", props.size(), outputFile); - writer.write(jsonResult); + StringIntPair jsonResult = Util.toJson(props); + System.err.format("writing JSON for %s properties to %s%n", jsonResult.i(), outputFile); + writer.write(jsonResult.s()); writer.write('\n'); writer.flush(); } @@ -246,10 +265,10 @@ public static ResultWriter of(String externalName) { throw OutputException.forIllegalArgument("invalid " + Ids.ConfigVariable.RESULT_TYPE + ": " + externalName); } - public abstract void write(Map props, Config config) throws IOException; + public abstract void write(Map> props, Config config) throws IOException; - private static void writeNamedImpl(Map props, Config config, GitHubOutputFile gitHubOutputFile) - throws IOException { + private static void writeNamedImpl(Map> props, Config config, boolean includeMissing, + GitHubOutputFile gitHubOutputFile) throws IOException { List selectedKeys = config.selectedKeys().orElseThrow(() -> OutputException.forIllegalArgument("invalid use of " + Ids.ConfigVariable.RESULT_TYPE + " " + config.resultType() + " (missing " + Ids.ConfigVariable.KEYS + ")")); @@ -263,8 +282,14 @@ private static void writeNamedImpl(Map props, Config config, Git try (GitHubVariableWriter writer = gitHubOutputFile.open()) { for (int i = 0; i < selectedKeys.size(); i++) { String name = resultNames[resultNames.length == 1 ? 0 : i]; - String value = props.get(selectedKeys.get(i)); - writer.write(name, value != null ? value : ""); + Optional value = props.get(selectedKeys.get(i)); + value.ifPresentOrElse(v -> writer.write(name, v), () -> { + if (includeMissing) { + writer.write(name, ""); + } else { + // Nothing to do. "not found" has already been logged. + } + }); } } } @@ -300,21 +325,25 @@ public GitHubVariableWriter(String description, String fileName) throws IOExcept this.writer = Util.openFile(fileName, StandardOpenOption.CREATE, StandardOpenOption.APPEND); } - public void write(String key, String value) throws IOException { - System.err.format("setting %s %s%n", description, key); - - // write (very) simple values in format "=": - if (SIMPLE_VALUE.matcher(value).matches()) { - this.writer.write(key); - this.writer.write('='); - this.writer.write(value); - this.writer.write('\n'); - } else { - writeMultiLine(key, value); + public void write(String key, String value) { + try { + System.err.format("setting %s %s%n", description, key); + + // write (very) simple values in format "=": + if (SIMPLE_VALUE.matcher(value).matches()) { + this.writer.write(key); + this.writer.write('='); + this.writer.write(value); + this.writer.write('\n'); + } else { + writeMultiLine(key, value); + } + } catch (IOException e) { + throw new IoRuntimeException(e); } } - public void write(Ids.OutputName key, String value) throws IOException { + public void write(Ids.OutputName key, String value) { write(key.externalName, value); } @@ -393,13 +422,13 @@ public static Properties readProperties(String file) throws IOException { /** * Selects the properties with the given keys and returns them as a Map with the corresponding iteration order. */ - public static Map selectProperties(Properties allProps, List selectedKeys, String file) { + public static Map> selectProperties(Properties allProps, List selectedKeys, String file) { Set unmatchedKeysSet = new LinkedHashSet<>(selectedKeys); - Map results = new LinkedHashMap<>(); + Map> results = new LinkedHashMap<>(); for (String key : selectedKeys) { String value = allProps.getProperty(key); + results.put(key, Optional.ofNullable(value)); if (value != null) { - results.put(key, value); unmatchedKeysSet.remove(key); } } @@ -409,25 +438,40 @@ public static Map selectProperties(Properties allProps, List stringEntries(Properties props) { - Map map = new LinkedHashMap<>(); + /** + * @return the Properties as a Map with each value as a non-empty Optional (strange, but useful for our use case) + */ + public static Map> stringEntries(Properties props) { + Map> map = new LinkedHashMap<>(); for (String key : props.stringPropertyNames()) { - map.put(key, props.getProperty(key)); + map.put(key, Optional.of(props.getProperty(key))); } return map; } - public static String toJson(Map map) { + /** + * @return the JSON string and the number of entries with non-empty value + */ + public static StringIntPair toJson(Map> map) { + AtomicInteger size = new AtomicInteger(0); StringBuilder s = new StringBuilder(50).append('{'); int initialLength = s.length(); - for (Map.Entry entry : map.entrySet()) { - s.append(s.length() == initialLength ? '"' : ", \""); - appendJsonString(s, entry.getKey()); - s.append("\": \""); - appendJsonString(s, entry.getValue()); + for (Map.Entry> entry : map.entrySet()) { + if (s.length() != initialLength) { + s.append(", "); + } s.append('"'); + appendJsonString(s, entry.getKey()); + s.append("\": "); + entry.getValue().ifPresentOrElse(value -> { + size.incrementAndGet(); + s.append('"'); + appendJsonString(s, value); + s.append('"'); + }, () -> s.append("null")); } - return s.append('}').toString(); + s.append('}'); + return new StringIntPair(s.toString(), size.get()); } private static void appendJsonString(StringBuilder buffer, String s) { @@ -456,3 +500,7 @@ private static void appendJsonString(StringBuilder buffer, String s) { } } } + + +record StringIntPair(String s, int i) { +} diff --git a/action.yml b/action.yml index 97e5ed6..d771ef7 100644 --- a/action.yml +++ b/action.yml @@ -27,16 +27,16 @@ inputs: Encoding replaces all punctuation or whitespace characters except the underscore ("_") with "-" followed by four hex digits of its unicode code point. For example, for a property key "a.b-c_d", resultType "output" sets an output with name "a-002Eb-002Dc_d". - Additionally, set output "value" to the last found value, unless `keys` is empty. I.e. last given key wins. (Without keys, this output is not set, because it would be arbitrary due to the "random" iteration order of the used Java class java.util.Properties.) - - "output-named:": is a `resultNameSeparator`-separated list of output names of the same length as (the `keySeparator`-separated list) `keys`. The value for `keys[i]` is set as output `[i]`. In order not to hide any future builtin outputs of this action, it is recommended to prefix each name with an underscore ("_"). In this mode, `keys` is required. Unlike "output", names are taken as-is. (This is because no official output naming rules seem to exist yet; so maybe a future user will know better how to choose valid characters than we would implement now.) - - "output-named:": Special case: A single name is supported even with multiple `keys`. All found values for the keys are set as the same output; the last found key wins. In this mode, `keys` is required. + Additionally, set output "value" to the last found value, unless `keys` is empty. I.e. last given key wins. If `keys` is given, but none is found, "value" is set to empty. (Without keys, this output is not set, because it would be arbitrary due to the "random" iteration order of the used Java class java.util.Properties.) + - "output-named:": is a `resultNameSeparator`-separated list of output names of the same length as (the `keySeparator`-separated list) `keys`. The value for `keys[i]` is set as output `[i]`. In order not to hide any future builtin outputs of this action, it is recommended to prefix each name with an underscore ("_"). In this mode, `keys` is required. Unlike "output", names are taken as-is. (This is because no official output naming rules seem to exist yet; so maybe a future user will know better how to choose valid characters than we would implement now.) The output for a missing property is set to empty. If you need to distinguish between empty an undefined properties, resultType "json" or "json-file" is recommended. + - "output-named:": Special case: A single name is supported even with multiple `keys`. All found values for the keys are set as the same output; the last found key wins. In this mode, `keys` is required. If none is found, the output is set to empty. - "env": For each property key , set an environment variable . In order not to pollute the environment with hard to understand variables, this should only be used to set some specifically named variables. I.e. you know that the property file can only contain such properties or you are selecting only such properties with `keys`. - "env:": For each property key , set an environment variable . - - "env-named:": is a `resultNameSeparator`-separated list of variable names of the same length as (the `keySeparator`-separated list) `keys`. The value for `keys[i]` is set as environment variable `[i]`. In this mode, `keys` is required. + - "env-named:": is a `resultNameSeparator`-separated list of variable names of the same length as (the `keySeparator`-separated list) `keys`. The value for `keys[i]` is set as environment variable `[i]`. In this mode, `keys` is required. The environment variable for a missing property is not set. - "env-named:": Special case: A single name is supported even with multiple `keys`. All found values for the keys are set as the same environment variable; the last found key wins. In this mode, `keys` is required. - - "json": Set an action output "json" to all properties as a JSON object, formatted as a single-line string. - - "json-file:": Write a file with name with contents: all properties as a JSON object (formatting not specified). + - "json": Set an action output "json" to all (selected, if keys is set) properties as a JSON object, formatted as a single-line string. Selected keys for missing properties are included with value null. + - "json-file:": Write a file with name with contents: all (selected, if keys is set) properties as a JSON object (formatting not specified). Selected keys for missing properties are included with value null. required: false default: 'output' From 381ae3c6c7aa01ebaac0a1b0bf40e28aec7b813b Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Wed, 20 May 2026 15:05:30 +0200 Subject: [PATCH 47/69] refactor test: move shell setup into separate file --- .github/workflows/test.yml | 221 +++++-------------------------------- test/test-setup.sh | 171 ++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 196 deletions(-) create mode 100644 test/test-setup.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 580a1fa..be75c65 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,179 +12,8 @@ env: SELECTED_NAME: 'my-prop' SELECTED_NAMES: 'java-version jvm_args unicode colon' + SHELL_SETUP: test/test-setup.sh NOT_FOUND_STATUS: 54 - SHELL_SETUP: |- - debugPrintf() { - printf "$@" | sed 's/^/::debug::/g' >&2 - } - - # Reads stdin and replaces one linefeed at the end of the input with "¶". Exits with non-zero status if that LF is - # missing. - # Piping a command to this function works around the removal of trailing LFs in command substitution - # [https://stackoverflow.com/a/15184414] and preserves meaningful LFs. - # For example, when the command is `jq --raw-output …` and the result is a string with ends with LF, that LF is - # preserved, and the LF added by jq (like line-based commands generally do) is replaced. - # This enables precise comparisons. - replaceLastLF() { - perl -e '$/ = undef; $_ = <>; ($r = $_) =~ s{\n$}{}; die "missing trailing LF\n" if $r eq $_; printf "%s¶", $r' - } - - assertVariableUnset() { - local varName=$1; shift - if [ "${!varName+defined}" = defined ]; then - printf 'assertion error: %s (== "%s") is set\n' "$varName" "${!varName}" >&2 - return 1 - else - printf 'ok: %s is not set\n' "$varName" >&2 - fi - } - # test this function: - ( - set +e - unset x; assertVariableUnset x >&/dev/null; statusUnset=$? - x=''; assertVariableUnset x >&/dev/null; statusEmpty=$? - x=foo; assertVariableUnset x >&/dev/null; statusNonEmpty1=$? - x=defined; assertVariableUnset x >&/dev/null; statusNonEmpty2=$? - set -e - [ $statusUnset -eq 0 ] - [ $statusEmpty -ne 0 ] - [ $statusNonEmpty1 -ne 0 ] - [ $statusNonEmpty2 -ne 0 ] - ) - - assertEquals() { - debugPrintf 'assertEquals("%s", "%s")\n' "$1" "$2" - if [ "$1" = "$2" ]; then - printf 'ok: "%s" == "%s"\n' "$1" "$2" >&2 - else - printf 'assertion error: "%s" != "%s"\n' "$1" "$2" >&2 - return 1 - fi - } - - # The last argument is the expected result. All other arguments are treated as a command (with arguments) - # to be evaluated which outputs the actual result. Both results are then asserted to be equal. - # - # Also, unlike directly using the form - # assertEquals "$(eval "$RESULT_GETTER 'key with spaces')" 'expected value' - # this function does not swallow a non-zero exit status in $RESULT_GETTER (but returns with that error - # status and skips the assertion). - # - # Of the arguments which form the command, only the first is evaled, the rest is treated literally. - # That odd behavior is useful for how the steps for similar test cases are written: They require - # different ways to get a result; for example, one needs `getJsonValue "$RESULT"` and another one - # `getJsonValue "$(<"$RESULT_FILE")`; this difference is moved to environment variables in order to keep - # the script identical, so it can be reused with a YAML anchor. - # Example usage: evalAndAssertEquals "$RESULT_GETTER" 'key with spaces' 'expected value' - # with RESULT_GETTER set to 'getJsonValue "$RESULT"'. - evalAndAssertEquals() { - local getterFunction=$1; shift - local args=("$@") - local expectedValue="${args[@]:${#args[@]}-1}" - args=("${args[@]:0:${#args[@]}-1}") - - local quotedArgs value - quotedArgs=$(printf ' %q' "${args[@]}") - - # Call $getterFunction once to check its status only before the call to capture its output. - # Otherwise, we'd get a misleading error from replaceLastLF if $getterFunction prints nothing and exits - # with $NOT_FOUND_STATUS. - eval "${getterFunction}${quotedArgs}" >/dev/null - eval "value=\$(set -o pipefail; ${getterFunction}${quotedArgs} | replaceLastLF)" - - expectedValue="$(printf '%s\n' "$expectedValue" | replaceLastLF)" - assertEquals "$value" "$expectedValue" - } - - # Queries a value by calling command $1 with the remaining args and expects it to exit with status $NOT_FOUND_STATUS. - # The command is handled like in →evalAndAssertEquals (except that there is no expected value as last argument which - # is not passed to the command). - evalAndAssertUndefined() { - local getterFunction=$1; shift - local quotedArgs value st - quotedArgs=$(printf ' %q' "$@") - if eval "value=\$(${getterFunction}${quotedArgs})"; then - printf 'assertion error: %s == "%s", expected undefined\n' "$*" "$value" >&2 - return 1 - elif [ ${st-$?} -eq "$NOT_FOUND_STATUS" ]; then - printf 'ok: %s is undefined\n' "$*" >&2 - else - # no error message, assuming failed $getterFunction has already written one - return $st - fi - } - - # Extracts the value for key $2 from JSON $1. - getJsonValue() { - local json=$1; shift - local key=$1; shift - debugPrintf 'getJsonValue("%s", "%s")\n' "$json" "$key" - jq --arg k "$key" --raw-output ' - .[$k] as $v - | if $v != null then - $v - elif (keys | index($k) != null) then #contains key $k (with a null or false value) - "" - else - (("key " + $k + " not found\n") | halt_error(env.NOT_FOUND_STATUS | tonumber)) - end' <<<"$json" - } - - assertJsonDoesNotContainKey() { - assertJsonContainsKeyEquals "$@" false - } - - assertJsonContainsKey() { - assertJsonContainsKeyEquals "$@" true - } - - assertJsonContainsKeyEquals() { - local json=$1; shift - local key=$1; shift - local expected=$1; shift - debugPrintf 'assertJsonContainsKeyEquals("%s", "%s", "%s")\n' "$json" "$key" "$expected" - local contains - contains=$(jq --arg k "$key" --raw-output 'keys | index($k) != null' <<<"$json") - assertEquals "$contains" "$expected" - } - - # Encodes a key $1 in the output-style encoding (property key to output name). - encodeKey() { - local key=$1; shift - perl -pe 's=((?!_)[[:punct:]])= sprintf("-%04X", ord($1)) =ge; s=^=_=;' <<<"$key" - } - - # Extracts the value for key from JSON $2. - encodeKeyAndGetJsonValue() { - local json=$1; shift - local key=$1; shift - local encodedKey - encodedKey=$(encodeKey "$key") - getJsonValue "$json" "$encodedKey" "$@" - } - - # Prints the value of the environment variable set for key $1 (when not using a prefix). - getEnv() { - local key=$1; shift - debugPrintf 'getEnv("%s")\n' "$key" - local st - if printenv -- "$key"; then - : # OK - elif [ ${st-$?} -eq 1 ]; then - printf 'environment variable %s not set\n' "$key" >&2 - return $NOT_FOUND_STATUS - else - # no error message, assuming printenv has already written one - return $st - fi - } - - # Prints the value of the environment variable set for key $2 when using a prefix $1. - getEnvWithPrefix() { - local prefix=$1; shift - local key=$1; shift - getEnv "${prefix}${key}" "$@" - } jobs: ######################################################################### @@ -244,7 +73,7 @@ jobs: STATUS: ${{steps.input-error_resultType.outcome}} ERROR: ${{steps.input-error_resultType.outputs.error}} run: |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" assertEquals "$STATUS" failure assertEquals "$ERROR" 'invalid resultType: rt1' @@ -262,7 +91,7 @@ jobs: STATUS: ${{steps.input-error_resultType-output-without-names.outcome}} ERROR: ${{steps.input-error_resultType-output-without-names.outputs.error}} run: |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" assertEquals "$STATUS" failure assertEquals "$ERROR" 'invalid resultType output-named: (missing argument)' @@ -280,7 +109,7 @@ jobs: STATUS: ${{steps.input-error_resultType-env-without-names.outcome}} ERROR: ${{steps.input-error_resultType-env-without-names.outputs.error}} run: |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" assertEquals "$STATUS" failure assertEquals "$ERROR" 'invalid resultType env-named: (missing argument)' @@ -297,7 +126,7 @@ jobs: STATUS: ${{steps.input-error_resultType-output-without-keys.outcome}} ERROR: ${{steps.input-error_resultType-output-without-keys.outputs.error}} run: |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" assertEquals "$STATUS" failure assertEquals "$ERROR" 'invalid use of resultType output-named (missing keys)' @@ -314,7 +143,7 @@ jobs: STATUS: ${{steps.input-error_resultType-env-without-keys.outcome}} ERROR: ${{steps.input-error_resultType-env-without-keys.outputs.error}} run: |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" assertEquals "$STATUS" failure assertEquals "$ERROR" 'invalid use of resultType env-named (missing keys)' @@ -334,7 +163,7 @@ jobs: env: RESULT: ${{steps.default_single.outputs._sourceJavaVersion}} run: |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" assertEquals "$RESULT" '21' test_output: @@ -360,7 +189,7 @@ jobs: RESULT_DASHK: ${{steps.output_multi_simple.outputs._dash-002Din-002DKey}} RESULT_UNI: ${{steps.output_multi_simple.outputs._unicode_escapes}} run: |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" assertEquals "$RESULT_SJV" '21' assertEquals "$RESULT_JVMARGS" '-ea -showversion' assertEquals "$RESULT_COLONK" 'c' @@ -379,7 +208,7 @@ jobs: RESULT_JSON: ${{toJSON(steps.output_all.outputs)}} RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' run: &validateAllImpl |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" evalAndAssertEquals "$RESULT_GETTER" sourceJavaVersion '21' evalAndAssertEquals "$RESULT_GETTER" targetJavaVersion '17' evalAndAssertEquals "$RESULT_GETTER" org.gradle.jvmargs '-ea -showversion' @@ -413,7 +242,7 @@ jobs: env: RESULT_VALUE: ${{steps.output_all.outputs.value || 'U_N_S_E_T'}} run: |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" assertEquals "$RESULT_VALUE" U_N_S_E_T # ===================================================================== @@ -430,7 +259,7 @@ jobs: RESULT_JSON: ${{toJSON(steps.output_multi.outputs)}} RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' run: &validateMultiImpl |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" evalAndAssertEquals "$RESULT_GETTER" sourceJavaVersion '21' evalAndAssertEquals "$RESULT_GETTER" org.gradle.jvmargs '-ea -showversion' evalAndAssertEquals "$RESULT_GETTER" colon:in:Key 'c' @@ -466,7 +295,7 @@ jobs: env: RESULT_VALUE: ${{steps.output_multi.outputs.value}} run: |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" assertEquals "$RESULT_VALUE" 'c' # from last key "colon:in:Key" # ===================================================================== @@ -516,7 +345,7 @@ jobs: env: RESULT_VALUE: ${{steps.output_multi2.outputs.value}} run: |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" assertEquals "$RESULT_VALUE" 'a«,»c' # from last key "unicode_escapes" # ===================================================================== @@ -533,7 +362,7 @@ jobs: RESULT_JSON: ${{toJSON(steps.output_single.outputs)}} RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' run: &validateSingleImpl |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" evalAndAssertEquals "$RESULT_GETTER" "$SELECTED_KEY" '21' evalAndAssertUndefined "$RESULT_GETTER" targetJavaVersion @@ -579,7 +408,7 @@ jobs: RESULT_VALUE: ${{steps.output_single_missing.outputs.value}} RESULT_JSON: ${{toJSON(steps.output_single_missing.outputs)}} run: |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" # Like this, the expected empty value is not distinguishable from unset: assertEquals "$RESULT" '' assertEquals "$RESULT_VALUE" '' @@ -602,7 +431,7 @@ jobs: RESULT_VALUE: ${{steps.output_multi_missing.outputs.value}} RESULT_JSON: ${{toJSON(steps.output_multi_missing.outputs)}} run: |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" # Like this, the expected empty value is not distinguishable from unset: assertEquals "$RESULT1" '' assertEquals "$RESULT2" '' @@ -632,7 +461,7 @@ jobs: # Output names specified as input are not subject to encoding (prefix "_", replace punctuation except "_"): RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' run: &validateMultiNamedImpl |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" evalAndAssertEquals "$RESULT_GETTER" java-version '21' evalAndAssertEquals "$RESULT_GETTER" jvm_args '-ea -showversion' evalAndAssertEquals "$RESULT_GETTER" colon 'c' @@ -652,7 +481,7 @@ jobs: env: RESULT_VALUE: ${{steps.output-named_multi.outputs.value || 'U_N_S_E_T'}} run: |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" assertEquals "$RESULT_VALUE" U_N_S_E_T # ===================================================================== @@ -700,7 +529,7 @@ jobs: # Output names specified as input are not subject to encoding (prefix "_", replace punctuation except "_"): RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' run: &validateMultiNamed1Impl |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" evalAndAssertEquals "$RESULT_GETTER" "$SELECTED_NAME" 'c' # The outputs with the default names are not set: @@ -726,7 +555,7 @@ jobs: RESULT: ${{steps.output-named_single_missing.outputs.my-prop}} RESULT_JSON: ${{toJSON(steps.output-named_single_missing.outputs)}} run: |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" # Like this, the expected empty value is not distinguishable from unset: assertEquals "$RESULT" '' # Assert set: @@ -745,7 +574,7 @@ jobs: RESULT: ${{steps.output-named_multi_missing.outputs.nonExistingKey1}} RESULT_JSON: ${{toJSON(steps.output-named_multi_missing.outputs)}} run: |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" # Like this, the expected empty value is not distinguishable from unset: assertEquals "$RESULT" '' # Assert set: @@ -795,7 +624,7 @@ jobs: env: RESULT_JSON: ${{steps.json_multi_missing.outputs.json}} run: |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" resultGetter='getJsonValue "$RESULT_JSON"' evalAndAssertEquals "$resultGetter" sourceJavaVersion '21' evalAndAssertEquals "$resultGetter" org.gradle.jvmargs '-ea -showversion' @@ -889,7 +718,7 @@ jobs: env: JSON_OUTPUT_FILE: ${{steps.json-file_multi_missing_arrange.outputs.file}} run: |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" resultJson=$(<"$JSON_OUTPUT_FILE") resultGetter='getJsonValue "$resultJson"' evalAndAssertEquals "$resultGetter" sourceJavaVersion '21' @@ -949,7 +778,7 @@ jobs: resultType: env - name: '[assert] query multiple missing properties as environment variables' run: |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" assertVariableUnset "$SELECTED_NON_EX_KEY" assertVariableUnset "$SELECTED_NON_EX_KEY2" assertEquals "${!SELECTED_KEY}" '21' @@ -1052,7 +881,7 @@ jobs: resultType: 'env-named:NE1 E1 NE2' - name: '[assert] query multiple missing properties as environment variables' run: |- - eval "$SHELL_SETUP" + . "$SHELL_SETUP" assertVariableUnset NE1 assertVariableUnset NE2 assertEquals "$E1" '21' diff --git a/test/test-setup.sh b/test/test-setup.sh new file mode 100644 index 0000000..1b45b11 --- /dev/null +++ b/test/test-setup.sh @@ -0,0 +1,171 @@ +debugPrintf() { + printf "$@" | sed 's/^/::debug::/g' >&2 +} + +# Reads stdin and replaces one linefeed at the end of the input with "¶". Exits with non-zero status if that LF is +# missing. +# Piping a command to this function works around the removal of trailing LFs in command substitution +# [https://stackoverflow.com/a/15184414] and preserves meaningful LFs. +# For example, when the command is `jq --raw-output …` and the result is a string with ends with LF, that LF is +# preserved, and the LF added by jq (like line-based commands generally do) is replaced. +# This enables precise comparisons. +replaceLastLF() { + perl -e '$/ = undef; $_ = <>; ($r = $_) =~ s{\n$}{}; die "missing trailing LF\n" if $r eq $_; printf "%s¶", $r' +} + +assertVariableUnset() { + local varName=$1; shift + if [ "${!varName+defined}" = defined ]; then + printf 'assertion error: %s (== "%s") is set\n' "$varName" "${!varName}" >&2 + return 1 + else + printf 'ok: %s is not set\n' "$varName" >&2 + fi +} +# test this function: +( + set +e + unset x; assertVariableUnset x >&/dev/null; statusUnset=$? + x=''; assertVariableUnset x >&/dev/null; statusEmpty=$? + x=foo; assertVariableUnset x >&/dev/null; statusNonEmpty1=$? + x=defined; assertVariableUnset x >&/dev/null; statusNonEmpty2=$? + set -e + [ $statusUnset -eq 0 ] + [ $statusEmpty -ne 0 ] + [ $statusNonEmpty1 -ne 0 ] + [ $statusNonEmpty2 -ne 0 ] +) + +assertEquals() { + debugPrintf 'assertEquals("%s", "%s")\n' "$1" "$2" + if [ "$1" = "$2" ]; then + printf 'ok: "%s" == "%s"\n' "$1" "$2" >&2 + else + printf 'assertion error: "%s" != "%s"\n' "$1" "$2" >&2 + return 1 + fi +} + +# The last argument is the expected result. All other arguments are treated as a command (with arguments) +# to be evaluated which outputs the actual result. Both results are then asserted to be equal. +# +# Also, unlike directly using the form +# assertEquals "$(eval "$RESULT_GETTER 'key with spaces')" 'expected value' +# this function does not swallow a non-zero exit status in $RESULT_GETTER (but returns with that error +# status and skips the assertion). +# +# Of the arguments which form the command, only the first is evaled, the rest is treated literally. +# That odd behavior is useful for how the steps for similar test cases are written: They require +# different ways to get a result; for example, one needs `getJsonValue "$RESULT"` and another one +# `getJsonValue "$(<"$RESULT_FILE")`; this difference is moved to environment variables in order to keep +# the script identical, so it can be reused with a YAML anchor. +# Example usage: evalAndAssertEquals "$RESULT_GETTER" 'key with spaces' 'expected value' +# with RESULT_GETTER set to 'getJsonValue "$RESULT"'. +evalAndAssertEquals() { + local getterFunction=$1; shift + local args=("$@") + local expectedValue="${args[@]:${#args[@]}-1}" + args=("${args[@]:0:${#args[@]}-1}") + + local quotedArgs value + quotedArgs=$(printf ' %q' "${args[@]}") + + # Call $getterFunction once to check its status only before the call to capture its output. + # Otherwise, we'd get a misleading error from replaceLastLF if $getterFunction prints nothing and exits + # with $NOT_FOUND_STATUS. + eval "${getterFunction}${quotedArgs}" >/dev/null + eval "value=\$(set -o pipefail; ${getterFunction}${quotedArgs} | replaceLastLF)" + + expectedValue="$(printf '%s\n' "$expectedValue" | replaceLastLF)" + assertEquals "$value" "$expectedValue" +} + +# Queries a value by calling command $1 with the remaining args and expects it to exit with status $NOT_FOUND_STATUS. +# The command is handled like in →evalAndAssertEquals (except that there is no expected value as last argument which +# is not passed to the command). +evalAndAssertUndefined() { + local getterFunction=$1; shift + local quotedArgs value st + quotedArgs=$(printf ' %q' "$@") + if eval "value=\$(${getterFunction}${quotedArgs})"; then + printf 'assertion error: %s == "%s", expected undefined\n' "$*" "$value" >&2 + return 1 + elif [ ${st-$?} -eq "$NOT_FOUND_STATUS" ]; then + printf 'ok: %s is undefined\n' "$*" >&2 + else + # no error message, assuming failed $getterFunction has already written one + return $st + fi +} + +# Extracts the value for key $2 from JSON $1. +getJsonValue() { + local json=$1; shift + local key=$1; shift + debugPrintf 'getJsonValue("%s", "%s")\n' "$json" "$key" + jq --arg k "$key" --raw-output ' + .[$k] as $v + | if $v != null then + $v + elif (keys | index($k) != null) then #contains key $k (with a null or false value) + "" + else + (("key " + $k + " not found\n") | halt_error(env.NOT_FOUND_STATUS | tonumber)) + end' <<<"$json" +} + +assertJsonDoesNotContainKey() { + assertJsonContainsKeyEquals "$@" false +} + +assertJsonContainsKey() { + assertJsonContainsKeyEquals "$@" true +} + +assertJsonContainsKeyEquals() { + local json=$1; shift + local key=$1; shift + local expected=$1; shift + debugPrintf 'assertJsonContainsKeyEquals("%s", "%s", "%s")\n' "$json" "$key" "$expected" + local contains + contains=$(jq --arg k "$key" --raw-output 'keys | index($k) != null' <<<"$json") + assertEquals "$contains" "$expected" +} + +# Encodes a key $1 in the output-style encoding (property key to output name). +encodeKey() { + local key=$1; shift + perl -pe 's=((?!_)[[:punct:]])= sprintf("-%04X", ord($1)) =ge; s=^=_=;' <<<"$key" +} + +# Extracts the value for key from JSON $2. +encodeKeyAndGetJsonValue() { + local json=$1; shift + local key=$1; shift + local encodedKey + encodedKey=$(encodeKey "$key") + getJsonValue "$json" "$encodedKey" "$@" +} + +# Prints the value of the environment variable set for key $1 (when not using a prefix). +getEnv() { + local key=$1; shift + debugPrintf 'getEnv("%s")\n' "$key" + local st + if printenv -- "$key"; then + : # OK + elif [ ${st-$?} -eq 1 ]; then + printf 'environment variable %s not set\n' "$key" >&2 + return $NOT_FOUND_STATUS + else + # no error message, assuming printenv has already written one + return $st + fi +} + +# Prints the value of the environment variable set for key $2 when using a prefix $1. +getEnvWithPrefix() { + local prefix=$1; shift + local key=$1; shift + getEnv "${prefix}${key}" "$@" +} From 45deb2bb8ed3c5f1f370fc480d030a1f79b77d08 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Wed, 20 May 2026 15:25:59 +0200 Subject: [PATCH 48/69] fix test: add missing jobs to meta job "test"; add step to check completeness --- .github/workflows/test.yml | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index be75c65..f3464de 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,15 +29,30 @@ jobs: - test_json-file - test_env_all - test_env_multi + - test_env_multi_missing + - test_env_single - test_env-named_multi - test_env-named_multi-to-single - - test_env_single + - test_env-named_multi_missing - test_env-with-prefix_all - test_env-with-prefix_multi - test_env-with-prefix_single if: always() runs-on: ubuntu-latest steps: + - &checkoutStep + name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + - name: "check that this job's `needs` is set to exactly all other jobs" + run: |- + diff --minimal \ + <(yq -r '.jobs | keys() | .[]' .github/workflows/test.yml | sort | grep --line-regexp --fixed-strings --invert-match test) \ + <(yq -r '.jobs.test.needs[]' < .github/workflows/test.yml | sort) + - name: check overall status env: JOBS_TO_CHECK_JSON: ${{toJSON(needs)}} @@ -52,13 +67,7 @@ jobs: test_invalid-usage: runs-on: ubuntu-latest steps: - - &checkoutStep - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ github.head_ref }} - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} + - *checkoutStep # ===================================================================== - name: '[act] (should fail): invalid input: resultType' From 2dafa04f348c11bc9a3e416630d93d7feaf06a84 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Wed, 20 May 2026 15:29:55 +0200 Subject: [PATCH 49/69] fix test: add missing jobs to meta job "test"; add step to check completeness --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f3464de..2d0b6a3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,7 +49,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: "check that this job's `needs` is set to exactly all other jobs" run: |- - diff --minimal \ + diff --minimal --report-identical-files \ <(yq -r '.jobs | keys() | .[]' .github/workflows/test.yml | sort | grep --line-regexp --fixed-strings --invert-match test) \ <(yq -r '.jobs.test.needs[]' < .github/workflows/test.yml | sort) From df35bb2ed8d42e4e42cc05f0a7550de3f63afa51 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Wed, 20 May 2026 16:10:11 +0200 Subject: [PATCH 50/69] output name encoding also for whitespace characters (as was already documented) --- .github/workflows/test.yml | 9 ++++----- Action.java | 2 +- test/test-setup.sh | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2d0b6a3..c77af2e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -188,22 +188,21 @@ jobs: uses: ./ with: file: test/resources/test.properties - keys: '${{env.SELECTED_KEYS}} dash-in-Key' + keySeparator: ', ' + keys: 'sourceJavaVersion, org.gradle.jvmargs, space in Key, dash-in-Key' resultType: output - name: '[assert] query multiple properties as action outputs (simple)' env: RESULT_SJV: ${{steps.output_multi_simple.outputs._sourceJavaVersion}} RESULT_JVMARGS: ${{steps.output_multi_simple.outputs._org-002Egradle-002Ejvmargs}} - RESULT_COLONK: ${{steps.output_multi_simple.outputs._colon-003Ain-003AKey}} + RESULT_SPACEK: ${{steps.output_multi_simple.outputs._space-0020in-0020Key}} RESULT_DASHK: ${{steps.output_multi_simple.outputs._dash-002Din-002DKey}} - RESULT_UNI: ${{steps.output_multi_simple.outputs._unicode_escapes}} run: |- . "$SHELL_SETUP" assertEquals "$RESULT_SJV" '21' assertEquals "$RESULT_JVMARGS" '-ea -showversion' - assertEquals "$RESULT_COLONK" 'c' + assertEquals "$RESULT_SPACEK" 'sp' assertEquals "$RESULT_DASHK" 'd' - assertEquals "$RESULT_UNI" 'a«,»c' # ===================================================================== - name: '[act] query all properties as action outputs' diff --git a/Action.java b/Action.java index 4759a0e..2e3306f 100644 --- a/Action.java +++ b/Action.java @@ -191,7 +191,7 @@ public void write(Map> props, Config config) throws IOE private static String encodeKey(String key) { StringBuilder result = new StringBuilder(key.length() + 4); - Matcher matcher = Pattern.compile("([\\p{Punct}&&[^_]])").matcher(key); + Matcher matcher = Pattern.compile("([\\s\\p{Punct}&&[^_]])").matcher(key); while (matcher.find()) { matcher.appendReplacement(result, String.format("-%04X", (int) matcher.group(1).charAt(0))); } diff --git a/test/test-setup.sh b/test/test-setup.sh index 1b45b11..6a2f9bd 100644 --- a/test/test-setup.sh +++ b/test/test-setup.sh @@ -135,7 +135,7 @@ assertJsonContainsKeyEquals() { # Encodes a key $1 in the output-style encoding (property key to output name). encodeKey() { local key=$1; shift - perl -pe 's=((?!_)[[:punct:]])= sprintf("-%04X", ord($1)) =ge; s=^=_=;' <<<"$key" + perl -ple 's=((?!_)[[:space:][:punct:]])= sprintf("-%04X", ord($1)) =ge; s=^=_=;' <<<"$key" } # Extracts the value for key from JSON $2. From 43223048cdd0e9b93c578354668eb2983f451149 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Wed, 20 May 2026 16:35:04 +0200 Subject: [PATCH 51/69] optimization: compile Java at Docker build time --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4a43212..1f3f8d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,5 +8,5 @@ LABEL "maintainer"="Team MCBS Core " WORKDIR /action COPY *.java . - -ENTRYPOINT ["java", "Action.java"] +RUN ["javac", "Action.java"] +ENTRYPOINT ["java", "--class-path", "/action", "Action"] From 38154fbb5ca887c2df0f272275945001e062ab7a Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Wed, 20 May 2026 16:50:23 +0200 Subject: [PATCH 52/69] fix IllegalArgumentException when writing values with duplicate lines --- .github/workflows/test.yml | 3 +++ Action.java | 3 ++- test/resources/test.properties | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c77af2e..3267f13 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -245,6 +245,7 @@ jobs: evalAndAssertEquals "$RESULT_GETTER" dash_11 '-----------' evalAndAssertEquals "$RESULT_GETTER" dash_12 '------------' evalAndAssertEquals "$RESULT_GETTER" dash_3_4_1 $'---\n----\n-' + evalAndAssertEquals "$RESULT_GETTER" dash_3_4_1_4_2 $'---\n----\n-\n----\n--' evalAndAssertEquals "$RESULT_GETTER" dash_3_4_8_1 $'---\n----\n--------\n-' - name: '[assert] query all properties as action outputs (additional "value" output not set)' env: @@ -297,6 +298,7 @@ jobs: evalAndAssertUndefined "$RESULT_GETTER" dash_11 evalAndAssertUndefined "$RESULT_GETTER" dash_12 evalAndAssertUndefined "$RESULT_GETTER" dash_3_4_1 + evalAndAssertUndefined "$RESULT_GETTER" dash_3_4_1_4_2 evalAndAssertUndefined "$RESULT_GETTER" dash_3_4_8_1 - name: '[assert] query multiple properties as action outputs (additional "value" output)' @@ -400,6 +402,7 @@ jobs: evalAndAssertUndefined "$RESULT_GETTER" dash_11 evalAndAssertUndefined "$RESULT_GETTER" dash_12 evalAndAssertUndefined "$RESULT_GETTER" dash_3_4_1 + evalAndAssertUndefined "$RESULT_GETTER" dash_3_4_1_4_2 evalAndAssertUndefined "$RESULT_GETTER" dash_3_4_8_1 # ===================================================================== diff --git a/Action.java b/Action.java index 2e3306f..19b6571 100644 --- a/Action.java +++ b/Action.java @@ -9,6 +9,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -371,7 +372,7 @@ private void writeMultiLine(String key, String value) throws IOException { @SuppressWarnings("java:S1643") // Sonar rule: "Strings should not be concatenated using '+' in a loop". // False positive: We would need that StringBuilder's toString for each iteration for the contains check anyway. private static String computeSeparator(String value) { - Set valueLines = Set.of(value.split("(?s)\n")); + Set valueLines = new HashSet<>(List.of(value.split("(?s)\n"))); String separatorPart = "----"; @SuppressWarnings("java:S1643") String separator = separatorPart; diff --git a/test/resources/test.properties b/test/resources/test.properties index 3933a50..5e7d695 100644 --- a/test/resources/test.properties +++ b/test/resources/test.properties @@ -31,4 +31,5 @@ dash_10 = ---------- dash_11 = ----------- dash_12 = ------------ dash_3_4_1 = ---\n----\n- +dash_3_4_1_4_2 = ---\n----\n-\n----\n-- dash_3_4_8_1 = ---\n----\n--------\n- From 086d62254630a757fb09f6fb0ecb288e0ff7a74e Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Wed, 20 May 2026 17:05:14 +0200 Subject: [PATCH 53/69] resultType "output": forbid argument --- .github/workflows/test.yml | 18 ++++++++++++++++++ Action.java | 1 + 2 files changed, 19 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3267f13..520f8ad 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -86,6 +86,24 @@ jobs: assertEquals "$STATUS" failure assertEquals "$ERROR" 'invalid resultType: rt1' + # ===================================================================== + - name: '[act] (should fail): resultType "output" with argument' + id: input-error_resultType-output-with-arg + continue-on-error: true + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS}}' + resultType: 'output:x' + - name: '[assert] (should fail): resultType "output-named" with argument' + env: + STATUS: ${{steps.input-error_resultType-output-with-arg.outcome}} + ERROR: ${{steps.input-error_resultType-output-with-arg.outputs.error}} + run: |- + . "$SHELL_SETUP" + assertEquals "$STATUS" failure + assertEquals "$ERROR" 'invalid resultType output:x (non-empty argument)' + # ===================================================================== - name: '[act] (should fail): resultType "output-named" without names' id: input-error_resultType-output-without-names diff --git a/Action.java b/Action.java index 19b6571..dca7ec6 100644 --- a/Action.java +++ b/Action.java @@ -176,6 +176,7 @@ enum ResultWriter { OUTPUT(Ids.ResultWriterName.OUTPUT) { @Override public void write(Map> props, Config config) throws IOException { + config.requireNoArg(); String lastValue = null; try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { for (Map.Entry> entry : props.entrySet()) { From 4440b3b9d653911e55b3444bb8745721e25f674d Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Wed, 20 May 2026 17:06:49 +0200 Subject: [PATCH 54/69] fix test: evalAndAssertUndefined unexpected $getterFunction status (did not happen, but was noticed by GitHub Copilot review) --- test/test-setup.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test-setup.sh b/test/test-setup.sh index 6a2f9bd..7c99b59 100644 --- a/test/test-setup.sh +++ b/test/test-setup.sh @@ -90,7 +90,7 @@ evalAndAssertUndefined() { if eval "value=\$(${getterFunction}${quotedArgs})"; then printf 'assertion error: %s == "%s", expected undefined\n' "$*" "$value" >&2 return 1 - elif [ ${st-$?} -eq "$NOT_FOUND_STATUS" ]; then + elif [ "${st=$?}" -eq "$NOT_FOUND_STATUS" ]; then printf 'ok: %s is undefined\n' "$*" >&2 else # no error message, assuming failed $getterFunction has already written one From 37b99a14bc1947b4ac4bbcee835c71a120f08fb5 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Wed, 20 May 2026 17:12:54 +0200 Subject: [PATCH 55/69] fix docs --- README.md | 2 +- action.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 69281a8..b458e39 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Query a single property as action output: keys: org.gradle.jvmargs ``` ⇒ \ - `${{steps.readProp.outputs.org-002Egradle-002Ejvmargs}}` == `-ea -showversion`, \ + `${{steps.readProp.outputs._org-002Egradle-002Ejvmargs}}` == `-ea -showversion`, \ `${{steps.readProp.outputs.value}}` == `-ea -showversion`. Query multiple (alternative) properties as a single action output. In other words, query a property with a fallback to another property. diff --git a/action.yml b/action.yml index d771ef7..778c6c1 100644 --- a/action.yml +++ b/action.yml @@ -26,7 +26,7 @@ inputs: - "output": For each property key , set an output named "_", but with special characters encoded. (The leading underscore serves to avoid conflicts with future output names used by this action.) Encoding replaces all punctuation or whitespace characters except the underscore ("_") with "-" followed by four hex digits of its unicode code point. For example, for a property key "a.b-c_d", resultType "output" sets an output with name - "a-002Eb-002Dc_d". + "_a-002Eb-002Dc_d". Additionally, set output "value" to the last found value, unless `keys` is empty. I.e. last given key wins. If `keys` is given, but none is found, "value" is set to empty. (Without keys, this output is not set, because it would be arbitrary due to the "random" iteration order of the used Java class java.util.Properties.) - "output-named:": is a `resultNameSeparator`-separated list of output names of the same length as (the `keySeparator`-separated list) `keys`. The value for `keys[i]` is set as output `[i]`. In order not to hide any future builtin outputs of this action, it is recommended to prefix each name with an underscore ("_"). In this mode, `keys` is required. Unlike "output", names are taken as-is. (This is because no official output naming rules seem to exist yet; so maybe a future user will know better how to choose valid characters than we would implement now.) The output for a missing property is set to empty. If you need to distinguish between empty an undefined properties, resultType "json" or "json-file" is recommended. - "output-named:": Special case: A single name is supported even with multiple `keys`. All found values for the keys are set as the same output; the last found key wins. In this mode, `keys` is required. If none is found, the output is set to empty. From f692bcb994c3175677b69fd6d68f76513d5b826f Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Wed, 20 May 2026 17:51:11 +0200 Subject: [PATCH 56/69] test: minor cleanup - consistency: both yq invocations with redirection - move job and workflow file names into `env` to make the assumptions more visible --- .github/workflows/test.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 520f8ad..89c6b80 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,10 +48,13 @@ jobs: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - name: "check that this job's `needs` is set to exactly all other jobs" + env: + THIS_WORKFLOW_FILE: '.github/workflows/test.yml' + THIS_JOB_NAME: test run: |- diff --minimal --report-identical-files \ - <(yq -r '.jobs | keys() | .[]' .github/workflows/test.yml | sort | grep --line-regexp --fixed-strings --invert-match test) \ - <(yq -r '.jobs.test.needs[]' < .github/workflows/test.yml | sort) + <(yq -r '.jobs | keys() | .[]' <"$THIS_WORKFLOW_FILE" | sort | grep --line-regexp --fixed-strings --invert-match -- "$THIS_JOB_NAME") \ + <(yq -r '.jobs.test.needs[]' <"$THIS_WORKFLOW_FILE" | sort) - name: check overall status env: From 719fdc9a61a3e101e59c49bbdd085a4f0e728147 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Wed, 20 May 2026 18:06:32 +0200 Subject: [PATCH 57/69] pin Docker base image version --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1f3f8d6..c17bba3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM azul/zulu-openjdk:25-latest +FROM azul/zulu-openjdk:25.0.2-25.32@sha256:634efad18fb8fbf1dcc4c2d5a43c663435a77f078a912345f23f332e7abd27be LABEL "com.github.actions.name"="read Java properties" LABEL "com.github.actions.description"="read Java properties file and return one or more as property values as plain text or JSON" From f04686b5ae6d3e1fe2b090a4b07e6ea626c67aac Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Wed, 20 May 2026 20:18:49 +0200 Subject: [PATCH 58/69] add input onMissingFile --- .github/workflows/test.yml | 73 +++++++++++++++++++++++++++++ Action.java | 94 ++++++++++++++++++++++++++++++++++---- action.yml | 9 ++++ test/test-setup.sh | 1 + 4 files changed, 167 insertions(+), 10 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 89c6b80..98121fa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,6 +22,7 @@ jobs: test: needs: - test_invalid-usage + - test_missing-file - test_default - test_output - test_output-named @@ -177,6 +178,78 @@ jobs: assertEquals "$STATUS" failure assertEquals "$ERROR" 'invalid use of resultType env-named (missing keys)' + ######################################################################### + test_missing-file: + runs-on: ubuntu-latest + steps: + - *checkoutStep + + # ===================================================================== + - name: '[act] missing file with default onMissingFile "notice-message"' + id: file-error_onMissingFile-notice-message + uses: ./ + with: + file: test/resources/DOES_NOT_EXIST.properties + onMissingFile: notice-message + keys: ${{env.SELECTED_KEY}} + resultType: json + - name: '[assert] missing file with onMissingFile "notice-message"' + env: + RESULT_JSON: ${{steps.file-error_onMissingFile-notice-message.outputs.json}} + run: |- + . "$SHELL_SETUP" + assertEquals "$(getJsonValue "$RESULT_JSON" "$SELECTED_KEY")" '' + + # ===================================================================== + - name: '[act] missing file with default onMissingFile "warning-message"' + id: file-error_onMissingFile-warning-message + uses: ./ + with: + file: test/resources/DOES_NOT_EXIST.properties + onMissingFile: warning-message + keys: ${{env.SELECTED_KEY}} + resultType: json + - name: '[assert] missing file with onMissingFile "warning-message"' + env: + RESULT_JSON: ${{steps.file-error_onMissingFile-warning-message.outputs.json}} + run: |- + . "$SHELL_SETUP" + assertEquals "$(getJsonValue "$RESULT_JSON" "$SELECTED_KEY")" '' + + # ===================================================================== + - name: '[act] (should fail): missing file with default onMissingFile "error"' + id: file-error_onMissingFile-error + continue-on-error: true + uses: ./ + with: + file: test/resources/DOES_NOT_EXIST.properties + onMissingFile: error + keys: ${{env.SELECTED_KEY}} + resultType: json + - name: '[assert] (should fail): missing file with onMissingFile "error"' + env: + STATUS: ${{steps.file-error_onMissingFile-error.outcome}} + RESULT_JSON: ${{steps.file-error_onMissingFile-error.outputs.json}} + run: &validateOnMissingFileErrorImpl |- + . "$SHELL_SETUP" + assertEquals "$STATUS" failure + assertEquals "$RESULT_JSON" '' + + # ===================================================================== + - name: '[act] (should fail): missing file with default onMissingFile' + id: file-error_onMissingFile-default + continue-on-error: true + uses: ./ + with: + file: test/resources/DOES_NOT_EXIST.properties + keys: ${{env.SELECTED_KEY}} + resultType: json + - name: '[assert] (should fail): missing file with default onMissingFile' + env: + STATUS: ${{steps.file-error_onMissingFile-default.outcome}} + RESULT_JSON: ${{steps.file-error_onMissingFile-default.outputs.json}} + run: *validateOnMissingFileErrorImpl + ######################################################################### test_default: runs-on: ubuntu-latest diff --git a/Action.java b/Action.java index dca7ec6..e628d00 100644 --- a/Action.java +++ b/Action.java @@ -28,9 +28,9 @@ private Action() {} static void main(String[] args) throws Exception { try { Config config = Config.fromEnv(); - ResultWriter resultWriter = ResultWriter.of(config.resultType()); + ResultWriter resultWriter = ResultWriter.ofExternalName(config.resultType()); for (String file : args) { - Properties properties = Util.readProperties(file); + Properties properties = Util.readProperties(file, config.missingFileHandler()); // If selectedKeys is set, this Map will have exactly those keys and values may be empty (where selected key is // not found in properties). // Otherwise, this Map will have the same keys as the properties file, and "not found" is not possible. @@ -39,6 +39,8 @@ static void main(String[] args) throws Exception { .orElseGet(() -> Util.stringEntries(properties)); resultWriter.write(selectedProperties, config); } + } catch (ExitSilentlyException e) { + System.exit(e.status); } catch (IoRuntimeException e) { throw e.getCause(); } catch (OutputException e) { @@ -62,6 +64,7 @@ private Ids() {} */ enum ConfigVariable { FILE("file"), // + ON_MISSING_FILE("onMissingFile"), // KEYS("keys"), // RESULT_TYPE("resultType"), // KEY_SEPARATOR("keySeparator"), // @@ -80,6 +83,18 @@ public String toString() { } } + enum MissingFileHandlerName { + NOTICE_MESSAGE("notice-message"), // + WARNING_MESSAGE("warning-message"), // + ERROR("error"); + + public final String externalName; + + private MissingFileHandlerName(String externalName) { + this.externalName = externalName; + } + } + enum ResultWriterName { OUTPUT("output"), // OUTPUT_NAMED("output-named"), // @@ -130,8 +145,18 @@ public static OutputException forIllegalArgument(String message) { } -record Config(Optional> selectedKeys, String keySeparator, String resultTypeWithArg, String resultType, - String resultTypeArg, String resultNameSeparator, String outputPrefix) { +class ExitSilentlyException extends RuntimeException { + public final int status; + + public ExitSilentlyException(int status) { + super(); + this.status = status; + } +} + + +record Config(MissingFileHandler missingFileHandler, Optional> selectedKeys, String keySeparator, + String resultTypeWithArg, String resultType, String resultTypeArg, String resultNameSeparator, String outputPrefix) { public static Config fromEnv() { // In order to keep the defaults DRY (in action.yml), the environment variables are all mandatory. String keySeparator = Util.getRequiredEnv(Ids.ConfigVariable.KEY_SEPARATOR); @@ -150,7 +175,13 @@ public static Config fromEnv() { String resultType = matcher.group(1); String resultTypeArg = Optional.ofNullable(matcher.group(2)).orElse(""); String outputPrefix = Util.getRequiredEnv(Ids.ConfigVariable.OUTPUT_PREFIX); - return new Config(keys.map(List::of), keySeparator, resultTypeWithArg, resultType, resultTypeArg, resultNameSeparator, + return new Config( // + MissingFileHandler.ofExternalName(Util.getRequiredEnv(Ids.ConfigVariable.ON_MISSING_FILE)), // + keys.map(List::of), keySeparator, // + resultTypeWithArg, // + resultType, // + resultTypeArg, // + resultNameSeparator, // outputPrefix); } @@ -172,6 +203,43 @@ public void requireNoArg() { } +enum MissingFileHandler { + NOTICE_MESSAGE(Ids.MissingFileHandlerName.NOTICE_MESSAGE, "notice"), // + WARNING_MESSAGE(Ids.MissingFileHandlerName.WARNING_MESSAGE, "warning"), // + ERROR(Ids.MissingFileHandlerName.ERROR, "error") { + @Override + public void handleMissingFile(String message) { + super.handleMissingFile(message); + throw new ExitSilentlyException(2); + } + }; + + private final String externalName; + private final String output; + + private MissingFileHandler(Ids.MissingFileHandlerName externalName, String output) { + this.externalName = externalName.externalName; + this.output = output; + } + + public static MissingFileHandler ofExternalName(String externalName) { + for (MissingFileHandler o : MissingFileHandler.values()) { + if (o.externalName.equals(externalName)) { + return o; + } + } + throw OutputException.forIllegalArgument("invalid " + Ids.ConfigVariable.ON_MISSING_FILE + ": " + externalName); + } + + @SuppressWarnings("java:S3457") // Sonar rule suggests %n instead of \n, but that would not strictly be covered by the docs + // [https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#setting-a-notice-message], so the \r + // might be considered part of the message. + public void handleMissingFile(String message) { + System.out.format("::%s::%s\n", output, message); + } +} + + enum ResultWriter { OUTPUT(Ids.ResultWriterName.OUTPUT) { @Override @@ -258,7 +326,7 @@ private ResultWriter(Ids.ResultWriterName externalName) { this.externalName = externalName.externalName; } - public static ResultWriter of(String externalName) { + public static ResultWriter ofExternalName(String externalName) { for (ResultWriter rw : ResultWriter.values()) { if (rw.externalName.equals(externalName)) { return rw; @@ -375,7 +443,6 @@ private void writeMultiLine(String key, String value) throws IOException { private static String computeSeparator(String value) { Set valueLines = new HashSet<>(List.of(value.split("(?s)\n"))); String separatorPart = "----"; - @SuppressWarnings("java:S1643") String separator = separatorPart; while (valueLines.contains(separator)) { separator = separator + separatorPart; @@ -413,12 +480,19 @@ public static Writer openFile(String name, OpenOption... options) throws IOExcep return new BufferedWriter(new OutputStreamWriter(Files.newOutputStream(Paths.get(name), options), StandardCharsets.UTF_8)); } - public static Properties readProperties(String file) throws IOException { + public static Properties readProperties(String file, MissingFileHandler missingFileHandler) { Properties allProps = new Properties(); - try (InputStream in = Files.newInputStream(Paths.get(file))) { + Path path = Paths.get(file); + try (InputStream in = Files.newInputStream(path)) { allProps.load(in); + return allProps; + } catch (IOException e) { + // Nice message (instead of catching FileNotFoundExeption which is also thrown on other problems and just contains the + // filename, not "does not exist" or similar): + String message = Files.exists(path) ? ("error opening file: " + e.getMessage()) : ("file " + path + " does not exist"); + missingFileHandler.handleMissingFile(message); + return new Properties(); } - return allProps; } /** diff --git a/action.yml b/action.yml index 778c6c1..89f1baa 100644 --- a/action.yml +++ b/action.yml @@ -7,6 +7,14 @@ inputs: file: description: the properties file name required: true + onMissingFile: + required: false + default: error + description: |- + What to do if `file` does not exist or is not readable. One of: + - "notice-message": write a notice message and continue with an empty properties map, + - "warning-message": write a warning message and continue with an empty properties map, + - "error": write an error message and exit with non-zero status. keys: description: |- Selects the property keys (names). Format: `keySeparator`-separated list. @@ -54,6 +62,7 @@ runs: args: - ${{inputs.file}} env: + ON_MISSING_FILE: ${{inputs.onMissingFile}} KEYS: ${{inputs.keys}} KEY_SEPARATOR: ${{inputs.keySeparator}} RESULT_NAME_SEPARATOR: ${{inputs.resultNameSeparator}} diff --git a/test/test-setup.sh b/test/test-setup.sh index 7c99b59..efc727d 100644 --- a/test/test-setup.sh +++ b/test/test-setup.sh @@ -99,6 +99,7 @@ evalAndAssertUndefined() { } # Extracts the value for key $2 from JSON $1. +# Successfully prints nothing if the value is null or empty, but fails if the key is not present. getJsonValue() { local json=$1; shift local key=$1; shift From 213f635bd3b44199e0cd9d02f3645547d92e810d Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Thu, 21 May 2026 11:59:30 +0200 Subject: [PATCH 59/69] fix Dockerfile: avoid out-of-date *.class files --- Dockerfile | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index c17bba3..311cca1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,5 +8,12 @@ LABEL "maintainer"="Team MCBS Core " WORKDIR /action COPY *.java . -RUN ["javac", "Action.java"] -ENTRYPOINT ["java", "--class-path", "/action", "Action"] +RUN ["chmod", "a-wx", "Action.java"] + +# When re-building like this and using `java`, we may get an image with up-to-date Action.java, +# but outdated *.class. Don't know how GitHub actions handles this exactly, but this would break +# local Docker builds at least. +# # RUN ["javac", "Action.java"] +# # ENTRYPOINT ["java", "--class-path", "/action", "Action"] +# So instead: +ENTRYPOINT ["java", "Action.java"] From 46f3f78923d9e50671020b52186f0709b7410bd7 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Thu, 21 May 2026 12:21:24 +0200 Subject: [PATCH 60/69] handle certain file errors as error, ignoring onMissingFile input --- .github/workflows/test.yml | 100 ++++++++++++----- Action.java | 147 ++++++++++++------------- action.yml | 4 + test/resources/test-invalid.properties | 1 + 4 files changed, 145 insertions(+), 107 deletions(-) create mode 100644 test/resources/test-invalid.properties diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 98121fa..87e1360 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,6 +23,7 @@ jobs: needs: - test_invalid-usage - test_missing-file + - test_invalid-file - test_default - test_output - test_output-named @@ -74,14 +75,14 @@ jobs: - *checkoutStep # ===================================================================== - - name: '[act] (should fail): invalid input: resultType' + - name: '[act] (should fail) invalid input: resultType' id: input-error_resultType continue-on-error: true uses: ./ with: file: test/resources/test.properties resultType: rt1 - - name: '[assert] (should fail): invalid input: resultType' + - name: '[assert] (should fail) invalid input: resultType' env: STATUS: ${{steps.input-error_resultType.outcome}} ERROR: ${{steps.input-error_resultType.outputs.error}} @@ -91,7 +92,7 @@ jobs: assertEquals "$ERROR" 'invalid resultType: rt1' # ===================================================================== - - name: '[act] (should fail): resultType "output" with argument' + - name: '[act] (should fail) resultType "output" with argument' id: input-error_resultType-output-with-arg continue-on-error: true uses: ./ @@ -99,7 +100,7 @@ jobs: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: 'output:x' - - name: '[assert] (should fail): resultType "output-named" with argument' + - name: '[assert] (should fail) resultType "output-named" with argument' env: STATUS: ${{steps.input-error_resultType-output-with-arg.outcome}} ERROR: ${{steps.input-error_resultType-output-with-arg.outputs.error}} @@ -109,7 +110,7 @@ jobs: assertEquals "$ERROR" 'invalid resultType output:x (non-empty argument)' # ===================================================================== - - name: '[act] (should fail): resultType "output-named" without names' + - name: '[act] (should fail) resultType "output-named" without names' id: input-error_resultType-output-without-names continue-on-error: true uses: ./ @@ -117,7 +118,7 @@ jobs: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: 'output-named:' - - name: '[assert] (should fail): resultType "output-named" without names' + - name: '[assert] (should fail) resultType "output-named" without names' env: STATUS: ${{steps.input-error_resultType-output-without-names.outcome}} ERROR: ${{steps.input-error_resultType-output-without-names.outputs.error}} @@ -127,7 +128,7 @@ jobs: assertEquals "$ERROR" 'invalid resultType output-named: (missing argument)' # ===================================================================== - - name: '[act] (should fail): resultType "env-named" without names' + - name: '[act] (should fail) resultType "env-named" without names' id: input-error_resultType-env-without-names continue-on-error: true uses: ./ @@ -135,7 +136,7 @@ jobs: file: test/resources/test.properties keys: '${{env.SELECTED_KEYS}}' resultType: 'env-named:' - - name: '[assert] (should fail): resultType "env-named" without names' + - name: '[assert] (should fail) resultType "env-named" without names' env: STATUS: ${{steps.input-error_resultType-env-without-names.outcome}} ERROR: ${{steps.input-error_resultType-env-without-names.outputs.error}} @@ -145,14 +146,14 @@ jobs: assertEquals "$ERROR" 'invalid resultType env-named: (missing argument)' # ===================================================================== - - name: '[act] (should fail): resultType "output-named" without keys' + - name: '[act] (should fail) resultType "output-named" without keys' id: input-error_resultType-output-without-keys continue-on-error: true uses: ./ with: file: test/resources/test.properties resultType: 'output-named:a b' - - name: '[assert] (should fail): resultType "output-named" without keys' + - name: '[assert] (should fail) resultType "output-named" without keys' env: STATUS: ${{steps.input-error_resultType-output-without-keys.outcome}} ERROR: ${{steps.input-error_resultType-output-without-keys.outputs.error}} @@ -162,14 +163,14 @@ jobs: assertEquals "$ERROR" 'invalid use of resultType output-named (missing keys)' # ===================================================================== - - name: '[act] (should fail): resultType "env-named" without keys' + - name: '[act] (should fail) resultType "env-named" without keys' id: input-error_resultType-env-without-keys continue-on-error: true uses: ./ with: file: test/resources/test.properties resultType: 'env-named:a b' - - name: '[assert] (should fail): resultType "env-named" without keys' + - name: '[assert] (should fail) resultType "env-named" without keys' env: STATUS: ${{steps.input-error_resultType-env-without-keys.outcome}} ERROR: ${{steps.input-error_resultType-env-without-keys.outputs.error}} @@ -185,8 +186,8 @@ jobs: - *checkoutStep # ===================================================================== - - name: '[act] missing file with default onMissingFile "notice-message"' - id: file-error_onMissingFile-notice-message + - name: '[act] missing file with onMissingFile "notice-message"' + id: missing-file-error_onMissingFile-notice-message uses: ./ with: file: test/resources/DOES_NOT_EXIST.properties @@ -195,14 +196,14 @@ jobs: resultType: json - name: '[assert] missing file with onMissingFile "notice-message"' env: - RESULT_JSON: ${{steps.file-error_onMissingFile-notice-message.outputs.json}} + RESULT_JSON: ${{steps.missing-file-error_onMissingFile-notice-message.outputs.json}} run: |- . "$SHELL_SETUP" assertEquals "$(getJsonValue "$RESULT_JSON" "$SELECTED_KEY")" '' # ===================================================================== - - name: '[act] missing file with default onMissingFile "warning-message"' - id: file-error_onMissingFile-warning-message + - name: '[act] missing file with onMissingFile "warning-message"' + id: missing-file-error_onMissingFile-warning-message uses: ./ with: file: test/resources/DOES_NOT_EXIST.properties @@ -211,14 +212,14 @@ jobs: resultType: json - name: '[assert] missing file with onMissingFile "warning-message"' env: - RESULT_JSON: ${{steps.file-error_onMissingFile-warning-message.outputs.json}} + RESULT_JSON: ${{steps.missing-file-error_onMissingFile-warning-message.outputs.json}} run: |- . "$SHELL_SETUP" assertEquals "$(getJsonValue "$RESULT_JSON" "$SELECTED_KEY")" '' # ===================================================================== - - name: '[act] (should fail): missing file with default onMissingFile "error"' - id: file-error_onMissingFile-error + - name: '[act] (should fail) missing file with onMissingFile "error"' + id: missing-file-error_onMissingFile-error continue-on-error: true uses: ./ with: @@ -226,30 +227,73 @@ jobs: onMissingFile: error keys: ${{env.SELECTED_KEY}} resultType: json - - name: '[assert] (should fail): missing file with onMissingFile "error"' + - name: '[assert] (should fail) missing file with onMissingFile "error"' env: - STATUS: ${{steps.file-error_onMissingFile-error.outcome}} - RESULT_JSON: ${{steps.file-error_onMissingFile-error.outputs.json}} + STATUS: ${{steps.missing-file-error_onMissingFile-error.outcome}} + RESULT_JSON: ${{steps.missing-file-error_onMissingFile-error.outputs.json}} run: &validateOnMissingFileErrorImpl |- . "$SHELL_SETUP" assertEquals "$STATUS" failure assertEquals "$RESULT_JSON" '' # ===================================================================== - - name: '[act] (should fail): missing file with default onMissingFile' - id: file-error_onMissingFile-default + - name: '[act] (should fail) missing file with default onMissingFile' + id: missing-file-error_onMissingFile-default continue-on-error: true uses: ./ with: file: test/resources/DOES_NOT_EXIST.properties keys: ${{env.SELECTED_KEY}} resultType: json - - name: '[assert] (should fail): missing file with default onMissingFile' + - name: '[assert] (should fail) missing file with default onMissingFile' env: - STATUS: ${{steps.file-error_onMissingFile-default.outcome}} - RESULT_JSON: ${{steps.file-error_onMissingFile-default.outputs.json}} + STATUS: ${{steps.missing-file-error_onMissingFile-default.outcome}} + RESULT_JSON: ${{steps.missing-file-error_onMissingFile-default.outputs.json}} run: *validateOnMissingFileErrorImpl + ######################################################################### + test_invalid-file: + runs-on: ubuntu-latest + steps: + - *checkoutStep + # ===================================================================== + - name: '[act] (should fail) file is a directory' + id: invalid-file-error_directory + continue-on-error: true + uses: ./ + with: + file: test + onMissingFile: 'notice-message' # should fail with any onMissingFile, even the most lenient one + keys: ${{env.SELECTED_KEY}} + resultType: json + - name: '[assert] (should fail) file is a directory' + env: + STATUS: ${{steps.invalid-file-error_directory.outcome}} + ERROR: ${{steps.invalid-file-error_directory.outputs.error}} + run: |- + . "$SHELL_SETUP" + assertEquals "$STATUS" failure + assertEquals "$ERROR" 'test is a directory' + + # ===================================================================== + - name: '[act] (should fail) invalid file format' + id: invalid-file-error_format + continue-on-error: true + uses: ./ + with: + file: test/resources/test-invalid.properties + onMissingFile: 'notice-message' # should fail with any onMissingFile, even the most lenient one + keys: ${{env.SELECTED_KEY}} + resultType: json + - name: '[assert] (should fail) invalid file format' + env: + STATUS: ${{steps.invalid-file-error_format.outcome}} + ERROR: ${{steps.invalid-file-error_format.outputs.error}} + run: |- + . "$SHELL_SETUP" + assertEquals "$STATUS" failure + assertEquals "$ERROR" 'error in file test/resources/test-invalid.properties: Malformed \uxxxx encoding.' + ######################################################################### test_default: runs-on: ubuntu-latest diff --git a/Action.java b/Action.java index e628d00..fe9433b 100644 --- a/Action.java +++ b/Action.java @@ -84,6 +84,7 @@ public String toString() { } enum MissingFileHandlerName { + DEBUG_MESSAGE("debug-message"), // NOTICE_MESSAGE("notice-message"), // WARNING_MESSAGE("warning-message"), // ERROR("error"); @@ -132,7 +133,7 @@ public synchronized IOException getCause() { /** - * An exception for which an "error" output should be set. + * An exception for which an {@link Ids.OutputName#ERROR} output should be set. */ class OutputException extends RuntimeException { public OutputException(String message, Throwable cause) { @@ -203,23 +204,41 @@ public void requireNoArg() { } +enum GithubMessageType { + DEBUG("debug"), // + NOTICE("notice"), // + WARNING("warning"), // + ERROR("error"); + + public final String externalName; + + private GithubMessageType(String externalName) { + this.externalName = externalName; + } + + public void format(String format, Object... args) { + String formatWithPrefix = String.format("::%s::%s\n", externalName, format); + System.out.format(formatWithPrefix, args); + } +} + + enum MissingFileHandler { - NOTICE_MESSAGE(Ids.MissingFileHandlerName.NOTICE_MESSAGE, "notice"), // - WARNING_MESSAGE(Ids.MissingFileHandlerName.WARNING_MESSAGE, "warning"), // - ERROR(Ids.MissingFileHandlerName.ERROR, "error") { + DEBUG_MESSAGE(Ids.MissingFileHandlerName.DEBUG_MESSAGE, GithubMessageType.DEBUG), // + NOTICE_MESSAGE(Ids.MissingFileHandlerName.NOTICE_MESSAGE, GithubMessageType.NOTICE), // + WARNING_MESSAGE(Ids.MissingFileHandlerName.WARNING_MESSAGE, GithubMessageType.WARNING), // + ERROR(Ids.MissingFileHandlerName.ERROR, GithubMessageType.ERROR) { @Override - public void handleMissingFile(String message) { - super.handleMissingFile(message); - throw new ExitSilentlyException(2); - } + public void handleMissingFile(String messageFormat, + Object... messageArgs) {super.handleMissingFile(messageFormat,messageArgs);throw new ExitSilentlyException(2);} }; private final String externalName; - private final String output; + private final GithubMessageType githubMessageType; - private MissingFileHandler(Ids.MissingFileHandlerName externalName, String output) { + private MissingFileHandler(Ids.MissingFileHandlerName externalName, GithubMessageType githubMessageType) { this.externalName = externalName.externalName; - this.output = output; + this.githubMessageType = githubMessageType; } public static MissingFileHandler ofExternalName(String externalName) { @@ -234,8 +253,15 @@ public static MissingFileHandler ofExternalName(String externalName) { @SuppressWarnings("java:S3457") // Sonar rule suggests %n instead of \n, but that would not strictly be covered by the docs // [https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-commands#setting-a-notice-message], so the \r // might be considered part of the message. - public void handleMissingFile(String message) { - System.out.format("::%s::%s\n", output, message); + public void handleMissingFile(String messageFormat, Object... messageArgs) { + String messageStr = String.format(messageFormat, messageArgs); + try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { + writer.write(Ids.OutputName.ERROR, messageStr); + } catch (IOException e) { + // This is an optional output. ⇒ don't throw + GithubMessageType.DEBUG.format("failed to set output %s = \"%s\": %s", Ids.OutputName.ERROR, messageStr, e.toString()); + } + githubMessageType.format(messageFormat, messageArgs); } } @@ -244,80 +270,38 @@ enum ResultWriter { OUTPUT(Ids.ResultWriterName.OUTPUT) { @Override public void write(Map> props, Config config) throws IOException { - config.requireNoArg(); - String lastValue = null; - try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { - for (Map.Entry> entry : props.entrySet()) { - String key = encodeKey(config.outputPrefix() + entry.getKey()); - lastValue = entry.getValue().orElse(""); - writer.write(key, lastValue); - } - - if (config.selectedKeys().isPresent()) { - writer.write(Ids.OutputName.VALUE, lastValue != null ? lastValue : ""); - } - } - } + config.requireNoArg();String lastValue=null;try(GitHubVariableWriter writer=GitHubOutputFile.OUTPUT.open()){for(Map.Entry>entry:props.entrySet()){String key=encodeKey(config.outputPrefix()+entry.getKey());lastValue=entry.getValue().orElse("");writer.write(key,lastValue);} - private static String encodeKey(String key) { - StringBuilder result = new StringBuilder(key.length() + 4); - Matcher matcher = Pattern.compile("([\\s\\p{Punct}&&[^_]])").matcher(key); - while (matcher.find()) { - matcher.appendReplacement(result, String.format("-%04X", (int) matcher.group(1).charAt(0))); - } - matcher.appendTail(result); - return result.toString(); + if(config.selectedKeys().isPresent()){writer.write(Ids.OutputName.VALUE,lastValue!=null?lastValue:"");}} } + + private static String encodeKey( + String key) {StringBuilder result=new StringBuilder(key.length()+4);Matcher matcher=Pattern.compile("([\\s\\p{Punct}&&[^_]])").matcher(key);while(matcher.find()){matcher.appendReplacement(result,String.format("-%04X",(int)matcher.group(1).charAt(0)));}matcher.appendTail(result);return result.toString();} }, OUTPUT_NAMED(Ids.ResultWriterName.OUTPUT_NAMED) { @Override - public void write(Map> props, Config config) throws IOException { - writeNamedImpl(props, config, true, GitHubOutputFile.OUTPUT); - } + public void write(Map> props, Config config) + throws IOException {writeNamedImpl(props,config,true,GitHubOutputFile.OUTPUT);} }, ENV_NAMED(Ids.ResultWriterName.ENV_NAMED) { @Override - public void write(Map> props, Config config) throws IOException { - writeNamedImpl(props, config, false, GitHubOutputFile.ENV); - } + public void write(Map> props, Config config) + throws IOException {writeNamedImpl(props,config,false,GitHubOutputFile.ENV);} }, ENV(Ids.ResultWriterName.ENV) { @Override - public void write(Map> props, Config config) throws IOException { - String prefix = config.resultTypeArg(); - try (GitHubVariableWriter writer = GitHubOutputFile.ENV.open()) { - for (Map.Entry> entry : props.entrySet()) { - Optional value = entry.getValue(); - value.ifPresent(v -> writer.write(prefix + entry.getKey(), v)); - } - } - } + public void write(Map> props, Config config) + throws IOException {String prefix=config.resultTypeArg();try(GitHubVariableWriter writer=GitHubOutputFile.ENV.open()){for(Map.Entry>entry:props.entrySet()){Optionalvalue=entry.getValue();value.ifPresent(v->writer.write(prefix+entry.getKey(),v));}}} }, JSON(Ids.ResultWriterName.JSON) { @Override - public void write(Map> props, Config config) throws IOException { - config.requireNoArg(); - try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { - writer.write(Ids.OutputName.JSON, Util.toJson(props).s()); - } - } + public void write(Map> props, Config config) + throws IOException {config.requireNoArg();try(GitHubVariableWriter writer=GitHubOutputFile.OUTPUT.open()){writer.write(Ids.OutputName.JSON,Util.toJson(props).s());}} }, JSON_FILE(Ids.ResultWriterName.JSON_FILE) { @Override - public void write(Map> props, Config config) throws IOException { - String outputFile = config.requiredResultTypeArg(); - Path parentDir = Paths.get(outputFile).getParent(); - if (parentDir != null) { - Files.createDirectories(parentDir); - } - try (Writer writer = Util.openFile(outputFile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { - StringIntPair jsonResult = Util.toJson(props); - System.err.format("writing JSON for %s properties to %s%n", jsonResult.i(), outputFile); - writer.write(jsonResult.s()); - writer.write('\n'); - writer.flush(); - } - } + public void write(Map> props, Config config) + throws IOException {String outputFile=config.requiredResultTypeArg();Path parentDir=Paths.get(outputFile).getParent();if(parentDir!=null){Files.createDirectories(parentDir);}try(Writer writer=Util.openFile(outputFile,StandardOpenOption.CREATE,StandardOpenOption.TRUNCATE_EXISTING)){StringIntPair jsonResult=Util.toJson(props);System.err.format("writing JSON for %s properties to %s%n",jsonResult.i(),outputFile);writer.write(jsonResult.s());writer.write('\n');writer.flush();}} }; private final String externalName; @@ -483,16 +467,21 @@ public static Writer openFile(String name, OpenOption... options) throws IOExcep public static Properties readProperties(String file, MissingFileHandler missingFileHandler) { Properties allProps = new Properties(); Path path = Paths.get(file); - try (InputStream in = Files.newInputStream(path)) { - allProps.load(in); - return allProps; - } catch (IOException e) { - // Nice message (instead of catching FileNotFoundExeption which is also thrown on other problems and just contains the - // filename, not "does not exist" or similar): - String message = Files.exists(path) ? ("error opening file: " + e.getMessage()) : ("file " + path + " does not exist"); - missingFileHandler.handleMissingFile(message); - return new Properties(); + if (!Files.exists(path)) { + missingFileHandler.handleMissingFile("file " + path + " does not exist"); + } else if (Files.isDirectory(path)) { + MissingFileHandler.ERROR.handleMissingFile(path + " is a directory"); + } else { + try (InputStream in = Files.newInputStream(path)) { + allProps.load(in); + return allProps; + } catch (IOException e) { + missingFileHandler.handleMissingFile("error opening file %s: %s", path, e.getMessage()); + } catch (Exception e) { // e.g. IllegalArgumentException: "Malformed \\uxxxx encoding." on invalid contents + MissingFileHandler.ERROR.handleMissingFile("error in file %s: %s", path, e.getMessage()); + } } + return new Properties(); } /** diff --git a/action.yml b/action.yml index 89f1baa..e51d979 100644 --- a/action.yml +++ b/action.yml @@ -12,9 +12,13 @@ inputs: default: error description: |- What to do if `file` does not exist or is not readable. One of: + - "debug-message": write a debug message and continue with an empty properties map, - "notice-message": write a notice message and continue with an empty properties map, - "warning-message": write a warning message and continue with an empty properties map, - "error": write an error message and exit with non-zero status. + Note that these situations are always treated as an error: + - `file` is a directory, + - `file` is readable, but not in the correct format. keys: description: |- Selects the property keys (names). Format: `keySeparator`-separated list. diff --git a/test/resources/test-invalid.properties b/test/resources/test-invalid.properties new file mode 100644 index 0000000..c62f03e --- /dev/null +++ b/test/resources/test-invalid.properties @@ -0,0 +1 @@ +foo = \u From fc19d7ea298516517cda848bf1eafc868d4e5dc7 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Thu, 21 May 2026 12:26:47 +0200 Subject: [PATCH 61/69] Dockerfile: remove maintainer label --- Dockerfile | 1 - 1 file changed, 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 311cca1..05b49f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,6 @@ LABEL "com.github.actions.name"="read Java properties" LABEL "com.github.actions.description"="read Java properties file and return one or more as property values as plain text or JSON" LABEL "repository"="https://github.com/freenet-actions/read-java-properties" LABEL "homepage"="https://github.com/freenet-actions" -LABEL "maintainer"="Team MCBS Core " WORKDIR /action COPY *.java . From fa7c17e71e528307c20017549ebead29ee4a2397 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Thu, 21 May 2026 12:30:16 +0200 Subject: [PATCH 62/69] Java formatting --- Action.java | 78 +++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 61 insertions(+), 17 deletions(-) diff --git a/Action.java b/Action.java index fe9433b..40df412 100644 --- a/Action.java +++ b/Action.java @@ -229,8 +229,10 @@ enum MissingFileHandler { WARNING_MESSAGE(Ids.MissingFileHandlerName.WARNING_MESSAGE, GithubMessageType.WARNING), // ERROR(Ids.MissingFileHandlerName.ERROR, GithubMessageType.ERROR) { @Override - public void handleMissingFile(String messageFormat, - Object... messageArgs) {super.handleMissingFile(messageFormat,messageArgs);throw new ExitSilentlyException(2);} + public void handleMissingFile(String messageFormat, Object... messageArgs) { + super.handleMissingFile(messageFormat, messageArgs); + throw new ExitSilentlyException(2); + } }; private final String externalName; @@ -270,38 +272,80 @@ enum ResultWriter { OUTPUT(Ids.ResultWriterName.OUTPUT) { @Override public void write(Map> props, Config config) throws IOException { - config.requireNoArg();String lastValue=null;try(GitHubVariableWriter writer=GitHubOutputFile.OUTPUT.open()){for(Map.Entry>entry:props.entrySet()){String key=encodeKey(config.outputPrefix()+entry.getKey());lastValue=entry.getValue().orElse("");writer.write(key,lastValue);} - - if(config.selectedKeys().isPresent()){writer.write(Ids.OutputName.VALUE,lastValue!=null?lastValue:"");}} + config.requireNoArg(); + String lastValue = null; + try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { + for (Map.Entry> entry : props.entrySet()) { + String key = encodeKey(config.outputPrefix() + entry.getKey()); + lastValue = entry.getValue().orElse(""); + writer.write(key, lastValue); + } + + if (config.selectedKeys().isPresent()) { + writer.write(Ids.OutputName.VALUE, lastValue != null ? lastValue : ""); + } + } } - private static String encodeKey( - String key) {StringBuilder result=new StringBuilder(key.length()+4);Matcher matcher=Pattern.compile("([\\s\\p{Punct}&&[^_]])").matcher(key);while(matcher.find()){matcher.appendReplacement(result,String.format("-%04X",(int)matcher.group(1).charAt(0)));}matcher.appendTail(result);return result.toString();} + private static String encodeKey(String key) { + StringBuilder result = new StringBuilder(key.length() + 4); + Matcher matcher = Pattern.compile("([\\s\\p{Punct}&&[^_]])").matcher(key); + while (matcher.find()) { + matcher.appendReplacement(result, String.format("-%04X", (int) matcher.group(1).charAt(0))); + } + matcher.appendTail(result); + return result.toString(); + } }, OUTPUT_NAMED(Ids.ResultWriterName.OUTPUT_NAMED) { @Override - public void write(Map> props, Config config) - throws IOException {writeNamedImpl(props,config,true,GitHubOutputFile.OUTPUT);} + public void write(Map> props, Config config) throws IOException { + writeNamedImpl(props, config, true, GitHubOutputFile.OUTPUT); + } }, ENV_NAMED(Ids.ResultWriterName.ENV_NAMED) { @Override - public void write(Map> props, Config config) - throws IOException {writeNamedImpl(props,config,false,GitHubOutputFile.ENV);} + public void write(Map> props, Config config) throws IOException { + writeNamedImpl(props, config, false, GitHubOutputFile.ENV); + } }, ENV(Ids.ResultWriterName.ENV) { @Override - public void write(Map> props, Config config) - throws IOException {String prefix=config.resultTypeArg();try(GitHubVariableWriter writer=GitHubOutputFile.ENV.open()){for(Map.Entry>entry:props.entrySet()){Optionalvalue=entry.getValue();value.ifPresent(v->writer.write(prefix+entry.getKey(),v));}}} + public void write(Map> props, Config config) throws IOException { + String prefix = config.resultTypeArg(); + try (GitHubVariableWriter writer = GitHubOutputFile.ENV.open()) { + for (Map.Entry> entry : props.entrySet()) { + Optional value = entry.getValue(); + value.ifPresent(v -> writer.write(prefix + entry.getKey(), v)); + } + } + } }, JSON(Ids.ResultWriterName.JSON) { @Override - public void write(Map> props, Config config) - throws IOException {config.requireNoArg();try(GitHubVariableWriter writer=GitHubOutputFile.OUTPUT.open()){writer.write(Ids.OutputName.JSON,Util.toJson(props).s());}} + public void write(Map> props, Config config) throws IOException { + config.requireNoArg(); + try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { + writer.write(Ids.OutputName.JSON, Util.toJson(props).s()); + } + } }, JSON_FILE(Ids.ResultWriterName.JSON_FILE) { @Override - public void write(Map> props, Config config) - throws IOException {String outputFile=config.requiredResultTypeArg();Path parentDir=Paths.get(outputFile).getParent();if(parentDir!=null){Files.createDirectories(parentDir);}try(Writer writer=Util.openFile(outputFile,StandardOpenOption.CREATE,StandardOpenOption.TRUNCATE_EXISTING)){StringIntPair jsonResult=Util.toJson(props);System.err.format("writing JSON for %s properties to %s%n",jsonResult.i(),outputFile);writer.write(jsonResult.s());writer.write('\n');writer.flush();}} + public void write(Map> props, Config config) throws IOException { + String outputFile = config.requiredResultTypeArg(); + Path parentDir = Paths.get(outputFile).getParent(); + if (parentDir != null) { + Files.createDirectories(parentDir); + } + try (Writer writer = Util.openFile(outputFile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { + StringIntPair jsonResult = Util.toJson(props); + System.err.format("writing JSON for %s properties to %s%n", jsonResult.i(), outputFile); + writer.write(jsonResult.s()); + writer.write('\n'); + writer.flush(); + } + } }; private final String externalName; From 5c182b281c3df5ea2f4878561b56f1f655f62cd3 Mon Sep 17 00:00:00 2001 From: dloetzke <97966457+dloetzke@users.noreply.github.com> Date: Thu, 21 May 2026 15:46:19 +0200 Subject: [PATCH 63/69] Added Dependabot config Signed-off-by: dloetzke <97966457+dloetzke@users.noreply.github.com> --- .github/dependabot.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ad14672 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: 'actions' + directory: '/' + cooldown: + default-days: 7 + schedule: + interval: 'monthly' + - package-ecosystem: 'docker' + directory: '/' + cooldown: + default-days: 7 + schedule: + interval: 'monthly' From 9b04bc0a8958a74935582bebeab517fdcf59ac55 Mon Sep 17 00:00:00 2001 From: dloetzke <97966457+dloetzke@users.noreply.github.com> Date: Thu, 21 May 2026 15:47:35 +0200 Subject: [PATCH 64/69] Fixed Dependabot config Signed-off-by: dloetzke <97966457+dloetzke@users.noreply.github.com> --- .github/dependabot.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index ad14672..4c9b865 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,6 @@ version: 2 updates: - - package-ecosystem: 'actions' + - package-ecosystem: 'github-actions' directory: '/' cooldown: default-days: 7 From 9820646ea52dfe1b49cdb938f0ea7fcec546d4df Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Thu, 21 May 2026 16:24:29 +0200 Subject: [PATCH 65/69] review suggestions: test workflow security, docs Co-authored-by: dloetzke <97966457+dloetzke@users.noreply.github.com> Signed-off-by: Christoph Strebin --- .github/workflows/test.yml | 4 ++-- README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 87e1360..a76dc3f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,11 +44,11 @@ jobs: steps: - &checkoutStep name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.head_ref }} fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} + persist-credentials: false - name: "check that this job's `needs` is set to exactly all other jobs" env: THIS_WORKFLOW_FILE: '.github/workflows/test.yml' diff --git a/README.md b/README.md index b458e39..c265174 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ GitHub Action to read a Java .properties file and output one, multiple, or all properties as plain strings or JSON. -## Usage example: +## Usage examples: Suppose file gradle.properties contains properties `sourceJavaVersion= 21`, `targetJavaVersion= 17`, and `org.gradle.jvmargs= -ea -showversion`. From f1ec5150d8fd41cb6d99575ba6985bfacff64cf9 Mon Sep 17 00:00:00 2001 From: Christoph Strebin <50327203+ChristophS-md@users.noreply.github.com> Date: Thu, 21 May 2026 17:46:46 +0200 Subject: [PATCH 66/69] re-do optimization: compile Java at Docker build time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assumed problem with this was just my wrong `docker run --mount=…` invocation during local testing. --- Dockerfile | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 05b49f0..fddfed7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,10 +9,5 @@ WORKDIR /action COPY *.java . RUN ["chmod", "a-wx", "Action.java"] -# When re-building like this and using `java`, we may get an image with up-to-date Action.java, -# but outdated *.class. Don't know how GitHub actions handles this exactly, but this would break -# local Docker builds at least. -# # RUN ["javac", "Action.java"] -# # ENTRYPOINT ["java", "--class-path", "/action", "Action"] -# So instead: -ENTRYPOINT ["java", "Action.java"] +RUN ["javac", "Action.java"] +ENTRYPOINT ["java", "--class-path", "/action", "Action"] From 193fcc6217365bfaa954098943fa9192240d41c9 Mon Sep 17 00:00:00 2001 From: Christoph Strebin <50327203+ChristophS-md@users.noreply.github.com> Date: Thu, 21 May 2026 18:12:52 +0200 Subject: [PATCH 67/69] docs: clarify that selected `keys` apply to all resultTypes --- action.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/action.yml b/action.yml index e51d979..24c8e0c 100644 --- a/action.yml +++ b/action.yml @@ -35,20 +35,22 @@ inputs: resultType: description: |- The result format and target. One of: - - "output": For each property key , set an output named "_", but with special characters encoded. (The leading underscore serves to avoid conflicts with future output names used by this action.) + - "output": For each¹ property key , set an output named "_", but with special characters encoded. (The leading underscore serves to avoid conflicts with future output names used by this action.) Encoding replaces all punctuation or whitespace characters except the underscore ("_") with "-" followed by four hex digits of its unicode code point. For example, for a property key "a.b-c_d", resultType "output" sets an output with name "_a-002Eb-002Dc_d". Additionally, set output "value" to the last found value, unless `keys` is empty. I.e. last given key wins. If `keys` is given, but none is found, "value" is set to empty. (Without keys, this output is not set, because it would be arbitrary due to the "random" iteration order of the used Java class java.util.Properties.) - "output-named:": is a `resultNameSeparator`-separated list of output names of the same length as (the `keySeparator`-separated list) `keys`. The value for `keys[i]` is set as output `[i]`. In order not to hide any future builtin outputs of this action, it is recommended to prefix each name with an underscore ("_"). In this mode, `keys` is required. Unlike "output", names are taken as-is. (This is because no official output naming rules seem to exist yet; so maybe a future user will know better how to choose valid characters than we would implement now.) The output for a missing property is set to empty. If you need to distinguish between empty an undefined properties, resultType "json" or "json-file" is recommended. - "output-named:": Special case: A single name is supported even with multiple `keys`. All found values for the keys are set as the same output; the last found key wins. In this mode, `keys` is required. If none is found, the output is set to empty. - - "env": For each property key , set an environment variable . + - "env": For each¹ property key , set an environment variable . In order not to pollute the environment with hard to understand variables, this should only be used to set some specifically named variables. I.e. you know that the property file can only contain such properties or you are selecting only such properties with `keys`. - - "env:": For each property key , set an environment variable . + - "env:": For each¹ property key , set an environment variable . - "env-named:": is a `resultNameSeparator`-separated list of variable names of the same length as (the `keySeparator`-separated list) `keys`. The value for `keys[i]` is set as environment variable `[i]`. In this mode, `keys` is required. The environment variable for a missing property is not set. - "env-named:": Special case: A single name is supported even with multiple `keys`. All found values for the keys are set as the same environment variable; the last found key wins. In this mode, `keys` is required. - - "json": Set an action output "json" to all (selected, if keys is set) properties as a JSON object, formatted as a single-line string. Selected keys for missing properties are included with value null. - - "json-file:": Write a file with name with contents: all (selected, if keys is set) properties as a JSON object (formatting not specified). Selected keys for missing properties are included with value null. + - "json": Set an action output "json" to all¹ properties as a JSON object, formatted as a single-line string. Selected keys for missing properties are included with value null. + - "json-file:": Write a file with name with contents: all¹ properties as a JSON object (formatting not specified). Selected keys for missing properties are included with value null. + + ¹Here, "each property" etc. means each property selected by input `keys` if it is non-empty. required: false default: 'output' From e66848603104018f5612dbca3625ea6adcdd0624 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Wed, 3 Jun 2026 18:27:07 +0200 Subject: [PATCH 68/69] dependabot config: daily (code review suggestion) Co-authored-by: dloetzke <97966457+dloetzke@users.noreply.github.com> Signed-off-by: Christoph Strebin --- .github/dependabot.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4c9b865..5d99fa2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,10 +5,10 @@ updates: cooldown: default-days: 7 schedule: - interval: 'monthly' + interval: 'daily' - package-ecosystem: 'docker' directory: '/' cooldown: default-days: 7 schedule: - interval: 'monthly' + interval: 'daily' From 1c8340661db83bbafadf2755bc3ed01199249c21 Mon Sep 17 00:00:00 2001 From: Christoph Strebin Date: Thu, 4 Jun 2026 12:35:45 +0200 Subject: [PATCH 69/69] docs (apply review suggestion) Co-authored-by: dloetzke <97966457+dloetzke@users.noreply.github.com> Signed-off-by: Christoph Strebin --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 24c8e0c..b1804b2 100644 --- a/action.yml +++ b/action.yml @@ -40,7 +40,7 @@ inputs: of its unicode code point. For example, for a property key "a.b-c_d", resultType "output" sets an output with name "_a-002Eb-002Dc_d". Additionally, set output "value" to the last found value, unless `keys` is empty. I.e. last given key wins. If `keys` is given, but none is found, "value" is set to empty. (Without keys, this output is not set, because it would be arbitrary due to the "random" iteration order of the used Java class java.util.Properties.) - - "output-named:": is a `resultNameSeparator`-separated list of output names of the same length as (the `keySeparator`-separated list) `keys`. The value for `keys[i]` is set as output `[i]`. In order not to hide any future builtin outputs of this action, it is recommended to prefix each name with an underscore ("_"). In this mode, `keys` is required. Unlike "output", names are taken as-is. (This is because no official output naming rules seem to exist yet; so maybe a future user will know better how to choose valid characters than we would implement now.) The output for a missing property is set to empty. If you need to distinguish between empty an undefined properties, resultType "json" or "json-file" is recommended. + - "output-named:": is a `resultNameSeparator`-separated list of output names of the same length as (the `keySeparator`-separated list) `keys`. The value for `keys[i]` is set as output `[i]`. In order not to hide any future builtin outputs of this action, it is recommended to prefix each name with an underscore ("_"). In this mode, `keys` is required. Unlike "output", names are taken as-is. (This is because no official output naming rules seem to exist yet; so maybe a future user will know better how to choose valid characters than we would implement now.) The output for a missing property is set to empty. If you need to distinguish between empty and undefined properties, resultType "json" or "json-file" is recommended. - "output-named:": Special case: A single name is supported even with multiple `keys`. All found values for the keys are set as the same output; the last found key wins. In this mode, `keys` is required. If none is found, the output is set to empty. - "env": For each¹ property key , set an environment variable . In order not to pollute the environment with hard to understand variables, this should only be used to set some specifically named variables. I.e. you know that the property file can only contain such properties or you are selecting only such properties with `keys`.