Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/actions/assert-equal/action.yml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
48 changes: 34 additions & 14 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
69 changes: 39 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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).
Expand All @@ -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?
Expand All @@ -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

Expand All @@ -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 }}
Expand Down
13 changes: 8 additions & 5 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -18,21 +18,24 @@ 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 <<DYNAMIC_USES_EOF >./.tmp-dynamic-uses/action.yml
outputs:
outputs:
value: ${{ '$' }}{{ toJSON(steps.run.outputs) }}
value: ${{ '\$' }}{{ toJSON(steps.run.outputs) }}
runs:
using: composite
steps:
- run: rm -rf ./.tmp-dynamic-uses
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
Expand Down