diff --git a/.github/actions/assert-equal/action.yml b/.github/actions/assert-equal/action.yml new file mode 100644 index 0000000..8c41db5 --- /dev/null +++ b/.github/actions/assert-equal/action.yml @@ -0,0 +1,22 @@ +name: Assert Equal +description: Testing action to assert whether two values are equal +inputs: + expected: + description: expected value + required: true + actual: + description: actual value + required: true +runs: + using: composite + steps: + - name: assert-equal + shell: bash + env: + expected: ${{ inputs.expected }} + actual: ${{ inputs.actual }} + run: | + if [[ "$expected" != "$actual" ]]; then + echo "::error::Expected \"$expected\", got \"$actual\"" + exit 1 + fi diff --git a/.github/workflows/test/action.yml b/.github/actions/echo/action.yml similarity index 82% rename from .github/workflows/test/action.yml rename to .github/actions/echo/action.yml index b6b9e3f..77acc04 100644 --- a/.github/workflows/test/action.yml +++ b/.github/actions/echo/action.yml @@ -3,7 +3,9 @@ description: return the input as an output inputs: echo: description: what should i say? - required: true + default: 🙊 + ignored: + description: this doesn't matter outputs: echo: description: what i said diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a17b8d9..d4c6790 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: app-id: ${{ secrets.PUSH_ID }} private-key: ${{ secrets.PUSH_KEY }} - name: Check out code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: ${{ inputs.ref }} token: ${{ steps.generate-token.outputs.token }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4d34576..e1626b5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,22 +10,42 @@ jobs: env: expected: |- here is some "text". this is not a newline: [\n] + but this is on another line steps: - name: Checkout - uses: actions/checkout@v3 - - name: Dynamic Use + uses: actions/checkout@v6 + - name: YAML inputs uses: ./. - id: parrot + id: yaml with: - uses: ./.github/workflows/test + uses: ./.github/actions/echo with: | - { "echo": ${{ toJSON(env.expected) }} } - - name: Validate outputs - env: - actual: ${{ fromJSON(steps.parrot.outputs.outputs).echo }} - shell: bash - run: | - if [[ "$expected" != "$actual" ]]; then - echo "::error::output mismatch! expected=$expected, actual=$actual" - exit 1 - fi + echo: ${{ toJSON(env.expected) }} + ignored: whatever + - uses: ./.github/actions/assert-equal + with: + expected: ${{ env.expected }} + actual: ${{ fromJSON(steps.yaml.outputs.outputs).echo }} + - name: JSON inputs + uses: ./. + id: json + with: + uses: ./.github/actions/echo + with: | + { + "echo": ${{ toJSON(env.expected) }}, + "ignored": "whatever" + } + - uses: ./.github/actions/assert-equal + with: + expected: ${{ env.expected }} + actual: ${{ fromJSON(steps.json.outputs.outputs).echo }} + - name: No inputs + uses: ./. + id: no-inputs + with: + uses: ./.github/actions/echo + - uses: ./.github/actions/assert-equal + with: + expected: 🙊 + actual: ${{ fromJSON(steps.no-inputs.outputs.outputs).echo }} \ No newline at end of file diff --git a/README.md b/README.md index 0156b42..f5c6085 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,9 @@ If you want your `uses` to be dynamic you can do: with: # now you can use expressions 🥳 uses: actions/setup-node@${{ inputs.version }} - # the `with` needs to be converted to a valid json string - with: '{ "node-version": 18 }' + # the `with` needs to be converted to a string (YAML mapping or JSON object) + with: | + node-version: 18 ``` ## Why would I want to do this? @@ -38,14 +39,16 @@ name: Deploy the stuff inputs: stuffToDeploy: description: The stuff -steps: - - shell: bash - env: - stuffToDeploy: ${{ inputs.stuffToDeploy }} - run: some-deploy-command "$stuffToDeploy" - - uses: my-cool-org/repo/actions/cleanup@v3 - with: - stuffToCleanUp: ${{ inputs.stuffToDeploy }} +runs: + using: composite + steps: + - shell: bash + env: + stuffToDeploy: ${{ inputs.stuffToDeploy }} + run: some-deploy-command "$stuffToDeploy" + - uses: my-cool-org/repo/actions/cleanup@v3 + with: + stuffToCleanUp: ${{ inputs.stuffToDeploy }} ``` Because the `uses` is hardcoded, it will always use `cleanup@v3`. This makes it challenging to test how `deploy` will work with a new version of `cleanup`, as you have to create and trigger one-off workflows to validate a new version before it lands. Ideally you could `use` a path instead, but that only works for workflows that have checked out `my-cool-org/repo`; the `deploy` action is much harder to reuse if you have to do that (i.e. imagine these actions are used by various other repos in the `my-cool-org` org). @@ -62,7 +65,8 @@ Taking our example above, we can make it work however we need to with `dynamic-u # - from outside the repo, we want the `action_ref` # (we pass it through env, otherwise it picks up `v1` from `jenseng/dynamic-uses@v1`) uses: my-cool-org/repo/actions/cleanup@${{ github.repository == 'my-cool-org/repo' && github.sha || env.action_ref }} - with: '{ "stuffToCleanUp": ${{ toJSON(inputs.stuffToDeploy) }} }' + with: | + stuffToCleanUp: ${{ toJSON(inputs.stuffToDeploy) }} } ``` ## How does it work? @@ -73,7 +77,7 @@ Because the action is referenced by path, it satisfies the parser. By the time i ## Specifying `with` inputs -JSON and quoting can get tricky, so here are some tips to ensure your `with` inputs work safely and correctly: +YAML and quoting can get tricky, so here are some tips to ensure your `with` inputs work safely and correctly: ### Use a multi-line string @@ -82,62 +86,67 @@ By using a multi-line string, you can keep things fairly manageable, and dealing For example, instead of this: ```yaml -with: '{ "environment": "test", "cluster": "", "user": "Reilly O''Reilly" }' +# 👎 escape sequences, hard to read +with: "environment: test\ncluster: ''\nuser: John \"Reilly\" O'Reilly" ``` -or this: +Prefer this: ```yaml -with: "{ \"environment\": \"test\", \"cluster\": \"\", \"user\": \"Reilly O'Reilly\" }" +# 👍 simple, readable +with: | + environment: test + cluster: "" + user: John "Reilly" O'Reilly ``` -Prefer this: +Since JSON is a subset of YAML, you can also specify `with` as a JSON object: ```yaml +# 👍 a literal JSON object with: | { "environment": "test", "cluster": "", - "user": "Reilly O'Reilly" + "user": "John \"Reilly\" O'Reilly" } +# 👍 a complete JSON object from somewhere else +with: ${{ toJSON(inputs) }} ``` ### Use `toJSON` for anything dynamic -If you have any expressions in the `with` string, you should use `toJSON` to ensure they are handled correctly. This will protect against malicious user input (e.g. `github.event.pull_request.title`), as well as mistakes that can break quoting or escape sequences (e.g. `env.trustedValueThatMightHaveQuotes`). +If you have any expressions in the `with` string, you should use `toJSON` to ensure they are handled correctly. This will protect against malicious user input (e.g. `github.event.pull_request.title`), as well as mistakes that can break quoting, indentation, or escape sequences (e.g. `env.trustedValueThatMightHaveQuotes`). For example, instead of this: ```yaml +# 👎 an attacker could use a specially crafted PR title to inject additional inputs with: | - { - "title": "${{ github.event.pull_request.title }}", - "message": "Testing ${{ github.event.pull_request.title }}" - } - + title: ${{ github.event.pull_request.title }} + message: "Testing ${{ github.event.pull_request.title }}" ``` You should instead let `toJSON` handle the quoting/escaping: ```yaml +# 👍 all inputs are handled safely and correctly with: | - { - "title": ${{ toJSON(github.event.pull_request.title) }}, - "message": ${{ toJSON(format('Testing {0}', github.event.pull_request.title)) }} - } - + title: ${{ toJSON(github.event.pull_request.title) }} + message: ${{ toJSON(format('Testing {0}', github.event.pull_request.title)) }} ``` ## Gotchas/limitations -- The `with` inputs to the action need to be converted to a single JSON object string (see examples above) +- The `with` inputs to the action need to be converted to a YAML mapping string (see examples above) - All outputs from the action will be serialized as a JSON object output named `outputs` . You can access specific outputs by using the `fromJSON` helper in an expression. For example: ```yaml - id: setup_node uses: jenseng/dynamic-uses@v1 with: uses: actions/setup-node@${{ inputs.version }} - with: '{ "node-version": 18 }' + with: | + node-version: 18 - env: # pull the node-version out of the outputs node_version: ${{ fromJSON(steps.setup_node.outputs.outputs).node-version }} diff --git a/action.yml b/action.yml index 268ea91..cd0124e 100644 --- a/action.yml +++ b/action.yml @@ -6,7 +6,7 @@ inputs: description: Action reference or path, e.g. `actions/setup-node@v3` required: true with: - description: 'JSON-ified `inputs` for the action, e.g. `{"node-version": "18"}`' + description: 'YAML/JSON string of `inputs` for the action, e.g. `node-version: 18` or `{"node-version": "18"}`' required: false default: "{}" outputs: @@ -18,12 +18,14 @@ runs: steps: - name: Setup shell: bash + env: + with: ${{ inputs.with }} run: | mkdir -p ./.tmp-dynamic-uses && - cat <<'DYNAMIC_USES_EOF' >./.tmp-dynamic-uses/action.yml + cat <./.tmp-dynamic-uses/action.yml outputs: outputs: - value: ${{ '$' }}{{ toJSON(steps.run.outputs) }} + value: ${{ '\$' }}{{ toJSON(steps.run.outputs) }} runs: using: composite steps: @@ -31,8 +33,9 @@ runs: shell: bash - name: Run id: run - uses: ${{ inputs.uses }} - with: ${{ inputs.with || '{}' }} + uses: ${{ toJSON(inputs.uses) }} + with: + $(echo "$with" | sed 's/^/ /') DYNAMIC_USES_EOF - name: Run id: run