diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5d99fa2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: 'github-actions' + directory: '/' + cooldown: + default-days: 7 + schedule: + interval: 'daily' + - package-ecosystem: 'docker' + directory: '/' + cooldown: + default-days: 7 + schedule: + interval: 'daily' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8cb65e0..a76dc3f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,14 +1,1053 @@ name: Test on: pull_request +defaults: + run: + shell: bash +env: + 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_NON_EX_KEY: 'nonExistingKey1' + SELECTED_NON_EX_KEY2: 'nonExistingKey2' + SELECTED_NAME: 'my-prop' + SELECTED_NAMES: 'java-version jvm_args unicode colon' + + SHELL_SETUP: test/test-setup.sh + NOT_FOUND_STATUS: 54 jobs: + ######################################################################### + # 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: - runs-on: ubuntu - defaults: - run: - shell: bash + needs: + - test_invalid-usage + - test_missing-file + - test_invalid-file + - test_default + - test_output + - test_output-named + - test_json + - 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-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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + 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' + THIS_JOB_NAME: test + run: |- + diff --minimal --report-identical-files \ + <(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: + 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_invalid-usage: + runs-on: ubuntu-latest + steps: + - *checkoutStep + + # ===================================================================== + - 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' + env: + STATUS: ${{steps.input-error_resultType.outcome}} + ERROR: ${{steps.input-error_resultType.outputs.error}} + run: |- + . "$SHELL_SETUP" + 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 + continue-on-error: true + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS}}' + resultType: 'output-named:' + - 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}} + run: |- + . "$SHELL_SETUP" + assertEquals "$STATUS" failure + assertEquals "$ERROR" 'invalid resultType output-named: (missing argument)' + + # ===================================================================== + - name: '[act] (should fail) resultType "env-named" without names' + id: input-error_resultType-env-without-names + continue-on-error: true + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS}}' + resultType: 'env-named:' + - 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}} + run: |- + . "$SHELL_SETUP" + assertEquals "$STATUS" failure + assertEquals "$ERROR" 'invalid resultType env-named: (missing argument)' + + # ===================================================================== + - 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' + env: + STATUS: ${{steps.input-error_resultType-output-without-keys.outcome}} + ERROR: ${{steps.input-error_resultType-output-without-keys.outputs.error}} + run: |- + . "$SHELL_SETUP" + assertEquals "$STATUS" failure + assertEquals "$ERROR" 'invalid use of resultType output-named (missing 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' + env: + STATUS: ${{steps.input-error_resultType-env-without-keys.outcome}} + ERROR: ${{steps.input-error_resultType-env-without-keys.outputs.error}} + run: |- + . "$SHELL_SETUP" + 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 onMissingFile "notice-message"' + id: missing-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.missing-file-error_onMissingFile-notice-message.outputs.json}} + run: |- + . "$SHELL_SETUP" + assertEquals "$(getJsonValue "$RESULT_JSON" "$SELECTED_KEY")" '' + + # ===================================================================== + - name: '[act] missing file with onMissingFile "warning-message"' + id: missing-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.missing-file-error_onMissingFile-warning-message.outputs.json}} + run: |- + . "$SHELL_SETUP" + assertEquals "$(getJsonValue "$RESULT_JSON" "$SELECTED_KEY")" '' + + # ===================================================================== + - name: '[act] (should fail) missing file with onMissingFile "error"' + id: missing-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.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: 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' + env: + 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 + steps: + - *checkoutStep + # ===================================================================== + - name: '[act] query single property with default resultType' + id: default_single + uses: ./ + with: + file: test/resources/test.properties + keys: sourceJavaVersion + - name: '[assert] query single property with default resultType' + env: + RESULT: ${{steps.default_single.outputs._sourceJavaVersion}} + run: |- + . "$SHELL_SETUP" + assertEquals "$RESULT" '21' + + 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. + - name: '[act] query multiple properties as action outputs (simple)' + id: output_multi_simple + uses: ./ + with: + file: test/resources/test.properties + 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_SPACEK: ${{steps.output_multi_simple.outputs._space-0020in-0020Key}} + RESULT_DASHK: ${{steps.output_multi_simple.outputs._dash-002Din-002DKey}} + run: |- + . "$SHELL_SETUP" + assertEquals "$RESULT_SJV" '21' + assertEquals "$RESULT_JVMARGS" '-ea -showversion' + assertEquals "$RESULT_SPACEK" 'sp' + assertEquals "$RESULT_DASHK" 'd' + + # ===================================================================== + - name: '[act] query all properties as action outputs' + 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.output_all.outputs)}} + RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' + run: &validateAllImpl |- + . "$SHELL_SETUP" + 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" 'back\slash' 'back\slash\value' + 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_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: + RESULT_VALUE: ${{steps.output_all.outputs.value || 'U_N_S_E_T'}} + run: |- + . "$SHELL_SETUP" + assertEquals "$RESULT_VALUE" U_N_S_E_T + + # ===================================================================== + - name: '[act] query multiple properties as action outputs' + id: output_multi + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS}}' + resultType: output + + - name: '[assert] query multiple properties as action outputs' + env: + RESULT_JSON: ${{toJSON(steps.output_multi.outputs)}} + RESULT_GETTER: 'encodeKeyAndGetJsonValue "$RESULT_JSON"' + run: &validateMultiImpl |- + . "$SHELL_SETUP" + 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" '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" 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_1_4_2 + evalAndAssertUndefined "$RESULT_GETTER" dash_3_4_8_1 + + - name: '[assert] query multiple properties as action outputs (additional "value" output)' + env: + RESULT_VALUE: ${{steps.output_multi.outputs.value}} + run: |- + . "$SHELL_SETUP" + assertEquals "$RESULT_VALUE" 'c' # from last key "colon:in:Key" + + # ===================================================================== + - name: '[act] query multiple properties as action outputs (different keySeparator)' + id: output_multi_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' + resultType: output + - name: '[assert] query multiple properties as action outputs (different keySeparator)' + env: + 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: output_multi_keySeparatorMC + uses: ./ + with: + file: test/resources/test.properties + keySeparator: ':/ ' + keys: 'sourceJavaVersion:/ org.gradle.jvmargs:/ unicode_escapes:/ colon:in:Key' + resultType: output + - name: '[assert] query multiple properties as action outputs (different multi-char keySeparator)' + env: + 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: output_multi2 + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS_OTHER_ORDER}}' + resultType: output + - name: '[assert] query multiple properties as action outputs (in another keys order)' + env: + 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.output_multi2.outputs.value}} + run: |- + . "$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 |- + . "$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_1_4_2 + 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: |- + . "$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: 'validate "test (should fail): invalid input: resultType"' + # ===================================================================== + - 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: |- - [ '${{steps.error-input-resultType.outcome}}' = failure ] + . "$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 + steps: + - *checkoutStep + # ===================================================================== + - name: '[act] query multiple properties as action outputs of given names' + id: output-named_multi + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS}}' + resultType: 'output-named:${{env.SELECTED_NAMES}}' + + - name: '[assert] query multiple properties as action outputs of given names' + env: + 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 |- + . "$SHELL_SETUP" + 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: + 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: + evalAndAssertUndefined "$RESULT_GETTER" colonInValue + evalAndAssertUndefined "$RESULT_GETTER" dash_1 + + - name: '[assert] query multiple properties as action outputs (additional "value" output not set)' + env: + RESULT_VALUE: ${{steps.output-named_multi.outputs.value || 'U_N_S_E_T'}} + run: |- + . "$SHELL_SETUP" + assertEquals "$RESULT_VALUE" U_N_S_E_T + + # ===================================================================== + - name: '[act] query multiple properties as action outputs of given names (different resultNameSeparator)' + id: output-named_multi_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] query multiple properties as action outputs of given names (different resultNameSeparator)' + env: + 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: output-named_multi_resultNameSeparatorMC + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS}}' + resultNameSeparator: '- -' + 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.output-named_multi_resultNameSeparatorMC.outputs)}} + RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' + run: *validateMultiNamedImpl + + # ===================================================================== + - name: '[act] query multiple properties as action outputs of given name' + id: output-named_multi-to-single + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS}}' + resultType: 'output-named:${{env.SELECTED_NAME}}' + + - name: '[assert] query multiple properties as action outputs of given name' + env: + 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 |- + . "$SHELL_SETUP" + evalAndAssertEquals "$RESULT_GETTER" "$SELECTED_NAME" 'c' + + # The outputs with the default names are not set: + 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: + evalAndAssertUndefined "$RESULT_GETTER" colonInValue + evalAndAssertUndefined "$RESULT_GETTER" dash_1 + + # ===================================================================== + - 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_NON_EX_KEY}} + resultType: 'output-named:${{env.SELECTED_NAME}}' + - name: '[assert] query single missing property as action output of given name' + env: + RESULT: ${{steps.output-named_single_missing.outputs.my-prop}} + RESULT_JSON: ${{toJSON(steps.output-named_single_missing.outputs)}} + run: |- + . "$SHELL_SETUP" + # Like this, the expected empty value is not distinguishable from unset: + assertEquals "$RESULT" '' + # Assert set: + assertJsonContainsKey "$RESULT_JSON" "$SELECTED_NAME" + + # ===================================================================== + - 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: |- + . "$SHELL_SETUP" + # Like this, the expected empty value is not distinguishable from unset: + assertEquals "$RESULT" '' + # Assert set: + assertJsonContainsKey "$RESULT_JSON" "$SELECTED_NAME" + + ######################################################################### + test_json: + runs-on: ubuntu-latest + steps: + - *checkoutStep + # ===================================================================== + - name: '[act] query all properties as JSON action output' + 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.json_all.outputs.json}} + RESULT_GETTER: 'getJsonValue "$RESULT_JSON"' + run: *validateAllImpl + + # ===================================================================== + - name: '[act] query multiple properties as JSON action output' + id: json_multi + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS}}' + resultType: json + - name: '[assert] query multiple properties as JSON action output' + env: + 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: |- + . "$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 + steps: + - *checkoutStep + # ===================================================================== + - name: '[arrange] query multiple properties as JSON file (creating file)' + id: json-file_multi_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] query multiple properties as JSON file (creating file)' + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS}}' + 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.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: 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 + # 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] query multiple properties as JSON file (overwriting file)' + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS}}' + 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.json-file_multi_overwrite_arrange.outputs.file}} + RESULT_GETTER: 'getJsonValue "$(<"$JSON_OUTPUT_FILE")"' + run: *validateMultiImpl + + # ===================================================================== + - name: '[arrange] query all properties as JSON file' + id: json-file_all_arrange + # Distinction between creating and overwriting need not be tested again. + # This test just uses an existing empty file. + 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=^\./==') + printf 'file=%s\n' "$file" | tee -- "$GITHUB_OUTPUT" + - name: '[act] query all properties as JSON file' + uses: ./ + with: + file: test/resources/test.properties + resultType: 'json-file:${{steps.json-file_all_arrange.outputs.file}}' + - name: '[assert] query all properties as JSON file' + env: + 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: |- + . "$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. + ######################################################################### + test_env_all: + runs-on: ubuntu-latest + steps: + - *checkoutStep + # ===================================================================== + - name: '[act] query all properties as environment variables' + uses: ./ + with: + file: test/resources/test.properties + resultType: env + - name: '[assert] query all properties as environment variables' + env: + RESULT_GETTER: getEnv + run: *validateAllImpl + + ######################################################################### + test_env_multi: + runs-on: ubuntu-latest + steps: + - *checkoutStep + # ===================================================================== + - name: '[act] query multiple properties as environment variables' + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEYS}}' + resultType: env + - name: '[assert] query multiple properties as environment variables' + env: + RESULT_GETTER: getEnv + run: *validateMultiImpl + + ######################################################################### + test_env_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 + - name: '[assert] query multiple missing properties as environment variables' + run: |- + . "$SHELL_SETUP" + assertVariableUnset "$SELECTED_NON_EX_KEY" + assertVariableUnset "$SELECTED_NON_EX_KEY2" + assertEquals "${!SELECTED_KEY}" '21' + + ######################################################################### + test_env_single: + runs-on: ubuntu-latest + steps: + - *checkoutStep + # ===================================================================== + - name: '[act] query single property as environment variable' + uses: ./ + with: + file: test/resources/test.properties + keys: '${{env.SELECTED_KEY}}' + resultType: env + - name: '[assert] query single property as environment variable' + env: + RESULT_GETTER: getEnv + run: *validateSingleImpl + + ######################################################################### + test_env-with-prefix_all: + runs-on: ubuntu-latest + steps: + - *checkoutStep + # ===================================================================== + - name: '[act] query all properties as environment variables with prefix' + uses: ./ + with: + file: test/resources/test.properties + resultType: 'env:ALL_PROPS__' + - name: '[assert] query all properties as environment variables' + env: + RESULT_GETTER: 'getEnvWithPrefix ALL_PROPS__' + run: *validateAllImpl + + ######################################################################### + test_env-with-prefix_multi: + runs-on: ubuntu-latest + steps: + - *checkoutStep + # ===================================================================== + - 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] query multiple properties as environment variables with prefix' + env: + RESULT_GETTER: 'getEnvWithPrefix MULTI_PROPS__' + run: *validateMultiImpl + + ######################################################################### + test_env-with-prefix_single: + runs-on: ubuntu-latest + steps: + - *checkoutStep + # ===================================================================== + - name: '[act] query single property as environment variable with prefix' + uses: ./ + with: + file: test/resources/test.properties + 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: |- + . "$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 new file mode 100644 index 0000000..40df412 --- /dev/null +++ b/Action.java @@ -0,0 +1,615 @@ +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.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; +import java.util.Map; +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; + + +public class Action { + private Action() {} + + static void main(String[] args) throws Exception { + try { + Config config = Config.fromEnv(); + ResultWriter resultWriter = ResultWriter.ofExternalName(config.resultType()); + for (String file : args) { + 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. + Map> selectedProperties = config.selectedKeys() + .map(keys -> Util.selectProperties(properties, keys, file)) // + .orElseGet(() -> Util.stringEntries(properties)); + resultWriter.write(selectedProperties, config); + } + } catch (ExitSilentlyException e) { + System.exit(e.status); + } catch (IoRuntimeException e) { + throw e.getCause(); + } catch (OutputException e) { + try (GitHubVariableWriter writer = GitHubOutputFile.OUTPUT.open()) { + writer.write(Ids.OutputName.ERROR, e.getMessage()); + } + throw e; + } + } +} + + +/** + * Identifiers which must match those specified in the action YAML. + */ +class Ids { + private Ids() {} + + /** + * Configuration environment variable names and their corresponding action input names for error messages. + */ + enum ConfigVariable { + FILE("file"), // + ON_MISSING_FILE("onMissingFile"), // + 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; + } + } + + enum MissingFileHandlerName { + DEBUG_MESSAGE("debug-message"), // + 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"), // + 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(); + } +} + + +class IoRuntimeException extends RuntimeException { + public IoRuntimeException(IOException cause) { + super(cause); + } + + @Override + public synchronized IOException getCause() { + return (IOException) super.getCause(); + } +} + + +/** + * An exception for which an {@link Ids.OutputName#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)); + } +} + + +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); + // 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(Ids.ConfigVariable.KEYS.name(), ""); + Optional keys = Util.splitArray(keysStr, keySeparator); + String resultNameSeparator = Util.getRequiredEnv(Ids.ConfigVariable.RESULT_NAME_SEPARATOR); + + // RESULT_TYPE format: "[:]" + String resultTypeWithArg = Util.getRequiredEnv(Ids.ConfigVariable.RESULT_TYPE); + Matcher matcher = Pattern.compile("([^:]+)(?::(.*))?").matcher(resultTypeWithArg); + if (!matcher.matches()) { + 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(Ids.ConfigVariable.OUTPUT_PREFIX); + return new Config( // + MissingFileHandler.ofExternalName(Util.getRequiredEnv(Ids.ConfigVariable.ON_MISSING_FILE)), // + keys.map(List::of), keySeparator, // + resultTypeWithArg, // + resultType, // + resultTypeArg, // + resultNameSeparator, // + outputPrefix); + } + + public String requiredResultTypeArg() { + String arg = resultTypeArg(); + if ("".equals(arg)) { + throw OutputException.forIllegalArgument( + "invalid " + Ids.ConfigVariable.RESULT_TYPE + " " + resultTypeWithArg() + " (missing argument)"); + } + return arg; + } + + public void requireNoArg() { + if (!"".equals(resultTypeArg())) { + throw OutputException.forIllegalArgument( + "invalid " + Ids.ConfigVariable.RESULT_TYPE + " " + resultTypeWithArg() + " (non-empty argument)"); + } + } +} + + +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 { + 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 messageFormat, Object... messageArgs) { + super.handleMissingFile(messageFormat, messageArgs); + throw new ExitSilentlyException(2); + } + }; + + private final String externalName; + private final GithubMessageType githubMessageType; + + private MissingFileHandler(Ids.MissingFileHandlerName externalName, GithubMessageType githubMessageType) { + this.externalName = externalName.externalName; + this.githubMessageType = githubMessageType; + } + + 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 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); + } +} + + +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 : ""); + } + } + } + + 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); + } + }, + ENV_NAMED(Ids.ResultWriterName.ENV_NAMED) { + @Override + 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)); + } + } + } + }, + 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()); + } + } + }, + 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(); + } + } + }; + + private final String externalName; + + private ResultWriter(Ids.ResultWriterName externalName) { + this.externalName = externalName.externalName; + } + + public static ResultWriter ofExternalName(String externalName) { + for (ResultWriter rw : ResultWriter.values()) { + if (rw.externalName.equals(externalName)) { + return rw; + } + } + throw OutputException.forIllegalArgument("invalid " + Ids.ConfigVariable.RESULT_TYPE + ": " + externalName); + } + + public abstract void write(Map> props, Config config) 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 + ")")); + + @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(Ids.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++) { + String name = resultNames[resultNames.length == 1 ? 0 : i]; + 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. + } + }); + } + } + } +} + + +enum GitHubOutputFile { + OUTPUT("GITHUB_OUTPUT", "output"), // + ENV("GITHUB_ENV", "environment variable"); + + private final String fileName; + private final String description; + + private GitHubOutputFile(String fileNameEnvVar, String description) { + this.fileName = Util.getRequiredEnv(fileNameEnvVar); + this.description = description; + } + + public GitHubVariableWriter open() throws IOException { + return new GitHubVariableWriter(description, fileName); + } +} + + +class GitHubVariableWriter implements AutoCloseable { + private static final Pattern SIMPLE_VALUE = Pattern.compile("[\\w.-]+"); + + private final String description; + private final Writer writer; + + public GitHubVariableWriter(String description, String fileName) throws IOException { + this.description = description; + this.writer = Util.openFile(fileName, StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } + + 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) { + write(key.externalName, value); + } + + /** + * Writes a key-value pair in + * multiline + * format. + */ + private void writeMultiLine(String key, String value) throws IOException { + String separator = computeSeparator(value); + + 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'); + } + + /** + * 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 = new HashSet<>(List.of(value.split("(?s)\n"))); + String separatorPart = "----"; + String separator = separatorPart; + while (valueLines.contains(separator)) { + separator = separator + separatorPart; + } + return separator; + } + + @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 OutputException.forIllegalArgument("missing or empty environment variable " + varName); + } + return value; + } + + public static String getRequiredEnv(Ids.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)); + } + + 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, MissingFileHandler missingFileHandler) { + Properties allProps = new Properties(); + Path path = Paths.get(file); + 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(); + } + + /** + * 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<>(); + for (String key : selectedKeys) { + String value = allProps.getProperty(key); + results.put(key, Optional.ofNullable(value)); + if (value != null) { + unmatchedKeysSet.remove(key); + } + } + for (String key : unmatchedKeysSet) { + System.err.format("Property %s not found in %s%n", key, file); + } + return results; + } + + /** + * @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, Optional.of(props.getProperty(key))); + } + return 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()) { + 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")); + } + s.append('}'); + return new StringIntPair(s.toString(), size.get()); + } + + 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') { + 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); + } + } + } +} + + +record StringIntPair(String s, int i) { +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fddfed7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +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" +LABEL "repository"="https://github.com/freenet-actions/read-java-properties" +LABEL "homepage"="https://github.com/freenet-actions" + +WORKDIR /action +COPY *.java . +RUN ["chmod", "a-wx", "Action.java"] + +RUN ["javac", "Action.java"] +ENTRYPOINT ["java", "--class-path", "/action", "Action"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c265174 --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +# read-java-properties + +GitHub Action to read a Java .properties file and output one, multiple, or all properties as plain strings or JSON. + +## Usage examples: + +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-002Egradle-002Ejvmargs}}` == `-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-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. +``` +- 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: +``` +- 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. 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: "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..b1804b2 --- /dev/null +++ b/action.yml @@ -0,0 +1,76 @@ +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 + onMissingFile: + required: false + 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. + 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 "_", 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 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`. + - "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¹ 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' + +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`. + #error: Not documented, because it should only be parsed by our tests. + +runs: + using: docker + image: Dockerfile + args: + - ${{inputs.file}} + env: + ON_MISSING_FILE: ${{inputs.onMissingFile}} + KEYS: ${{inputs.keys}} + KEY_SEPARATOR: ${{inputs.keySeparator}} + RESULT_NAME_SEPARATOR: ${{inputs.resultNameSeparator}} + RESULT_TYPE: ${{inputs.resultType}} + OUTPUT_PREFIX: '_' 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 diff --git a/test/resources/test.properties b/test/resources/test.properties new file mode 100644 index 0000000..5e7d695 --- /dev/null +++ b/test/resources/test.properties @@ -0,0 +1,35 @@ +sourceJavaVersion : 21 +targetJavaVersion : 17 +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: +null: null + +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. +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_1_4_2 = ---\n----\n-\n----\n-- +dash_3_4_8_1 = ---\n----\n--------\n- diff --git a/test/test-setup.sh b/test/test-setup.sh new file mode 100644 index 0000000..efc727d --- /dev/null +++ b/test/test-setup.sh @@ -0,0 +1,172 @@ +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. +# 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 + 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 -ple 's=((?!_)[[:space:][: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}" "$@" +}